feat: add MFA verification (TOTP + backup code) to sign-in flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -120,3 +120,33 @@
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.backupCodeCard {
|
||||
margin-top: 4px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface, #f8f7f5);
|
||||
border: 1px solid var(--border-default, #e8e0d4);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.backupCodeText {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.backupCodeAction {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-link, #C6820E);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.backupCodeAction:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ export function SignInPage() {
|
||||
const redirectTo = await signIn(identifier, password);
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
if (err instanceof MfaRequiredError) {
|
||||
setMode('mfaVerify');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -221,6 +226,36 @@ export function SignInPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- MFA: TOTP verification ---
|
||||
const handleMfaVerify = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const verificationId = await verifyTotp(code);
|
||||
const redirectTo = await submitMfa(verificationId);
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
};
|
||||
|
||||
const passwordToggle = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,6 +549,87 @@ export function SignInPage() {
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user