fix: sign-in MFA flow overhaul — passkey verify, backup codes, defaults
Four fixes for the MFA sign-in flow: 1. Fix passkey verify crash: extract authenticationOptions from Logto response (was passing full response as optionsJSON). Pass verificationId to the verify endpoint. 2. Default to passkey verification when no MFA method preference is stored (was showing method picker which offered TOTP to passkey-only users). 3. Show backup codes after MFA enrollment: new mfaEnrollBackupCodes mode with copy/download buttons and confirmation checkbox. Users must save codes before completing sign-in. 4. Remove duplicate error alerts in enrollment screens (top-level alert handles all modes). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string[] | null>(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<string, unknown>);
|
||||
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
|
||||
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
|
||||
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
||||
Use passkey
|
||||
@@ -829,7 +847,6 @@ export function SignInPage() {
|
||||
Scan this QR code with your authenticator app, then enter the 6-digit code.
|
||||
</p>
|
||||
</div>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
|
||||
</div>
|
||||
@@ -866,6 +883,47 @@ export function SignInPage() {
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- MFA enrollment: backup codes --- */}
|
||||
{mode === 'mfaEnrollBackupCodes' && backupCodes && (
|
||||
<div className={styles.fields}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Save your backup codes</h2>
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
||||
Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||
gap: '6px 20px', padding: 12,
|
||||
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
||||
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.85rem',
|
||||
}}>
|
||||
{backupCodes.map((c) => <span key={c}>{c}</span>)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}>
|
||||
Copy all
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'cameleer-backup-codes.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={backupCodesSaved} onChange={(e) => setBackupCodesSaved(e.target.checked)} />
|
||||
I've saved my backup codes
|
||||
</label>
|
||||
<Button variant="primary" disabled={!backupCodesSaved} onClick={handleBackupCodesDone} loading={loading}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -277,18 +277,17 @@ export async function submitMfa(_verificationId: string): Promise<string> {
|
||||
|
||||
// --- WebAuthn MFA Verification ---
|
||||
|
||||
export async function startWebAuthnAuth(): Promise<Record<string, unknown>> {
|
||||
export async function startWebAuthnAuth(): Promise<{ verificationId: string; authenticationOptions: Record<string, unknown> }> {
|
||||
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<string, unknown>): Promise<string> {
|
||||
const res = await request('POST', '/verification/web-authn/authentication/verify', payload);
|
||||
export async function verifyWebAuthnAuth(verificationId: string, payload: Record<string, unknown>): Promise<string> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user