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:
saravanakumardb1 2026-03-19 20:25:57 -07:00
parent f051942ef6
commit 43439e9c85
10 changed files with 1422 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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');
});
});

View File

@ -1,9 +1,24 @@
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 { SocialButtons } from './SocialButtons.js';
export { OnboardingShell } from './OnboardingShell.js';
export { AuthPageLayout } from './AuthPageLayout.js';
export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js';
export type {
LoginFormProps,
RegisterFormProps,
ForgotPasswordFormProps,
ResetPasswordFormProps,
VerifyEmailFormProps,
MfaChallengeProps,
SocialButtonsProps,
SocialProvider,
OnboardingShellProps,
OnboardingStep,
AuthPageLayoutProps,
PasswordStrength,
} from './types.js';

View File

@ -40,3 +40,108 @@ export interface SocialButtonsProps {
/** Additional CSS class for the root element. */
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';