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:
hsiegeln
2026-04-26 13:44:28 +02:00
parent 1f3a9551c5
commit 6c70efcb54
2 changed files with 146 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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>