Four fixes for the MFA sign-in flow: 1. Fix passkey verify crash: extract authenticationOptions from Logto response (was passing full response as optionsJSON). Pass verificationId to the verify endpoint. 2. Default to passkey verification when no MFA method preference is stored (was showing method picker which offered TOTP to passkey-only users). 3. Show backup codes after MFA enrollment: new mfaEnrollBackupCodes mode with copy/download buttons and confirmation checkbox. Users must save codes before completing sign-in. 4. Remove duplicate error alerts in enrollment screens (top-level alert handles all modes). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
932 lines
35 KiB
TypeScript
932 lines
35 KiB
TypeScript
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|
import { Eye, EyeOff } from 'lucide-react';
|
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
|
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
|
import {
|
|
signIn, startRegistration, completeRegistration,
|
|
startForgotPassword, forgotPasswordVerifyAndReset,
|
|
verifyTotp, verifyBackupCode, submitMfa,
|
|
startWebAuthnAuth, verifyWebAuthnAuth,
|
|
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
|
|
generateBackupCodes, createTotpSecret, verifyTotpSetup,
|
|
skipMfaEnrollment, submitInteraction,
|
|
MfaRequiredError, MfaEnrollmentError,
|
|
} from './experience-api';
|
|
import styles from './SignInPage.module.css';
|
|
|
|
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp' | 'mfaEnrollBackupCodes';
|
|
|
|
const SIGN_IN_SUBTITLES = [
|
|
"Prove you're not a mirage",
|
|
"Only authorized cameleers beyond this dune",
|
|
"Halt, traveler — state your business",
|
|
"The caravan doesn't move without credentials",
|
|
"No hitchhikers on this caravan",
|
|
"This oasis requires a password",
|
|
"Camels remember faces. We use passwords.",
|
|
"You shall not pass... without logging in",
|
|
"The desert is vast. Your session has expired.",
|
|
"Another day, another dune to authenticate",
|
|
"Papers, please. The caravan master is watching.",
|
|
"Trust, but verify — ancient cameleer proverb",
|
|
"Even the Silk Road had checkpoints",
|
|
"Your camel is parked outside. Now identify yourself.",
|
|
"One does not simply walk into the dashboard",
|
|
"The sands shift, but your password shouldn't",
|
|
"Unauthorized access? In this economy?",
|
|
"Welcome back, weary traveler",
|
|
"The dashboard awaits on the other side of this dune",
|
|
"Keep calm and authenticate",
|
|
"Who goes there? Friend or rogue exchange?",
|
|
"Access denied looks the same in every desert",
|
|
"May your routes be green and your tokens valid",
|
|
"Forgot your password? That's between you and the dunes.",
|
|
"No ticket, no caravan",
|
|
];
|
|
|
|
const REGISTER_SUBTITLES = [
|
|
"Every great journey starts with a single sign-up",
|
|
"Welcome to the caravan — let's get you registered",
|
|
"A new cameleer approaches the oasis",
|
|
"Join the caravan. We have dashboards.",
|
|
"The desert is better with company",
|
|
"First time here? The camels don't bite.",
|
|
"Pack your bags, you're joining the caravan",
|
|
"Room for one more on this caravan",
|
|
"New rider? Excellent. Credentials, please.",
|
|
"The Silk Road awaits — just need your email first",
|
|
];
|
|
|
|
function pickRandom(arr: string[]) {
|
|
return arr[Math.floor(Math.random() * arr.length)];
|
|
}
|
|
|
|
function getInitialMode(): Mode {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get('first_screen') === 'register') return 'register';
|
|
if (window.location.pathname.endsWith('/register')) return 'register';
|
|
return 'signIn';
|
|
}
|
|
|
|
export function SignInPage() {
|
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
|
const [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
|
|
const subtitle = useMemo(
|
|
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
|
[mode === 'signIn' ? 'signIn' : 'register'],
|
|
);
|
|
|
|
const [identifier, setIdentifier] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [code, setCode] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [verificationId, setVerificationId] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
|
const [webauthnError, setWebauthnError] = useState('');
|
|
const [webauthnLoading, setWebauthnLoading] = useState(false);
|
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
|
const [backupCodesSaved, setBackupCodesSaved] = useState(false);
|
|
|
|
// Fetch sign-in experience to check if registration is enabled
|
|
useEffect(() => {
|
|
fetch('/api/.well-known/sign-in-exp')
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
const enabled = data.signInMode === 'SignInAndRegister';
|
|
setRegistrationEnabled(enabled);
|
|
if (!enabled && mode !== 'signIn') setMode('signIn');
|
|
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
|
|
setEmailConnectorConfigured(hasEmailConnector);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Reset error when switching modes
|
|
useEffect(() => { setError(null); }, [mode]);
|
|
|
|
const switchMode = (next: Mode) => {
|
|
setMode(next);
|
|
setPassword('');
|
|
setConfirmPassword('');
|
|
setNewPassword('');
|
|
setConfirmNewPassword('');
|
|
setCode('');
|
|
setShowPassword(false);
|
|
setVerificationId('');
|
|
};
|
|
|
|
// --- Sign-in ---
|
|
const handleSignIn = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const redirectTo = await signIn(identifier, password);
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
if (err instanceof MfaRequiredError) {
|
|
const pref = localStorage.getItem('mfa_method_preference');
|
|
if (pref === 'totp') {
|
|
setMode('mfaVerify');
|
|
} else {
|
|
setMode('mfaWebauthn');
|
|
}
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (err instanceof MfaEnrollmentError) {
|
|
setMode('mfaEnroll');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- Register step 1: send verification code ---
|
|
const handleRegister = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
if (!identifier.includes('@')) {
|
|
setError('Please enter a valid email address');
|
|
return;
|
|
}
|
|
if (password !== confirmPassword) {
|
|
setError('Passwords do not match');
|
|
return;
|
|
}
|
|
if (password.length < 8) {
|
|
setError('Password must be at least 8 characters');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const vId = await startRegistration(identifier);
|
|
setVerificationId(vId);
|
|
setMode('verifyCode');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- Register step 2: verify code + complete ---
|
|
const handleVerifyCode = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
if (err instanceof MfaEnrollmentError) {
|
|
setMode('mfaEnroll');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- Forgot password step 1: send reset code ---
|
|
const handleForgotPassword = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
if (!identifier.includes('@')) {
|
|
setError('Please enter your email address');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const vId = await startForgotPassword(identifier);
|
|
setVerificationId(vId);
|
|
setMode('forgotPasswordVerify');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to send reset code');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- Forgot password step 2: verify code + set new password ---
|
|
const handleForgotPasswordVerify = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
if (newPassword !== confirmNewPassword) {
|
|
setError('Passwords do not match');
|
|
return;
|
|
}
|
|
if (newPassword.length < 8) {
|
|
setError('Password must be at least 8 characters');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
await forgotPasswordVerifyAndReset(identifier, verificationId, code, newPassword);
|
|
// Send security notification email (fire-and-forget)
|
|
fetch('/platform/api/password-reset-notification', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: identifier }),
|
|
}).catch(() => {});
|
|
// Reset to sign-in with success message
|
|
switchMode('signIn');
|
|
setError(null);
|
|
setIdentifier(identifier); // preserve email for convenience
|
|
alert('Password reset successful. Please sign in with your new password.');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Password reset failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- MFA: TOTP verification ---
|
|
const handleMfaVerify = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const verificationId = await verifyTotp(code);
|
|
localStorage.setItem('mfa_method_preference', 'totp');
|
|
const redirectTo = await submitMfa(verificationId);
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- MFA: WebAuthn/passkey verification ---
|
|
async function handleWebAuthnVerify() {
|
|
setWebauthnError('');
|
|
setWebauthnLoading(true);
|
|
try {
|
|
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
|
|
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
|
|
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
|
|
localStorage.setItem('mfa_method_preference', 'webauthn');
|
|
const redirectTo = await submitMfa(verificationId);
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
setWebauthnLoading(false);
|
|
return;
|
|
}
|
|
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
|
setWebauthnLoading(false);
|
|
}
|
|
}
|
|
|
|
// Auto-trigger passkey prompt when entering mfaWebauthn mode
|
|
useEffect(() => {
|
|
if (mode === 'mfaWebauthn') {
|
|
handleWebAuthnVerify();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mode]);
|
|
|
|
// --- MFA: backup code verification ---
|
|
const handleBackupCodeVerify = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const verificationId = await verifyBackupCode(code);
|
|
const redirectTo = await submitMfa(verificationId);
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// --- MFA enrollment ---
|
|
const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null);
|
|
const [totpCode, setTotpCode] = useState('');
|
|
|
|
async function handleEnrollPasskey() {
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const { verificationId, registrationOptions } = await startWebAuthnRegistration();
|
|
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
|
|
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
|
|
await bindMfaProfile('WebAuthn', verifiedId);
|
|
const bc = await generateBackupCodes();
|
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
|
setBackupCodes(bc.codes);
|
|
setBackupCodesSaved(false);
|
|
setMode('mfaEnrollBackupCodes');
|
|
setLoading(false);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Passkey registration failed');
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleStartTotpEnroll() {
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const data = await createTotpSecret();
|
|
setTotpSetup(data);
|
|
setTotpCode('');
|
|
setMode('mfaEnrollTotp');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to start TOTP setup');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleVerifyTotpEnroll(e: FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const verifiedId = await verifyTotpSetup(totpCode);
|
|
await bindMfaProfile('Totp', verifiedId);
|
|
const bc = await generateBackupCodes();
|
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
|
setBackupCodes(bc.codes);
|
|
setBackupCodesSaved(false);
|
|
setMode('mfaEnrollBackupCodes');
|
|
setLoading(false);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleBackupCodesDone() {
|
|
setLoading(true);
|
|
try {
|
|
const redirectTo = await submitInteraction();
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to complete sign-in');
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSkipEnrollment() {
|
|
setLoading(true);
|
|
try {
|
|
const redirectTo = await skipMfaEnrollment();
|
|
window.location.replace(redirectTo);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to continue');
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const passwordToggle = (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className={styles.passwordToggle}
|
|
tabIndex={-1}
|
|
>
|
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<Card className={styles.card}>
|
|
<div className={styles.formContainer}>
|
|
<div className={styles.logo}>
|
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
|
Cameleer
|
|
</div>
|
|
<p className={styles.subtitle}>{subtitle}</p>
|
|
|
|
{error && (
|
|
<div className={styles.error}>
|
|
<Alert variant="error">{error}</Alert>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- Sign-in form --- */}
|
|
{mode === 'signIn' && (
|
|
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
|
<FormField label="Login" htmlFor="login-identifier">
|
|
<Input
|
|
id="login-identifier"
|
|
type="email"
|
|
value={identifier}
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
placeholder="you@company.com"
|
|
autoFocus
|
|
autoComplete="username"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="Password" htmlFor="login-password">
|
|
<div className={styles.passwordWrapper}>
|
|
<Input
|
|
id="login-password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
autoComplete="current-password"
|
|
disabled={loading}
|
|
/>
|
|
{passwordToggle}
|
|
</div>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || !identifier || !password}
|
|
className={styles.submitButton}
|
|
>
|
|
Sign in
|
|
</Button>
|
|
|
|
{emailConnectorConfigured && (
|
|
<button
|
|
type="button"
|
|
className={styles.forgotLink}
|
|
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
|
>
|
|
Forgot password?
|
|
</button>
|
|
)}
|
|
|
|
{registrationEnabled && (
|
|
<p className={styles.switchText}>
|
|
Don't have an account?{' '}
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
|
Sign up
|
|
</button>
|
|
</p>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
{/* --- Register form --- */}
|
|
{mode === 'register' && (
|
|
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
|
|
<FormField label="Email" htmlFor="register-email">
|
|
<Input
|
|
id="register-email"
|
|
type="email"
|
|
value={identifier}
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
placeholder="you@company.com"
|
|
autoFocus
|
|
autoComplete="email"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="Password" htmlFor="register-password">
|
|
<div className={styles.passwordWrapper}>
|
|
<Input
|
|
id="register-password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="At least 8 characters"
|
|
autoComplete="new-password"
|
|
disabled={loading}
|
|
/>
|
|
{passwordToggle}
|
|
</div>
|
|
</FormField>
|
|
|
|
<FormField label="Confirm password" htmlFor="register-confirm">
|
|
<Input
|
|
id="register-confirm"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
autoComplete="new-password"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || !identifier || !password || !confirmPassword}
|
|
className={styles.submitButton}
|
|
>
|
|
Create account
|
|
</Button>
|
|
|
|
<p className={styles.switchText}>
|
|
Already have an account?{' '}
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
|
Sign in
|
|
</button>
|
|
</p>
|
|
</form>
|
|
)}
|
|
|
|
{/* --- Verification code form --- */}
|
|
{mode === 'verifyCode' && (
|
|
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
|
|
<p className={styles.verifyHint}>
|
|
We sent a verification code to <strong>{identifier}</strong>
|
|
</p>
|
|
|
|
<FormField label="Verification code" htmlFor="verify-code">
|
|
<Input
|
|
id="verify-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="000000"
|
|
autoFocus
|
|
autoComplete="one-time-code"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || code.length < 6}
|
|
className={styles.submitButton}
|
|
>
|
|
Verify & create account
|
|
</Button>
|
|
|
|
<p className={styles.switchText}>
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
|
Back
|
|
</button>
|
|
</p>
|
|
</form>
|
|
)}
|
|
|
|
{/* --- Forgot password: email entry --- */}
|
|
{mode === 'forgotPassword' && (
|
|
<form className={styles.fields} onSubmit={handleForgotPassword} aria-label="Reset password" noValidate>
|
|
<p className={styles.verifyHint}>
|
|
Enter your email address and we'll send you a code to reset your password.
|
|
</p>
|
|
|
|
<FormField label="Email" htmlFor="forgot-email">
|
|
<Input
|
|
id="forgot-email"
|
|
type="email"
|
|
value={identifier}
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
placeholder="you@company.com"
|
|
autoFocus
|
|
autoComplete="email"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || !identifier}
|
|
className={styles.submitButton}
|
|
>
|
|
Send reset code
|
|
</Button>
|
|
|
|
<p className={styles.switchText}>
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
|
Back to sign in
|
|
</button>
|
|
</p>
|
|
</form>
|
|
)}
|
|
|
|
{/* --- Forgot password: verify code + new password --- */}
|
|
{mode === 'forgotPasswordVerify' && (
|
|
<form className={styles.fields} onSubmit={handleForgotPasswordVerify} aria-label="Set new password" noValidate>
|
|
<p className={styles.verifyHint}>
|
|
We sent a verification code to <strong>{identifier}</strong>
|
|
</p>
|
|
|
|
<FormField label="Verification code" htmlFor="forgot-code">
|
|
<Input
|
|
id="forgot-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="000000"
|
|
autoFocus
|
|
autoComplete="one-time-code"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="New password" htmlFor="forgot-new-password">
|
|
<div className={styles.passwordWrapper}>
|
|
<Input
|
|
id="forgot-new-password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="At least 8 characters"
|
|
autoComplete="new-password"
|
|
disabled={loading}
|
|
/>
|
|
{passwordToggle}
|
|
</div>
|
|
</FormField>
|
|
|
|
<FormField label="Confirm new password" htmlFor="forgot-confirm-password">
|
|
<Input
|
|
id="forgot-confirm-password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={confirmNewPassword}
|
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
autoComplete="new-password"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || code.length < 6 || !newPassword || !confirmNewPassword}
|
|
className={styles.submitButton}
|
|
>
|
|
Reset password
|
|
</Button>
|
|
|
|
<p className={styles.switchText}>
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('forgotPassword')}>
|
|
Back
|
|
</button>
|
|
</p>
|
|
</form>
|
|
)}
|
|
{/* --- MFA: TOTP verification --- */}
|
|
{mode === 'mfaVerify' && (
|
|
<form className={styles.fields} onSubmit={handleMfaVerify} aria-label="Two-factor authentication" noValidate>
|
|
<p className={styles.verifyHint}>
|
|
Enter the 6-digit code from your authenticator app.
|
|
</p>
|
|
|
|
<FormField label="Authentication code" htmlFor="mfa-totp-code">
|
|
<Input
|
|
id="mfa-totp-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="000000"
|
|
autoFocus
|
|
autoComplete="one-time-code"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || code.length < 6}
|
|
className={styles.submitButton}
|
|
>
|
|
Verify
|
|
</Button>
|
|
|
|
<div className={styles.backupCodeCard}>
|
|
<p className={styles.backupCodeText}>Lost your device?</p>
|
|
<button
|
|
type="button"
|
|
className={styles.backupCodeAction}
|
|
onClick={() => { setCode(''); setError(null); setMode('mfaBackupCode'); }}
|
|
>
|
|
Use a backup code
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.backupCodeAction}
|
|
onClick={() => { setCode(''); setError(null); setMode('mfaWebauthn'); }}
|
|
>
|
|
Use passkey instead
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* --- MFA: backup code verification --- */}
|
|
{mode === 'mfaBackupCode' && (
|
|
<form className={styles.fields} onSubmit={handleBackupCodeVerify} aria-label="Backup code verification" noValidate>
|
|
<p className={styles.verifyHint}>
|
|
Enter one of your 10 backup codes.
|
|
</p>
|
|
|
|
<FormField label="Backup code" htmlFor="mfa-backup-code">
|
|
<Input
|
|
id="mfa-backup-code"
|
|
type="text"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
placeholder="Enter backup code"
|
|
autoFocus
|
|
autoComplete="off"
|
|
disabled={loading}
|
|
/>
|
|
</FormField>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || !code}
|
|
className={styles.submitButton}
|
|
>
|
|
Verify backup code
|
|
</Button>
|
|
|
|
<p className={styles.switchText}>
|
|
<button type="button" className={styles.switchLink} onClick={() => { setCode(''); setError(null); setMode('mfaVerify'); }}>
|
|
Use authenticator app instead
|
|
</button>
|
|
</p>
|
|
</form>
|
|
)}
|
|
|
|
{/* --- MFA: method picker --- */}
|
|
{mode === 'mfaMethodPicker' && (
|
|
<div className={styles.fields}>
|
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Verify your identity</h2>
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Choose a verification method</p>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<Button variant="primary" onClick={() => setMode('mfaWebauthn')}>
|
|
Use passkey
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => setMode('mfaVerify')}>
|
|
Use authenticator code
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- MFA: WebAuthn/passkey verification --- */}
|
|
{mode === 'mfaWebauthn' && (
|
|
<div className={styles.fields}>
|
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Passkey verification</h2>
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
Use your fingerprint, face, or security key
|
|
</p>
|
|
</div>
|
|
{webauthnError && <Alert variant="error">{webauthnError}</Alert>}
|
|
<Button variant="primary" onClick={handleWebAuthnVerify} loading={webauthnLoading} style={{ width: '100%' }}>
|
|
Verify with passkey
|
|
</Button>
|
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
|
<button type="button" className={styles.switchLink} onClick={() => setMode('mfaVerify')}>
|
|
Use authenticator code instead
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- MFA enrollment: choose method --- */}
|
|
{mode === 'mfaEnroll' && (
|
|
<div className={styles.fields}>
|
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
Add an extra layer of security to your account.
|
|
</p>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
|
Use passkey
|
|
</Button>
|
|
<Button variant="secondary" onClick={handleStartTotpEnroll} disabled={loading}>
|
|
Use authenticator app
|
|
</Button>
|
|
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
|
|
Set up later
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- MFA enrollment: TOTP setup --- */}
|
|
{mode === 'mfaEnrollTotp' && totpSetup && (
|
|
<div className={styles.fields}>
|
|
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Set up authenticator</h2>
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
Scan this QR code with your authenticator app, then enter the 6-digit code.
|
|
</p>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
|
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
|
|
</div>
|
|
<div style={{
|
|
textAlign: 'center', padding: '6px 10px',
|
|
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
|
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.7rem',
|
|
wordBreak: 'break-all',
|
|
}}>
|
|
{totpSetup.secret}
|
|
</div>
|
|
<form onSubmit={handleVerifyTotpEnroll} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<FormField label="Verification code" htmlFor="enroll-totp-code">
|
|
<Input
|
|
id="enroll-totp-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={6}
|
|
value={totpCode}
|
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="Enter 6-digit code"
|
|
autoFocus
|
|
autoComplete="one-time-code"
|
|
/>
|
|
</FormField>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<Button type="submit" variant="primary" loading={loading} disabled={totpCode.length !== 6}>
|
|
Verify & Enable
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={() => { setTotpSetup(null); setMode('mfaEnroll'); }} disabled={loading}>
|
|
Back
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- MFA enrollment: backup codes --- */}
|
|
{mode === 'mfaEnrollBackupCodes' && backupCodes && (
|
|
<div className={styles.fields}>
|
|
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Save your backup codes</h2>
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
|
|
</p>
|
|
</div>
|
|
<div style={{
|
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
|
gap: '6px 20px', padding: 12,
|
|
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
|
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.85rem',
|
|
}}>
|
|
{backupCodes.map((c) => <span key={c}>{c}</span>)}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
|
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}>
|
|
Copy all
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => {
|
|
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = 'cameleer-backup-codes.txt'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}}>
|
|
Download
|
|
</Button>
|
|
</div>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={backupCodesSaved} onChange={(e) => setBackupCodesSaved(e.target.checked)} />
|
|
I've saved my backup codes
|
|
</label>
|
|
<Button variant="primary" disabled={!backupCodesSaved} onClick={handleBackupCodesDone} loading={loading}>
|
|
Continue
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|