feat(auth-ui): complete Auth UI Kit (3.2) — 7 new components, 54 tests
New components: - RegisterForm — name, email, password, confirm, terms, password strength - ForgotPasswordForm — email input with success/error states, back link - ResetPasswordForm — new password + confirm with strength indicator - VerifyEmailForm — 6-digit code input with resend, numeric-only filter - OnboardingShell — step indicator, progress bar, back/next/complete nav - AuthPageLayout — full-page centered card with product branding - PasswordStrengthBar — visual bar + label (weak/fair/good/strong) Existing components preserved: LoginForm, MfaChallenge, SocialButtons All styled via --bl-* CSS custom properties for product theming 54 tests (13 existing + 41 new) — all passing
This commit is contained in:
parent
f051942ef6
commit
43439e9c85
101
packages/auth-ui/src/AuthPageLayout.tsx
Normal file
101
packages/auth-ui/src/AuthPageLayout.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { AuthPageLayoutProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page auth layout — centered card with product branding.
|
||||||
|
* Wraps any auth form (LoginForm, RegisterForm, etc.).
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function AuthPageLayout({
|
||||||
|
productName,
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
}: AuthPageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
data-testid="bl-auth-page"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--bl-page-bg, #f5f5f5)',
|
||||||
|
fontFamily: 'var(--bl-font, system-ui, -apple-system, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
background: 'var(--bl-surface, #fff)',
|
||||||
|
borderRadius: 'var(--bl-card-radius, 12px)',
|
||||||
|
boxShadow: 'var(--bl-card-shadow, 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06))',
|
||||||
|
padding: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Branding */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||||
|
{logo && (
|
||||||
|
<div style={{ marginBottom: '12px' }} data-testid="bl-auth-logo">
|
||||||
|
{typeof logo === 'string' ? (
|
||||||
|
<img src={logo} alt={productName} style={{ height: '48px' }} />
|
||||||
|
) : (
|
||||||
|
logo
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '20px', fontWeight: 700, color: 'var(--bl-text, #111)' }}
|
||||||
|
data-testid="bl-auth-product-name"
|
||||||
|
>
|
||||||
|
{productName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
marginTop: '8px',
|
||||||
|
}}
|
||||||
|
data-testid="bl-auth-title"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '14px', color: 'var(--bl-muted, #666)', marginTop: '4px' }}
|
||||||
|
data-testid="bl-auth-subtitle"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form content */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
paddingTop: '16px',
|
||||||
|
borderTop: '1px solid var(--bl-border, #eee)',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--bl-muted, #999)',
|
||||||
|
}}
|
||||||
|
data-testid="bl-auth-footer"
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
packages/auth-ui/src/ForgotPasswordForm.tsx
Normal file
111
packages/auth-ui/src/ForgotPasswordForm.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { ForgotPasswordFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgot password form — email input to request a reset link.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function ForgotPasswordForm({
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
onBack,
|
||||||
|
className,
|
||||||
|
}: ForgotPasswordFormProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-forgot-password-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter your email address and we'll send you a link to reset your password.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-forgot-email"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-forgot-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-forgot-success"
|
||||||
|
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || email.length === 0}
|
||||||
|
data-testid="bl-forgot-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
data-testid="bl-forgot-back"
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
packages/auth-ui/src/OnboardingShell.tsx
Normal file
148
packages/auth-ui/src/OnboardingShell.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import type { OnboardingShellProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding shell — step indicator, navigation, progress bar.
|
||||||
|
* Wraps arbitrary step content provided via children.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function OnboardingShell({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onComplete,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: OnboardingShellProps) {
|
||||||
|
const isFirst = currentStep === 0;
|
||||||
|
const isLast = currentStep === steps.length - 1;
|
||||||
|
const progress = steps.length > 1 ? ((currentStep + 1) / steps.length) * 100 : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-onboarding-shell">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'var(--bl-border, #e5e7eb)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="bl-onboarding-progress"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progress}%`,
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div
|
||||||
|
data-testid="bl-onboarding-steps"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={step.key}
|
||||||
|
data-testid={`bl-onboarding-step-${step.key}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color:
|
||||||
|
i === currentStep
|
||||||
|
? 'var(--bl-primary, #0066ff)'
|
||||||
|
: i < currentStep
|
||||||
|
? 'var(--bl-success, #22c55e)'
|
||||||
|
: 'var(--bl-muted, #999)',
|
||||||
|
fontWeight: i === currentStep ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
background:
|
||||||
|
i <= currentStep ? 'var(--bl-primary, #0066ff)' : 'var(--bl-border, #e5e7eb)',
|
||||||
|
color: i <= currentStep ? '#fff' : 'var(--bl-muted, #999)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i < currentStep ? '✓' : i + 1}
|
||||||
|
</span>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div data-testid="bl-onboarding-content" style={{ marginBottom: '24px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isFirst}
|
||||||
|
data-testid="bl-onboarding-back"
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-surface, #fff)',
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
cursor: isFirst ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
opacity: isFirst ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={isLast ? onComplete : onNext}
|
||||||
|
data-testid={isLast ? 'bl-onboarding-complete' : 'bl-onboarding-next'}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLast ? 'Complete' : 'Next'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
packages/auth-ui/src/PasswordStrengthBar.tsx
Normal file
67
packages/auth-ui/src/PasswordStrengthBar.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { PasswordStrength } from './types.js';
|
||||||
|
|
||||||
|
interface PasswordStrengthBarProps {
|
||||||
|
password: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRENGTH_CONFIG: Record<PasswordStrength, { color: string; label: string }> = {
|
||||||
|
weak: { color: 'var(--bl-error, #dc3545)', label: 'Weak' },
|
||||||
|
fair: { color: 'var(--bl-warning, #f59e0b)', label: 'Fair' },
|
||||||
|
good: { color: 'var(--bl-info, #3b82f6)', label: 'Good' },
|
||||||
|
strong: { color: 'var(--bl-success, #22c55e)', label: 'Strong' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPasswordStrength(password: string): PasswordStrength {
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (password.length >= 12) score++;
|
||||||
|
if (/[A-Z]/.test(password)) score++;
|
||||||
|
if (/[a-z]/.test(password)) score++;
|
||||||
|
if (/\d/.test(password)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
|
if (score <= 2) return 'weak';
|
||||||
|
if (score <= 3) return 'fair';
|
||||||
|
if (score <= 4) return 'good';
|
||||||
|
return 'strong';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordStrengthBar({ password, className }: PasswordStrengthBarProps) {
|
||||||
|
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
||||||
|
const config = STRENGTH_CONFIG[strength];
|
||||||
|
const widthPercent = { weak: 25, fair: 50, good: 75, strong: 100 }[strength];
|
||||||
|
|
||||||
|
if (!password) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-password-strength">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'var(--bl-border, #e5e7eb)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${widthPercent}%`,
|
||||||
|
background: config.color,
|
||||||
|
transition: 'width 0.2s, background 0.2s',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}
|
||||||
|
data-testid="bl-password-strength-fill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '12px', color: config.color, marginTop: '4px' }}
|
||||||
|
data-testid="bl-password-strength-label"
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
packages/auth-ui/src/RegisterForm.tsx
Normal file
226
packages/auth-ui/src/RegisterForm.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { PasswordStrengthBar } from './PasswordStrengthBar.js';
|
||||||
|
import type { RegisterFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration form with name, email, password, confirm password,
|
||||||
|
* password strength indicator, and optional terms checkbox.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function RegisterForm({
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
termsUrl,
|
||||||
|
privacyUrl,
|
||||||
|
onSwitchToLogin,
|
||||||
|
className,
|
||||||
|
}: RegisterFormProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [termsAccepted, setTermsAccepted] = useState(!termsUrl);
|
||||||
|
|
||||||
|
const passwordMismatch = confirm.length > 0 && password !== confirm;
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
email.length > 0 &&
|
||||||
|
password.length >= 8 &&
|
||||||
|
!passwordMismatch &&
|
||||||
|
termsAccepted &&
|
||||||
|
!isLoading;
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onSubmit({ name: name.trim(), email, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-register-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-name"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-email"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-password"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<PasswordStrengthBar password={password} />
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Confirm password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-confirm"
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
marginTop: '4px',
|
||||||
|
display: 'block',
|
||||||
|
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{passwordMismatch && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-register-mismatch"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
Passwords do not match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{termsUrl && (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--bl-text, #333)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={termsAccepted}
|
||||||
|
onChange={e => setTermsAccepted(e.target.checked)}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-register-terms"
|
||||||
|
style={{ marginTop: '2px' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I agree to the{' '}
|
||||||
|
<a
|
||||||
|
href={termsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--bl-link, #0066ff)' }}
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
{privacyUrl && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
and{' '}
|
||||||
|
<a
|
||||||
|
href={privacyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--bl-link, #0066ff)' }}
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-register-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-testid="bl-register-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: !canSubmit ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: !canSubmit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onSwitchToLogin && (
|
||||||
|
<div style={{ textAlign: 'center', fontSize: '13px', color: 'var(--bl-muted, #999)' }}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
data-testid="bl-register-switch-login"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
packages/auth-ui/src/ResetPasswordForm.tsx
Normal file
131
packages/auth-ui/src/ResetPasswordForm.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { PasswordStrengthBar } from './PasswordStrengthBar.js';
|
||||||
|
import type { ResetPasswordFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password form — new password + confirm, with strength indicator.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function ResetPasswordForm({
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
className,
|
||||||
|
}: ResetPasswordFormProps) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
|
||||||
|
const passwordMismatch = confirm.length > 0 && password !== confirm;
|
||||||
|
const canSubmit = password.length >= 8 && !passwordMismatch && !isLoading;
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onSubmit(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-reset-password-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter your new password.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
New password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-reset-password"
|
||||||
|
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<PasswordStrengthBar password={password} />
|
||||||
|
|
||||||
|
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
||||||
|
Confirm password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-reset-confirm"
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
marginTop: '4px',
|
||||||
|
display: 'block',
|
||||||
|
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{passwordMismatch && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-reset-mismatch"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
Passwords do not match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-reset-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-reset-success"
|
||||||
|
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-testid="bl-reset-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: !canSubmit ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: !canSubmit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Updating...' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
packages/auth-ui/src/VerifyEmailForm.tsx
Normal file
117
packages/auth-ui/src/VerifyEmailForm.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { VerifyEmailFormProps } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification form — 6-digit code input with resend option.
|
||||||
|
* Styled via CSS custom properties (inherits --bl-* from host app).
|
||||||
|
*/
|
||||||
|
export function VerifyEmailForm({
|
||||||
|
onSubmit,
|
||||||
|
onResend,
|
||||||
|
isLoading = false,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
email,
|
||||||
|
className,
|
||||||
|
}: VerifyEmailFormProps) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="bl-verify-email-form">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
||||||
|
Enter the 6-digit code sent to {email ? <strong>{email}</strong> : 'your email'}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="000000"
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={6}
|
||||||
|
data-testid="bl-verify-code"
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
border: '1px solid var(--bl-border, #ccc)',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
fontSize: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: '6px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-verify-error"
|
||||||
|
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-verify-success"
|
||||||
|
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || code.length < 6}
|
||||||
|
data-testid="bl-verify-submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--bl-radius, 6px)',
|
||||||
|
background: 'var(--bl-primary, #0066ff)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: isLoading || code.length < 6 ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isLoading || code.length < 6 ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Verifying...' : 'Verify email'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onResend && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResend}
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="bl-verify-resend"
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--bl-link, #0066ff)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
401
packages/auth-ui/src/__tests__/new-components.test.tsx
Normal file
401
packages/auth-ui/src/__tests__/new-components.test.tsx
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { RegisterForm } from '../RegisterForm.js';
|
||||||
|
import { ForgotPasswordForm } from '../ForgotPasswordForm.js';
|
||||||
|
import { ResetPasswordForm } from '../ResetPasswordForm.js';
|
||||||
|
import { VerifyEmailForm } from '../VerifyEmailForm.js';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell.js';
|
||||||
|
import { AuthPageLayout } from '../AuthPageLayout.js';
|
||||||
|
import { PasswordStrengthBar, getPasswordStrength } from '../PasswordStrengthBar.js';
|
||||||
|
|
||||||
|
describe('RegisterForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders all fields', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-register-name')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-email')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-password')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-confirm')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-register-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with name, email, password', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<RegisterForm onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-name'), { target: { value: 'Alice' } });
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-email'), {
|
||||||
|
target: { value: 'alice@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-password'), {
|
||||||
|
target: { value: 'Password1!' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-confirm'), {
|
||||||
|
target: { value: 'Password1!' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-register-submit').closest('form')!);
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
name: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
password: 'Password1!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password mismatch error', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-password'), {
|
||||||
|
target: { value: 'Password1!' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-confirm'), {
|
||||||
|
target: { value: 'Different1!' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bl-register-mismatch')).toBeDefined();
|
||||||
|
expect(screen.getByText('Passwords do not match')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} error="Email already taken" />);
|
||||||
|
expect(screen.getByTestId('bl-register-error')).toBeDefined();
|
||||||
|
expect(screen.getByText('Email already taken')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows terms checkbox when termsUrl provided', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} termsUrl="https://example.com/terms" />);
|
||||||
|
expect(screen.getByTestId('bl-register-terms')).toBeDefined();
|
||||||
|
expect(screen.getByText('Terms of Service')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders switch to login link', () => {
|
||||||
|
const onSwitch = vi.fn();
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} onSwitchToLogin={onSwitch} />);
|
||||||
|
const link = screen.getByTestId('bl-register-switch-login');
|
||||||
|
fireEvent.click(link);
|
||||||
|
expect(onSwitch).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} isLoading />);
|
||||||
|
expect(screen.getByText('Creating account...')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password strength bar when typing', () => {
|
||||||
|
render(<RegisterForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-register-password'), { target: { value: 'ab' } });
|
||||||
|
expect(screen.getByTestId('bl-password-strength')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ForgotPasswordForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders email input and submit', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-forgot-email')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-forgot-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with email', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<ForgotPasswordForm onSubmit={onSubmit} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-forgot-email'), {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-forgot-submit').closest('form')!);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} error="Email not found" />);
|
||||||
|
expect(screen.getByTestId('bl-forgot-error')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays success message', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} success="Check your email" />);
|
||||||
|
expect(screen.getByTestId('bl-forgot-success')).toBeDefined();
|
||||||
|
expect(screen.getByText('Check your email')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders back button and calls onBack', () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} onBack={onBack} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-forgot-back'));
|
||||||
|
expect(onBack).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
render(<ForgotPasswordForm onSubmit={vi.fn()} isLoading />);
|
||||||
|
expect(screen.getByText('Sending...')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResetPasswordForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders password fields and submit', () => {
|
||||||
|
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-reset-password')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-reset-confirm')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-reset-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with password', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<ResetPasswordForm onSubmit={onSubmit} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } });
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'NewPass1!' } });
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-reset-submit').closest('form')!);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('NewPass1!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows mismatch error', () => {
|
||||||
|
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } });
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'Different!' } });
|
||||||
|
expect(screen.getByTestId('bl-reset-mismatch')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error and success messages', () => {
|
||||||
|
const { rerender } = render(<ResetPasswordForm onSubmit={vi.fn()} error="Token expired" />);
|
||||||
|
expect(screen.getByTestId('bl-reset-error')).toBeDefined();
|
||||||
|
|
||||||
|
rerender(<ResetPasswordForm onSubmit={vi.fn()} success="Password updated" />);
|
||||||
|
expect(screen.getByTestId('bl-reset-success')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password strength bar', () => {
|
||||||
|
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'abc' } });
|
||||||
|
expect(screen.getByTestId('bl-password-strength')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VerifyEmailForm', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders code input and submit', () => {
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('bl-verify-code')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-verify-submit')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSubmit with code', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<VerifyEmailForm onSubmit={onSubmit} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-verify-code'), { target: { value: '123456' } });
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-verify-submit').closest('form')!);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays email address', () => {
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} email="test@example.com" />);
|
||||||
|
expect(screen.getByText('test@example.com')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders resend button', () => {
|
||||||
|
const onResend = vi.fn();
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} onResend={onResend} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-verify-resend'));
|
||||||
|
expect(onResend).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error and success messages', () => {
|
||||||
|
const { rerender } = render(<VerifyEmailForm onSubmit={vi.fn()} error="Invalid code" />);
|
||||||
|
expect(screen.getByTestId('bl-verify-error')).toBeDefined();
|
||||||
|
|
||||||
|
rerender(<VerifyEmailForm onSubmit={vi.fn()} success="Code resent" />);
|
||||||
|
expect(screen.getByTestId('bl-verify-success')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips non-numeric characters', () => {
|
||||||
|
render(<VerifyEmailForm onSubmit={vi.fn()} />);
|
||||||
|
const input = screen.getByTestId('bl-verify-code');
|
||||||
|
fireEvent.change(input, { target: { value: 'abc123def456' } });
|
||||||
|
expect((input as unknown as { value: string }).value).toBe('123456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OnboardingShell', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'welcome', label: 'Welcome' },
|
||||||
|
{ key: 'profile', label: 'Profile' },
|
||||||
|
{ key: 'preferences', label: 'Preferences' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders steps and content', () => {
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={0}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div>Step 1 content</div>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-onboarding-shell')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-onboarding-steps')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-onboarding-progress')).toBeDefined();
|
||||||
|
expect(screen.getByText('Step 1 content')).toBeDefined();
|
||||||
|
expect(screen.getByText('Welcome')).toBeDefined();
|
||||||
|
expect(screen.getByText('Profile')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Back on first step', () => {
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={0}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
const back = screen.getByTestId('bl-onboarding-back');
|
||||||
|
expect(back.getAttribute('disabled')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onNext on middle step', () => {
|
||||||
|
const onNext = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={1}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-onboarding-next'));
|
||||||
|
expect(onNext).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Complete on last step and calls onComplete', () => {
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={2}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onComplete={onComplete}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
const btn = screen.getByTestId('bl-onboarding-complete');
|
||||||
|
expect(btn.textContent).toBe('Complete');
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onComplete).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onBack on non-first step', () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingShell
|
||||||
|
steps={steps}
|
||||||
|
currentStep={1}
|
||||||
|
onNext={vi.fn()}
|
||||||
|
onBack={onBack}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-onboarding-back'));
|
||||||
|
expect(onBack).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthPageLayout', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders product name and title', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout productName="TestApp" title="Sign In">
|
||||||
|
<div>Form content</div>
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-auth-product-name').textContent).toBe('TestApp');
|
||||||
|
expect(screen.getByTestId('bl-auth-title').textContent).toBe('Sign In');
|
||||||
|
expect(screen.getByText('Form content')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders subtitle when provided', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout productName="TestApp" title="Sign In" subtitle="Welcome back">
|
||||||
|
<div />
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders logo as element', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout
|
||||||
|
productName="TestApp"
|
||||||
|
title="Sign In"
|
||||||
|
logo={<span data-testid="custom-logo">Logo</span>}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('custom-logo')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders footer', () => {
|
||||||
|
render(
|
||||||
|
<AuthPageLayout productName="TestApp" title="Sign In" footer={<span>Footer text</span>}>
|
||||||
|
<div />
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-auth-footer')).toBeDefined();
|
||||||
|
expect(screen.getByText('Footer text')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PasswordStrengthBar', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('returns null for empty password', () => {
|
||||||
|
const { container } = render(<PasswordStrengthBar password="" />);
|
||||||
|
expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Weak for short password', () => {
|
||||||
|
render(<PasswordStrengthBar password="ab" />);
|
||||||
|
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Strong for complex password', () => {
|
||||||
|
render(<PasswordStrengthBar password="MyStr0ng!Pass" />);
|
||||||
|
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Strong');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPasswordStrength', () => {
|
||||||
|
it('returns weak for very short passwords', () => {
|
||||||
|
expect(getPasswordStrength('ab')).toBe('weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fair for medium passwords', () => {
|
||||||
|
expect(getPasswordStrength('abcdefgh1')).toBe('fair');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns good for decent passwords', () => {
|
||||||
|
expect(getPasswordStrength('Abcdefgh1')).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns strong for complex passwords', () => {
|
||||||
|
expect(getPasswordStrength('MyStr0ng!Pass')).toBe('strong');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,9 +1,24 @@
|
|||||||
export { LoginForm } from './LoginForm.js';
|
export { LoginForm } from './LoginForm.js';
|
||||||
|
export { RegisterForm } from './RegisterForm.js';
|
||||||
|
export { ForgotPasswordForm } from './ForgotPasswordForm.js';
|
||||||
|
export { ResetPasswordForm } from './ResetPasswordForm.js';
|
||||||
|
export { VerifyEmailForm } from './VerifyEmailForm.js';
|
||||||
export { MfaChallenge } from './MfaChallenge.js';
|
export { MfaChallenge } from './MfaChallenge.js';
|
||||||
export { SocialButtons } from './SocialButtons.js';
|
export { SocialButtons } from './SocialButtons.js';
|
||||||
|
export { OnboardingShell } from './OnboardingShell.js';
|
||||||
|
export { AuthPageLayout } from './AuthPageLayout.js';
|
||||||
|
export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js';
|
||||||
export type {
|
export type {
|
||||||
LoginFormProps,
|
LoginFormProps,
|
||||||
|
RegisterFormProps,
|
||||||
|
ForgotPasswordFormProps,
|
||||||
|
ResetPasswordFormProps,
|
||||||
|
VerifyEmailFormProps,
|
||||||
MfaChallengeProps,
|
MfaChallengeProps,
|
||||||
SocialButtonsProps,
|
SocialButtonsProps,
|
||||||
SocialProvider,
|
SocialProvider,
|
||||||
|
OnboardingShellProps,
|
||||||
|
OnboardingStep,
|
||||||
|
AuthPageLayoutProps,
|
||||||
|
PasswordStrength,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|||||||
@ -40,3 +40,108 @@ export interface SocialButtonsProps {
|
|||||||
/** Additional CSS class for the root element. */
|
/** Additional CSS class for the root element. */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterFormProps {
|
||||||
|
/** Called when user submits registration. */
|
||||||
|
onSubmit: (data: { name: string; email: string; password: string }) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Terms of service URL (renders checkbox if provided). */
|
||||||
|
termsUrl?: string;
|
||||||
|
/** Privacy policy URL. */
|
||||||
|
privacyUrl?: string;
|
||||||
|
/** Called when user clicks "Already have an account?" */
|
||||||
|
onSwitchToLogin?: () => void;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordFormProps {
|
||||||
|
/** Called when user submits email for password reset. */
|
||||||
|
onSubmit: (email: string) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Success message (e.g., "Check your email"). */
|
||||||
|
success?: string | null;
|
||||||
|
/** Called when user clicks "Back to login". */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordFormProps {
|
||||||
|
/** Called when user submits new password. */
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Success message (e.g., "Password updated"). */
|
||||||
|
success?: string | null;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyEmailFormProps {
|
||||||
|
/** Called when user submits the verification code. */
|
||||||
|
onSubmit: (code: string) => void;
|
||||||
|
/** Called when user clicks "Resend code". */
|
||||||
|
onResend?: () => void;
|
||||||
|
/** Whether the form is currently loading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message to display. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Success message (e.g., "Code resent"). */
|
||||||
|
success?: string | null;
|
||||||
|
/** Email address being verified (for display). */
|
||||||
|
email?: string;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingStep {
|
||||||
|
/** Unique key for the step. */
|
||||||
|
key: string;
|
||||||
|
/** Display label for the step indicator. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingShellProps {
|
||||||
|
/** Ordered list of steps. */
|
||||||
|
steps: OnboardingStep[];
|
||||||
|
/** Index of the current step (0-based). */
|
||||||
|
currentStep: number;
|
||||||
|
/** Called when user clicks Next. */
|
||||||
|
onNext: () => void;
|
||||||
|
/** Called when user clicks Back. */
|
||||||
|
onBack: () => void;
|
||||||
|
/** Called when the final step completes. */
|
||||||
|
onComplete: () => void;
|
||||||
|
/** Content to render for the current step. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPageLayoutProps {
|
||||||
|
/** Product name displayed at the top. */
|
||||||
|
productName: string;
|
||||||
|
/** Optional logo element or URL. */
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
/** Page title (e.g., "Sign In", "Create Account"). */
|
||||||
|
title: string;
|
||||||
|
/** Subtitle or description. */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Form content. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Footer content (links, etc.). */
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
/** Additional CSS class for the root element. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user