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

@@ -1,13 +1,10 @@
import { useState } from 'react';
import { useLogto } from '@logto/react';
import { errorMessage } from '../../api/client';
import {
Alert,
Button,
Card,
FormField,
Input,
Modal,
useToast,
} from '@cameleer/design-system';
import {
@@ -16,7 +13,6 @@ import {
useAccountRenamePasskey,
useAccountDeletePasskey,
} from '../../api/account-hooks';
import { registerPasskey } from '../../api/logto-account-api';
import styles from '../../styles/platform.module.css';
export function PasskeyNudgeBanner() {
@@ -45,17 +41,12 @@ export function PasskeyNudgeBanner() {
export function PasskeySection() {
const { toast } = useToast();
const { getAccessToken } = useLogto();
const { data: passkeys, isLoading, refetch } = useAccountPasskeyList();
const { data: passkeys, isLoading } = useAccountPasskeyList();
const renamePasskey = useAccountRenamePasskey();
const deletePasskey = useAccountDeletePasskey();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [registering, setRegistering] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [regPassword, setRegPassword] = useState('');
const [regError, setRegError] = useState<string | null>(null);
function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device';
@@ -81,37 +72,6 @@ export function PasskeySection() {
}
}
function openRegister() {
setRegPassword('');
setRegError(null);
setShowPasswordModal(true);
}
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setRegError(null);
setRegistering(true);
try {
await registerPasskey(async () => {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
return token;
}, regPassword);
setShowPasswordModal(false);
await refetch();
toast({ title: 'Passkey registered', variant: 'success' });
} catch (err) {
// User cancelled the WebAuthn prompt — not an error
if (err instanceof Error && err.name === 'NotAllowedError') {
setRegistering(false);
return;
}
setRegError(errorMessage(err));
} finally {
setRegistering(false);
}
}
async function handleDelete(id: string) {
try {
await deletePasskey.mutateAsync(id);
@@ -126,19 +86,13 @@ export function PasskeySection() {
const credentials = passkeys ?? [];
return (
<>
<Card title="Passkeys">
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
<div style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={openRegister}>
Register passkey
</Button>
</div>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered yet.
No passkeys registered. You can register a passkey during sign-in.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -178,39 +132,5 @@ export function PasskeySection() {
</div>
)}
</Card>
<Modal
open={showPasswordModal}
onClose={() => { if (!registering) setShowPasswordModal(false); }}
title="Confirm identity"
size="sm"
>
<p className={styles.description} style={{ marginTop: 0 }}>
Enter your password to register a new passkey.
</p>
{regError && <Alert variant="error">{regError}</Alert>}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Password" htmlFor="passkey-reg-password">
<Input
id="passkey-reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="Enter your password"
autoFocus
autoComplete="current-password"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={registering} disabled={!regPassword}>
Continue
</Button>
<Button type="button" variant="secondary" onClick={() => setShowPasswordModal(false)} disabled={registering}>
Cancel
</Button>
</div>
</form>
</Modal>
</>
);
}