feat: add WebAuthn and method picker modes to sign-in UI
Adds mfaWebauthn and mfaMethodPicker modes with smart routing based on stored preference (localStorage). Auto-triggers passkey prompt on mode entry. Adds "Use passkey instead" link in TOTP mode. Saves method preference on successful verification. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,17 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import {
|
import {
|
||||||
signIn, startRegistration, completeRegistration,
|
signIn, startRegistration, completeRegistration,
|
||||||
startForgotPassword, forgotPasswordVerifyAndReset,
|
startForgotPassword, forgotPasswordVerifyAndReset,
|
||||||
verifyTotp, verifyBackupCode, submitMfa,
|
verifyTotp, verifyBackupCode, submitMfa,
|
||||||
|
startWebAuthnAuth, verifyWebAuthnAuth,
|
||||||
MfaRequiredError,
|
MfaRequiredError,
|
||||||
} from './experience-api';
|
} from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
|
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
|
||||||
|
|
||||||
const SIGN_IN_SUBTITLES = [
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
@@ -83,6 +85,8 @@ export function SignInPage() {
|
|||||||
const [verificationId, setVerificationId] = useState('');
|
const [verificationId, setVerificationId] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
|
const [webauthnError, setWebauthnError] = useState('');
|
||||||
|
const [webauthnLoading, setWebauthnLoading] = useState(false);
|
||||||
|
|
||||||
// Fetch sign-in experience to check if registration is enabled
|
// Fetch sign-in experience to check if registration is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +126,14 @@ export function SignInPage() {
|
|||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof MfaRequiredError) {
|
if (err instanceof MfaRequiredError) {
|
||||||
|
const pref = localStorage.getItem('mfa_method_preference');
|
||||||
|
if (pref === 'webauthn') {
|
||||||
|
setMode('mfaWebauthn');
|
||||||
|
} else if (pref === 'totp') {
|
||||||
setMode('mfaVerify');
|
setMode('mfaVerify');
|
||||||
|
} else {
|
||||||
|
setMode('mfaMethodPicker');
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -233,6 +244,7 @@ export function SignInPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const verificationId = await verifyTotp(code);
|
const verificationId = await verifyTotp(code);
|
||||||
|
localStorage.setItem('mfa_method_preference', 'totp');
|
||||||
const redirectTo = await submitMfa(verificationId);
|
const redirectTo = await submitMfa(verificationId);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -241,6 +253,31 @@ export function SignInPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- MFA: WebAuthn/passkey verification ---
|
||||||
|
async function handleWebAuthnVerify() {
|
||||||
|
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>);
|
||||||
|
localStorage.setItem('mfa_method_preference', 'webauthn');
|
||||||
|
const redirectTo = await submitMfa(verificationId);
|
||||||
|
window.location.replace(redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
||||||
|
setWebauthnLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-trigger passkey prompt when entering mfaWebauthn mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'mfaWebauthn') {
|
||||||
|
handleWebAuthnVerify();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
// --- MFA: backup code verification ---
|
// --- MFA: backup code verification ---
|
||||||
const handleBackupCodeVerify = async (e: FormEvent) => {
|
const handleBackupCodeVerify = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -589,6 +626,13 @@ export function SignInPage() {
|
|||||||
>
|
>
|
||||||
Use a backup code
|
Use a backup code
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.backupCodeAction}
|
||||||
|
onClick={() => { setCode(''); setError(null); setMode('mfaWebauthn'); }}
|
||||||
|
>
|
||||||
|
Use passkey instead
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -630,6 +674,45 @@ export function SignInPage() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA: method picker --- */}
|
||||||
|
{mode === 'mfaMethodPicker' && (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Verify your identity</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Choose a verification method</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Button variant="primary" onClick={() => setMode('mfaWebauthn')}>
|
||||||
|
Use passkey
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setMode('mfaVerify')}>
|
||||||
|
Use authenticator code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA: WebAuthn/passkey verification --- */}
|
||||||
|
{mode === 'mfaWebauthn' && (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Passkey verification</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
||||||
|
Use your fingerprint, face, or security key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{webauthnError && <Alert variant="error">{webauthnError}</Alert>}
|
||||||
|
<Button variant="primary" onClick={handleWebAuthnVerify} loading={webauthnLoading} style={{ width: '100%' }}>
|
||||||
|
Verify with passkey
|
||||||
|
</Button>
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => setMode('mfaVerify')}>
|
||||||
|
Use authenticator code instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user