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}}
TOTP QR Code
@@ -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) {