refactor: move passkey enrollment to sign-in UI via Experience API
Remove the SaaS backend proxy approach for passkey registration (Account API binding, Management API proxy, password modal in PasskeySection). Instead, offer passkey enrollment natively during sign-in via Logto's Experience API — the correct architectural layer. Sign-in flow: when Logto returns MFA enrollment available (422), show a "Secure your account" screen with Register passkey / Set up later. Uses Experience API WebAuthn registration endpoints. Works for all users (SaaS and future server users) since the sign-in UI is shared. PasskeySection in account settings now only manages existing passkeys (list/rename/delete) and directs users to register during sign-in. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,19 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
||||
import {
|
||||
signIn, startRegistration, completeRegistration,
|
||||
startForgotPassword, forgotPasswordVerifyAndReset,
|
||||
verifyTotp, verifyBackupCode, submitMfa,
|
||||
startWebAuthnAuth, verifyWebAuthnAuth,
|
||||
MfaRequiredError,
|
||||
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
|
||||
skipMfaEnrollment, submitInteraction,
|
||||
MfaRequiredError, MfaEnrollmentError,
|
||||
} from './experience-api';
|
||||
import styles from './SignInPage.module.css';
|
||||
|
||||
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
|
||||
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll';
|
||||
|
||||
const SIGN_IN_SUBTITLES = [
|
||||
"Prove you're not a mirage",
|
||||
@@ -137,6 +139,11 @@ export function SignInPage() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (err instanceof MfaEnrollmentError) {
|
||||
setMode('mfaEnroll');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -179,6 +186,11 @@ export function SignInPage() {
|
||||
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
if (err instanceof MfaEnrollmentError) {
|
||||
setMode('mfaEnroll');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -293,6 +305,38 @@ export function SignInPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- MFA enrollment: passkey registration ---
|
||||
async function handleEnrollPasskey() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { verificationId, registrationOptions } = await startWebAuthnRegistration();
|
||||
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
|
||||
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
|
||||
await bindMfaProfile('WebAuthn', verifiedId);
|
||||
const result = await submitInteraction();
|
||||
window.location.replace(result);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'NotAllowedError') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Passkey registration failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkipEnrollment() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const redirectTo = await skipMfaEnrollment();
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to continue');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const passwordToggle = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -713,6 +757,27 @@ export function SignInPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- MFA enrollment: offer passkey registration --- */}
|
||||
{mode === 'mfaEnroll' && (
|
||||
<div className={styles.fields}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
||||
Add a passkey to sign in faster with your fingerprint, face, or security key.
|
||||
</p>
|
||||
</div>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
||||
Register passkey
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
|
||||
Set up later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user