diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx index 788580b..a610227 100644 --- a/ui/sign-in/src/SignInPage.tsx +++ b/ui/sign-in/src/SignInPage.tsx @@ -9,12 +9,13 @@ import { verifyTotp, verifyBackupCode, submitMfa, startWebAuthnAuth, verifyWebAuthnAuth, startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile, + createTotpSecret, verifyTotpSetup, skipMfaEnrollment, submitInteraction, MfaRequiredError, MfaEnrollmentError, } from './experience-api'; import styles from './SignInPage.module.css'; -type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll'; +type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp'; const SIGN_IN_SUBTITLES = [ "Prove you're not a mirage", @@ -305,7 +306,10 @@ export function SignInPage() { } }; - // --- MFA enrollment: passkey registration --- + // --- MFA enrollment --- + const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null); + const [totpCode, setTotpCode] = useState(''); + async function handleEnrollPasskey() { setError(null); setLoading(true); @@ -314,6 +318,7 @@ export function SignInPage() { const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any }); const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record); await bindMfaProfile('WebAuthn', verifiedId); + await bindMfaProfile('BackupCode'); const result = await submitInteraction(); window.location.replace(result); } catch (err) { @@ -326,6 +331,37 @@ export function SignInPage() { } } + async function handleStartTotpEnroll() { + setError(null); + setLoading(true); + try { + const data = await createTotpSecret(); + setTotpSetup(data); + setTotpCode(''); + setMode('mfaEnrollTotp'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start TOTP setup'); + } finally { + setLoading(false); + } + } + + async function handleVerifyTotpEnroll(e: FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const verifiedId = await verifyTotpSetup(totpCode); + await bindMfaProfile('Totp', verifiedId); + await bindMfaProfile('BackupCode'); + const result = await submitInteraction(); + window.location.replace(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Verification failed'); + setLoading(false); + } + } + async function handleSkipEnrollment() { setLoading(true); try { @@ -758,19 +794,22 @@ export function SignInPage() { )} - {/* --- MFA enrollment: offer passkey registration --- */} + {/* --- MFA enrollment: choose method --- */} {mode === 'mfaEnroll' && (

Secure your account

- Add a passkey to sign in faster with your fingerprint, face, or security key. + Add an extra layer of security to your account.

{error && {error}}
+
)} + + {/* --- MFA enrollment: TOTP setup --- */} + {mode === 'mfaEnrollTotp' && totpSetup && ( +
+
+

Set up authenticator

+

+ Scan this QR code with your authenticator app, then enter the 6-digit code. +

+
+ {error && {error}} +
+ TOTP QR Code +
+
+ {totpSetup.secret} +
+
+ + setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="Enter 6-digit code" + autoFocus + autoComplete="one-time-code" + /> + +
+ + +
+
+
+ )} diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts index 4045bd6..30709ef 100644 --- a/ui/sign-in/src/experience-api.ts +++ b/ui/sign-in/src/experience-api.ts @@ -322,14 +322,35 @@ export async function verifyWebAuthnRegistration(verificationId: string, payload return data.verificationId; } -export async function bindMfaProfile(type: string, verificationId: string): Promise { - const res = await request('POST', '/profile/mfa', { type, verificationId }); +export async function bindMfaProfile(type: string, verificationId?: string): Promise { + const body: Record = { type }; + if (verificationId) body.verificationId = verificationId; + const res = await request('POST', '/profile/mfa', body); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to bind MFA (${res.status})`); } } +export async function createTotpSecret(): Promise<{ secret: string; secretQrCode: string; verificationId: string }> { + const res = await request('POST', '/verification/totp'); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || `Failed to create TOTP secret (${res.status})`); + } + return res.json(); +} + +export async function verifyTotpSetup(code: string): Promise { + const res = await request('POST', '/verification/totp/verify', { code }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || `TOTP verification failed (${res.status})`); + } + const data = await res.json(); + return data.verificationId; +} + export async function skipMfaEnrollment(): Promise { await skipMfaBinding(); const result = await trySubmit();