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 { 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';
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user