refactor: move passkey enrollment to sign-in UI via Experience API
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m49s

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:
hsiegeln
2026-04-27 18:33:46 +02:00
parent 4df6fc9e03
commit 18e6f32f90
8 changed files with 120 additions and 231 deletions

View File

@@ -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>