diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx
index 5429d07..f8703d9 100644
--- a/ui/sign-in/src/SignInPage.tsx
+++ b/ui/sign-in/src/SignInPage.tsx
@@ -15,7 +15,7 @@ import {
} from './experience-api';
import styles from './SignInPage.module.css';
-type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp';
+type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp' | 'mfaEnrollBackupCodes';
const SIGN_IN_SUBTITLES = [
"Prove you're not a mirage",
@@ -90,6 +90,8 @@ export function SignInPage() {
const [confirmNewPassword, setConfirmNewPassword] = useState('');
const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);
+ const [backupCodes, setBackupCodes] = useState(null);
+ const [backupCodesSaved, setBackupCodesSaved] = useState(false);
// Fetch sign-in experience to check if registration is enabled
useEffect(() => {
@@ -130,12 +132,10 @@ export function SignInPage() {
} catch (err) {
if (err instanceof MfaRequiredError) {
const pref = localStorage.getItem('mfa_method_preference');
- if (pref === 'webauthn') {
- setMode('mfaWebauthn');
- } else if (pref === 'totp') {
+ if (pref === 'totp') {
setMode('mfaVerify');
} else {
- setMode('mfaMethodPicker');
+ setMode('mfaWebauthn');
}
setLoading(false);
return;
@@ -271,13 +271,17 @@ export function SignInPage() {
setWebauthnError('');
setWebauthnLoading(true);
try {
- const options = await startWebAuthnAuth();
- const credential = await startAuthentication({ optionsJSON: options as any });
- const verificationId = await verifyWebAuthnAuth(credential as unknown as Record);
+ const { verificationId, authenticationOptions } = await startWebAuthnAuth();
+ const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
+ await verifyWebAuthnAuth(verificationId, credential as unknown as Record);
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);
}
@@ -320,8 +324,10 @@ export function SignInPage() {
await bindMfaProfile('WebAuthn', verifiedId);
const bc = await generateBackupCodes();
await bindMfaProfile('BackupCode', bc.verificationId);
- const result = await submitInteraction();
- window.location.replace(result);
+ setBackupCodes(bc.codes);
+ setBackupCodesSaved(false);
+ setMode('mfaEnrollBackupCodes');
+ setLoading(false);
} catch (err) {
if (err instanceof Error && err.name === 'NotAllowedError') {
setLoading(false);
@@ -356,14 +362,27 @@ export function SignInPage() {
await bindMfaProfile('Totp', verifiedId);
const bc = await generateBackupCodes();
await bindMfaProfile('BackupCode', bc.verificationId);
- const result = await submitInteraction();
- window.location.replace(result);
+ 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 {
@@ -805,7 +824,6 @@ export function SignInPage() {
Add an extra layer of security to your account.
- {error && {error}}
- {error && {error}}
@@ -866,6 +883,47 @@ export function SignInPage() {
)}
+
+ {/* --- MFA enrollment: backup codes --- */}
+ {mode === 'mfaEnrollBackupCodes' && backupCodes && (
+
+
+
Save your backup codes
+
+ Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
+
+
+
+ {backupCodes.map((c) => {c})}
+
+
+
+
+
+
+
+
+ )}
diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts
index 437721e..8e1c0d9 100644
--- a/ui/sign-in/src/experience-api.ts
+++ b/ui/sign-in/src/experience-api.ts
@@ -277,18 +277,17 @@ export async function submitMfa(_verificationId: string): Promise {
// --- WebAuthn MFA Verification ---
-export async function startWebAuthnAuth(): Promise> {
+export async function startWebAuthnAuth(): Promise<{ verificationId: string; authenticationOptions: Record }> {
const res = await request('POST', '/verification/web-authn/authentication');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to start passkey authentication (${res.status})`);
}
- const data = await res.json();
- return data;
+ return res.json();
}
-export async function verifyWebAuthnAuth(payload: Record): Promise {
- const res = await request('POST', '/verification/web-authn/authentication/verify', payload);
+export async function verifyWebAuthnAuth(verificationId: string, payload: Record): Promise {
+ const res = await request('POST', '/verification/web-authn/authentication/verify', { verificationId, payload });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {