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;
|
text-align: center;
|
||||||
line-height: 1.5;
|
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);
|
const redirectTo = await signIn(identifier, password);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof MfaRequiredError) {
|
||||||
|
setMode('mfaVerify');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
setLoading(false);
|
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 = (
|
const passwordToggle = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -514,6 +549,87 @@ export function SignInPage() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user