feat: always enable WebAuthn in MFA factors and add passkey registration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m26s

- Sync vendor auth policy to Logto sign-in experience on save and on
  startup. Always include WebAuthn + TOTP + BackupCode in MFA factors
  when MFA is enabled — no reason to gate passkeys behind a toggle.
- Enable Logto Account Center on startup for user-facing MFA management.
- Add passkey registration to account settings via Logto Account API.
  Frontend calls Logto directly (same domain) for the WebAuthn ceremony:
  generate options, browser credential creation, verify, and bind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 17:01:58 +02:00
parent a5c20830a7
commit c22580e124
5 changed files with 209 additions and 3 deletions

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useLogto } from '@logto/react';
import { errorMessage } from '../../api/client';
import {
Alert,
@@ -13,6 +14,7 @@ import {
useAccountRenamePasskey,
useAccountDeletePasskey,
} from '../../api/account-hooks';
import { registerPasskey } from '../../api/logto-account-api';
import styles from '../../styles/platform.module.css';
export function PasskeyNudgeBanner() {
@@ -41,12 +43,14 @@ export function PasskeyNudgeBanner() {
export function PasskeySection() {
const { toast } = useToast();
const { data: passkeys, isLoading } = useAccountPasskeyList();
const { getAccessToken } = useLogto();
const { data: passkeys, isLoading, refetch } = 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);
function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device';
@@ -72,6 +76,25 @@ export function PasskeySection() {
}
}
async function handleRegister() {
setRegistering(true);
try {
await registerPasskey(async () => {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
return token;
});
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') return;
toast({ title: 'Passkey registration failed', description: errorMessage(err), variant: 'error' });
} finally {
setRegistering(false);
}
}
async function handleDelete(id: string) {
try {
await deletePasskey.mutateAsync(id);
@@ -90,9 +113,14 @@ export function PasskeySection() {
<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={handleRegister} loading={registering}>
Register passkey
</Button>
</div>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
No passkeys registered yet.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>