refactor(settings): share the passkey logic between desktop and phone

Listing, adding with the step up and the WebAuthn ceremony, renaming and
deleting passkeys now live in one usePasskeys hook together with the
date and abort helpers. The desktop section and the phone card keep
their own markup.
This commit is contained in:
Maurice
2026-10-09 10:43:52 +02:00
parent 3b4539f600
commit 231acced7e
4 changed files with 452 additions and 276 deletions
@@ -1,33 +1,16 @@
import React, { useEffect, useState } from 'react'
import React from 'react'
import { Fingerprint, Plus, Trash2, Pencil, Check, X, AlertTriangle } from 'lucide-react'
import { startRegistration } from '@simplewebauthn/browser'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { authApi, type PasskeyCredential } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import Section from './Section'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { GRID_2, INPUT, PANEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
import { fmtDate, usePasskeys } from './usePasskeys'
/** The step-up's final answer: the danger fill, white on red like ConfirmDialog's. */
const DANGER_FILL = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90 disabled:cursor-default disabled:opacity-50' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
function fmtDate(ts: string | null): string | null {
if (!ts) return null
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString()
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
function isWebauthnAbort(err: unknown): boolean {
const name = (err as { name?: string })?.name
return name === 'NotAllowedError' || name === 'AbortError'
}
/**
* Passkey enrolment + management. Mirrors the MFA block: list / add (with a
* password step-up + the WebAuthn ceremony) / rename / delete (password step-up).
@@ -37,97 +20,17 @@ function isWebauthnAbort(err: unknown): boolean {
*/
export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const [enabled, setEnabled] = useState(false)
const [configured, setConfigured] = useState(false)
const [creds, setCreds] = useState<PasskeyCredential[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [addPwd, setAddPwd] = useState('')
const [addName, setAddName] = useState('')
const [renamingId, setRenamingId] = useState<number | null>(null)
const [renameVal, setRenameVal] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletePwd, setDeletePwd] = useState('')
const refresh = () => {
authApi.passkey.list()
.then(r => setCreds(r.credentials))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(() => {
authApi.getAppConfig?.()
.then(c => { setEnabled(!!c?.passkey_login); setConfigured(!!c?.passkey_configured) })
.catch(() => {})
refresh()
}, [])
const canAdd = enabled && configured
// Both step-up flows are gated by disabled={busy || !pwd} on their submit button,
// so the password is always present by the time these run.
const handleAdd = async () => {
setBusy(true)
try {
const options = await authApi.passkey.registerOptions(addPwd)
const attResp = await startRegistration({ optionsJSON: options })
await authApi.passkey.registerVerify(attResp, addName.trim() || undefined)
toast.success(t('settings.passkey.addedToast'))
setAddOpen(false); setAddPwd(''); setAddName('')
refresh()
} catch (err: unknown) {
if (isWebauthnAbort(err)) toast.error(t('settings.passkey.cancelled'))
else toast.error(getApiErrorMessage(err, t('settings.passkey.addError')))
} finally {
setBusy(false)
}
}
const handleRename = async (id: number) => {
const name = renameVal.trim()
if (!name) { setRenamingId(null); return }
try {
await authApi.passkey.rename(id, name)
setRenamingId(null)
refresh()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleDelete = async (id: number) => {
setBusy(true)
try {
await authApi.passkey.delete(id, deletePwd)
toast.success(t('settings.passkey.deleted'))
setDeletingId(null); setDeletePwd('')
refresh()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusy(false)
}
}
if (demoMode) return null
// Nothing to show: feature off and the user has no credentials to manage.
if (!loading && !enabled && creds.length === 0) return null
const pk = usePasskeys({ demoMode })
if (pk.hidden) return null
return (
<Section
title={t('settings.passkey.title')}
icon={Fingerprint}
badge={creds.length > 0 ? <StatusPill>{creds.length}</StatusPill> : undefined}
badge={pk.creds.length > 0 ? <StatusPill>{pk.creds.length}</StatusPill> : undefined}
// The way in sits on the band, like the planner's "add" actions; the step-up opens in the body.
action={canAdd && !addOpen ? (
<button type="button" onClick={() => setAddOpen(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
action={pk.canAdd && !pk.addOpen ? (
<button type="button" onClick={() => pk.setAddOpen(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<Plus size={14} />
{t('settings.passkey.add')}
</button>
@@ -135,39 +38,39 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
>
<SettingsHint>{t('settings.passkey.description')}</SettingsHint>
{enabled && !configured && (
{pk.notConfigured && (
<div className="flex gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-content" style={fs(12.5, 'body')}>
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
<p className="m-0 leading-snug">{t('settings.passkey.notConfigured')}</p>
</div>
)}
{creds.length > 0 && (
{pk.creds.length > 0 && (
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
{creds.map(c => (
{pk.creds.map(c => (
<li key={c.id} className="flex items-center gap-3 px-3.5 py-3">
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
<Fingerprint size={16} strokeWidth={1.9} />
</span>
<div className="min-w-0 flex-1">
{renamingId === c.id ? (
{pk.renamingId === c.id ? (
<div className="flex items-center gap-2">
<input
autoFocus
type="text"
value={renameVal}
onChange={e => setRenameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
value={pk.renameVal}
onChange={e => pk.setRenameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void pk.handleRename(c.id); if (e.key === 'Escape') pk.setRenamingId(null) }}
aria-label={t('settings.passkey.rename')}
className={`${INPUT} flex-1`}
/>
<Tooltip label={t('common.save')}>
<button type="button" onClick={() => handleRename(c.id)} className={`${SETTINGS_ICON_BUTTON} !text-success`} aria-label={t('common.save')}>
<button type="button" onClick={() => pk.handleRename(c.id)} className={`${SETTINGS_ICON_BUTTON} !text-success`} aria-label={t('common.save')}>
<Check size={15} strokeWidth={2.4} />
</button>
</Tooltip>
<Tooltip label={t('common.cancel')}>
<button type="button" onClick={() => setRenamingId(null)} className={SETTINGS_ICON_BUTTON} aria-label={t('common.cancel')}>
<button type="button" onClick={() => pk.setRenamingId(null)} className={SETTINGS_ICON_BUTTON} aria-label={t('common.cancel')}>
<X size={15} />
</button>
</Tooltip>
@@ -190,12 +93,12 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
</>
)}
</div>
{renamingId !== c.id && (
{pk.renamingId !== c.id && (
<div className="flex flex-none items-center gap-1.5">
<Tooltip label={t('settings.passkey.rename')}>
<button
type="button"
onClick={() => { setRenamingId(c.id); setRenameVal(c.name || '') }}
onClick={() => pk.startRename(c)}
className={SETTINGS_ICON_BUTTON}
aria-label={t('settings.passkey.rename')}
>
@@ -205,7 +108,7 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
<Tooltip label={t('common.delete')}>
<button
type="button"
onClick={() => { setDeletingId(c.id); setDeletePwd('') }}
onClick={() => pk.startDelete(c.id)}
className={`${SETTINGS_ICON_BUTTON} hover:!bg-danger-soft hover:!text-danger`}
aria-label={t('common.delete')}
>
@@ -220,13 +123,13 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
)}
{/* Delete confirmation (password step-up) */}
{deletingId !== null && (
{pk.deletingId !== null && (
<div className="flex flex-col gap-3 rounded-[14px] border border-edge-faint bg-danger-soft p-3.5">
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.passkey.deleteConfirm')}</p>
<input
type="password"
value={deletePwd}
onChange={e => setDeletePwd(e.target.value)}
value={pk.deletePwd}
onChange={e => pk.setDeletePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
aria-label={t('settings.currentPassword')}
className={INPUT}
@@ -234,15 +137,15 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => { setDeletingId(null); setDeletePwd('') }}
onClick={pk.cancelDelete}
className={SETTINGS_BUTTON}
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={busy || !deletePwd}
onClick={() => handleDelete(deletingId)}
disabled={pk.busy || !pk.deletePwd}
onClick={() => pk.handleDelete(pk.deletingId)}
className={DANGER_FILL}
>
<Trash2 size={14} />
@@ -253,7 +156,7 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
)}
{/* Add a passkey */}
{canAdd && addOpen && (
{pk.canAdd && pk.addOpen && (
<div className={PANEL}>
<div>
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.passkey.addTitle')}</p>
@@ -262,16 +165,16 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
<div className={GRID_2}>
<input
type="password"
value={addPwd}
onChange={e => setAddPwd(e.target.value)}
value={pk.addPwd}
onChange={e => pk.setAddPwd(e.target.value)}
placeholder={t('settings.currentPassword')}
aria-label={t('settings.currentPassword')}
className={INPUT}
/>
<input
type="text"
value={addName}
onChange={e => setAddName(e.target.value)}
value={pk.addName}
onChange={e => pk.setAddName(e.target.value)}
placeholder={t('settings.passkey.namePlaceholder')}
aria-label={t('settings.passkey.namePlaceholder')}
className={INPUT}
@@ -280,18 +183,18 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
onClick={pk.cancelAdd}
className={SETTINGS_BUTTON}
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={busy || !addPwd}
onClick={handleAdd}
disabled={pk.busy || !pk.addPwd}
onClick={pk.handleAdd}
className={SETTINGS_BUTTON_PRIMARY}
>
{busy
{pk.busy
? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
: <><Fingerprint size={14} />{t('settings.passkey.add')}</>}
</button>
@@ -0,0 +1,212 @@
// FE-COMP-PASSKEYS-HOOK-001 to -012: the passkey logic the desktop section and the phone card share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { authApi, type PasskeyCredential } from '../../api/client';
import { fmtDate, isWebauthnAbort, usePasskeys } from './usePasskeys';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const startRegistration = vi.hoisted(() => vi.fn());
vi.mock('@simplewebauthn/browser', () => ({ startRegistration }));
const CRED: PasskeyCredential = {
id: 1,
name: 'MacBook',
device_type: 'multiDevice',
backed_up: true,
created_at: '2025-05-01 08:30:00',
last_used_at: null,
};
function serve(config: { passkey_login?: boolean; passkey_configured?: boolean }, creds: PasskeyCredential[] = [CRED]) {
vi.spyOn(authApi, 'getAppConfig').mockResolvedValue(config as Awaited<ReturnType<typeof authApi.getAppConfig>>);
vi.spyOn(authApi.passkey, 'list').mockResolvedValue({ credentials: creds });
}
async function loaded() {
const hook = renderHook(() => usePasskeys());
await waitFor(() => expect(hook.result.current.loading).toBe(false));
return hook;
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe('passkey helpers', () => {
it('FE-COMP-PASSKEYS-HOOK-001: reads a SQLite UTC timestamp and rejects nonsense', () => {
expect(fmtDate(null)).toBeNull();
expect(fmtDate('nope')).toBeNull();
expect(fmtDate('2025-05-01 08:30:00')).toBe(new Date('2025-05-01T08:30:00Z').toLocaleDateString());
expect(fmtDate('2025-05-01T08:30:00Z')).toBe(new Date('2025-05-01T08:30:00Z').toLocaleDateString());
});
it('FE-COMP-PASSKEYS-HOOK-002: only the cancel and abort DOMExceptions count as a cancelled ceremony', () => {
expect(isWebauthnAbort({ name: 'NotAllowedError' })).toBe(true);
expect(isWebauthnAbort({ name: 'AbortError' })).toBe(true);
expect(isWebauthnAbort({ name: 'InvalidStateError' })).toBe(false);
expect(isWebauthnAbort(null)).toBe(false);
});
});
describe('usePasskeys', () => {
it('FE-COMP-PASSKEYS-HOOK-003: loads the instance flags and the credentials', async () => {
serve({ passkey_login: true, passkey_configured: true });
const { result } = await loaded();
expect(result.current.creds).toEqual([CRED]);
expect(result.current.enabled).toBe(true);
expect(result.current.canAdd).toBe(true);
expect(result.current.nothingToShow).toBe(false);
});
it('FE-COMP-PASSKEYS-HOOK-004: adding needs the toggle and a usable RP ID', async () => {
serve({ passkey_login: true, passkey_configured: false });
const { result } = await loaded();
await waitFor(() => expect(result.current.enabled).toBe(true));
expect(result.current.canAdd).toBe(false);
});
it('FE-COMP-PASSKEYS-HOOK-005: with the feature off and no credentials there is nothing to show', async () => {
serve({ passkey_login: false }, []);
const { result } = await loaded();
expect(result.current.nothingToShow).toBe(true);
});
it('FE-COMP-PASSKEYS-HOOK-006: adding runs the step-up and the ceremony, then resets the form and reloads', async () => {
serve({ passkey_login: true, passkey_configured: true });
vi.spyOn(authApi.passkey, 'registerOptions').mockResolvedValue({ challenge: 'c' });
vi.spyOn(authApi.passkey, 'registerVerify').mockResolvedValue({});
startRegistration.mockResolvedValue({ id: 'att' });
const { result } = await loaded();
act(() => result.current.setAddOpen(true));
act(() => result.current.setAddPwd('pw'));
act(() => result.current.setAddName(' Phone '));
vi.mocked(authApi.passkey.list).mockClear();
await act(() => result.current.handleAdd());
expect(authApi.passkey.registerOptions).toHaveBeenCalledWith('pw');
expect(startRegistration).toHaveBeenCalledWith({ optionsJSON: { challenge: 'c' } });
expect(authApi.passkey.registerVerify).toHaveBeenCalledWith({ id: 'att' }, 'Phone');
expect(toast.success).toHaveBeenCalledWith('settings.passkey.addedToast');
expect(result.current.addOpen).toBe(false);
expect(result.current.addPwd).toBe('');
expect(authApi.passkey.list).toHaveBeenCalledTimes(1);
expect(result.current.busy).toBe(false);
});
it('FE-COMP-PASSKEYS-HOOK-007: a cancelled ceremony and a refused step-up toast differently', async () => {
serve({ passkey_login: true, passkey_configured: true });
vi.spyOn(authApi.passkey, 'registerOptions').mockResolvedValue({});
startRegistration.mockRejectedValueOnce({ name: 'NotAllowedError' });
const { result } = await loaded();
await act(() => result.current.handleAdd());
expect(toast.error).toHaveBeenLastCalledWith('settings.passkey.cancelled');
vi.mocked(authApi.passkey.registerOptions).mockRejectedValueOnce({
response: { data: { error: 'Wrong password' } },
});
await act(() => result.current.handleAdd());
expect(toast.error).toHaveBeenLastCalledWith('Wrong password');
});
it('FE-COMP-PASSKEYS-HOOK-008: a blank rename just closes the editor, a name is saved trimmed', async () => {
serve({ passkey_login: true });
const rename = vi.spyOn(authApi.passkey, 'rename').mockResolvedValue({});
const { result } = await loaded();
act(() => result.current.startRename(CRED));
expect(result.current.renamingId).toBe(1);
expect(result.current.renameVal).toBe('MacBook');
act(() => result.current.setRenameVal(' '));
await act(() => result.current.handleRename(1));
expect(rename).not.toHaveBeenCalled();
expect(result.current.renamingId).toBeNull();
act(() => result.current.startRename(CRED));
act(() => result.current.setRenameVal(' Work '));
await act(() => result.current.handleRename(1));
expect(rename).toHaveBeenCalledWith(1, 'Work');
expect(result.current.renamingId).toBeNull();
});
it('FE-COMP-PASSKEYS-HOOK-009: a failed rename keeps the editor open and toasts', async () => {
serve({ passkey_login: true });
vi.spyOn(authApi.passkey, 'rename').mockRejectedValue({});
const { result } = await loaded();
act(() => result.current.startRename(CRED));
await act(() => result.current.handleRename(1));
expect(result.current.renamingId).toBe(1);
expect(toast.error).toHaveBeenCalledWith('common.error');
});
it('FE-COMP-PASSKEYS-HOOK-010: delete sends the step-up password, then closes the confirm and reloads', async () => {
serve({ passkey_login: true });
const del = vi.spyOn(authApi.passkey, 'delete').mockResolvedValue({});
const { result } = await loaded();
act(() => result.current.startDelete(1));
expect(result.current.deletingId).toBe(1);
act(() => result.current.setDeletePwd('pw'));
await act(() => result.current.handleDelete(1));
expect(del).toHaveBeenCalledWith(1, 'pw');
expect(toast.success).toHaveBeenCalledWith('settings.passkey.deleted');
expect(result.current.deletingId).toBeNull();
expect(result.current.deletePwd).toBe('');
});
it('FE-COMP-PASSKEYS-HOOK-011: the cancel helpers clear their step-up', async () => {
serve({ passkey_login: true });
const { result } = await loaded();
act(() => result.current.startDelete(1));
act(() => result.current.setDeletePwd('x'));
act(() => result.current.cancelDelete());
expect(result.current.deletingId).toBeNull();
expect(result.current.deletePwd).toBe('');
act(() => result.current.setAddOpen(true));
act(() => result.current.setAddPwd('x'));
act(() => result.current.setAddName('y'));
act(() => result.current.cancelAdd());
expect(result.current.addOpen).toBe(false);
expect(result.current.addPwd).toBe('');
expect(result.current.addName).toBe('');
});
it('FE-COMP-PASSKEYS-HOOK-012: a refused delete toasts the server reason and keeps the confirm open', async () => {
serve({ passkey_login: true });
vi.spyOn(authApi.passkey, 'delete').mockRejectedValue({ response: { data: { error: 'Wrong password' } } });
const { result } = await loaded();
act(() => result.current.startDelete(1));
act(() => result.current.setDeletePwd('bad'));
await act(() => result.current.handleDelete(1));
expect(toast.error).toHaveBeenCalledWith('Wrong password');
expect(toast.success).not.toHaveBeenCalled();
expect(result.current.deletingId).toBe(1);
expect(result.current.deletePwd).toBe('bad');
expect(result.current.busy).toBe(false);
});
it('FE-COMP-PASSKEYS-HOOK-013: demo mode hides the view, otherwise it hides only with nothing to show', async () => {
serve({ passkey_login: true, passkey_configured: true });
const demo = renderHook(() => usePasskeys({ demoMode: true }));
await waitFor(() => expect(demo.result.current.loading).toBe(false));
expect(demo.result.current.hidden).toBe(true);
expect(demo.result.current.creds).toEqual([CRED]);
const live = await loaded();
expect(live.result.current.hidden).toBe(false);
serve({ passkey_login: false }, []);
const empty = await loaded();
expect(empty.result.current.hidden).toBe(true);
});
it('FE-COMP-PASSKEYS-HOOK-014: the not configured warning needs the toggle on and no usable RP ID', async () => {
serve({ passkey_login: true, passkey_configured: false });
const missing = await loaded();
await waitFor(() => expect(missing.result.current.enabled).toBe(true));
expect(missing.result.current.notConfigured).toBe(true);
serve({ passkey_login: true, passkey_configured: true });
const ready = await loaded();
await waitFor(() => expect(ready.result.current.configured).toBe(true));
expect(ready.result.current.notConfigured).toBe(false);
serve({ passkey_login: false, passkey_configured: false });
const off = await loaded();
expect(off.result.current.notConfigured).toBe(false);
});
});
@@ -0,0 +1,175 @@
import { startRegistration } from '@simplewebauthn/browser';
import { useEffect, useState } from 'react';
import { authApi, type PasskeyCredential } from '../../api/client';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { useToast } from '../shared/Toast';
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
export function fmtDate(ts: string | null): string | null {
if (!ts) return null;
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T');
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString();
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
export function isWebauthnAbort(err: unknown): boolean {
const name = (err as { name?: string })?.name;
return name === 'NotAllowedError' || name === 'AbortError';
}
/**
* Passkey enrolment and management behind the desktop section and the phone card:
* list, add (a password step-up, then the WebAuthn ceremony), rename, and delete
* (password step-up). Adding needs the instance toggle on AND a usable RP ID; the
* list stays reachable when the feature is later switched off so users can clean up.
* `hidden` tells a view to render nothing: in demo mode, or when there is nothing to show.
*/
export function usePasskeys({ demoMode = false }: { demoMode?: boolean } = {}) {
const { t } = useTranslation();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [configured, setConfigured] = useState(false);
const [creds, setCreds] = useState<PasskeyCredential[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [addPwd, setAddPwd] = useState('');
const [addName, setAddName] = useState('');
const [renamingId, setRenamingId] = useState<number | null>(null);
const [renameVal, setRenameVal] = useState('');
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deletePwd, setDeletePwd] = useState('');
const refresh = () => {
authApi.passkey
.list()
.then((r) => setCreds(r.credentials))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => {
authApi
.getAppConfig?.()
.then((c) => {
setEnabled(!!c?.passkey_login);
setConfigured(!!c?.passkey_configured);
})
.catch(() => {});
refresh();
}, []);
const canAdd = enabled && configured;
// Nothing to show: feature off and the user has no credentials to manage.
const nothingToShow = !loading && !enabled && creds.length === 0;
const hidden = demoMode || nothingToShow;
// The toggle is on but no usable RP ID resolves, so adding stays off.
const notConfigured = enabled && !configured;
// Both step-up flows are gated by disabled={busy || !pwd} on their submit button,
// so the password is always present by the time these run.
const handleAdd = async () => {
setBusy(true);
try {
const options = await authApi.passkey.registerOptions(addPwd);
const attResp = await startRegistration({ optionsJSON: options });
await authApi.passkey.registerVerify(attResp, addName.trim() || undefined);
toast.success(t('settings.passkey.addedToast'));
setAddOpen(false);
setAddPwd('');
setAddName('');
refresh();
} catch (err: unknown) {
if (isWebauthnAbort(err)) toast.error(t('settings.passkey.cancelled'));
else toast.error(getApiErrorMessage(err, t('settings.passkey.addError')));
} finally {
setBusy(false);
}
};
const handleRename = async (id: number) => {
const name = renameVal.trim();
if (!name) {
setRenamingId(null);
return;
}
try {
await authApi.passkey.rename(id, name);
setRenamingId(null);
refresh();
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')));
}
};
const handleDelete = async (id: number) => {
setBusy(true);
try {
await authApi.passkey.delete(id, deletePwd);
toast.success(t('settings.passkey.deleted'));
setDeletingId(null);
setDeletePwd('');
refresh();
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')));
} finally {
setBusy(false);
}
};
const startRename = (c: PasskeyCredential) => {
setRenamingId(c.id);
setRenameVal(c.name || '');
};
const startDelete = (id: number) => {
setDeletingId(id);
setDeletePwd('');
};
const cancelDelete = () => {
setDeletingId(null);
setDeletePwd('');
};
const cancelAdd = () => {
setAddOpen(false);
setAddPwd('');
setAddName('');
};
return {
enabled,
configured,
creds,
loading,
busy,
canAdd,
nothingToShow,
hidden,
notConfigured,
addOpen,
setAddOpen,
addPwd,
setAddPwd,
addName,
setAddName,
renamingId,
setRenamingId,
renameVal,
setRenameVal,
deletingId,
deletePwd,
setDeletePwd,
handleAdd,
handleRename,
handleDelete,
startRename,
startDelete,
cancelDelete,
cancelAdd,
};
}
@@ -17,35 +17,21 @@ import {
X,
} from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router'
import { startRegistration } from '@simplewebauthn/browser'
import { escapeHtml } from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { useAuthStore } from '../../../store/authStore'
import { useToast } from '../../../components/shared/Toast'
import { authApi, adminApi, type PasskeyCredential } from '../../../api/client'
import { authApi, adminApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import type { UserWithOidc } from '../../../types'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
import { passwordErrorKey } from '../../../utils/passwordError'
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
function fmtDate(ts: string | null): string | null {
if (!ts) return null
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString()
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
function isWebauthnAbort(err: unknown): boolean {
const name = (err as { name?: string })?.name
return name === 'NotAllowedError' || name === 'AbortError'
}
/** Drop trailing slashes for display, as a scan: without it /\/+$/ backtracks quadratically. */
function trimTrailingSlashes(value: string): string {
let end = value.length
@@ -536,125 +522,38 @@ export default function MSettingsAccount() {
*/
function MPasskeysCard({ demoMode }: { demoMode?: boolean }): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const [enabled, setEnabled] = useState(false)
const [configured, setConfigured] = useState(false)
const [creds, setCreds] = useState<PasskeyCredential[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [addPwd, setAddPwd] = useState('')
const [addName, setAddName] = useState('')
const [renamingId, setRenamingId] = useState<number | null>(null)
const [renameVal, setRenameVal] = useState('')
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletePwd, setDeletePwd] = useState('')
const refresh = () => {
authApi.passkey.list()
.then((r) => setCreds(r.credentials))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(() => {
authApi.getAppConfig?.()
.then((c) => {
setEnabled(!!c?.passkey_login)
setConfigured(!!c?.passkey_configured)
})
.catch(() => {})
refresh()
}, [])
const canAdd = enabled && configured
const handleAdd = async () => {
setBusy(true)
try {
const options = await authApi.passkey.registerOptions(addPwd)
const attResp = await startRegistration({ optionsJSON: options })
await authApi.passkey.registerVerify(attResp, addName.trim() || undefined)
toast.success(t('settings.passkey.addedToast'))
setAddOpen(false)
setAddPwd('')
setAddName('')
refresh()
} catch (err: unknown) {
if (isWebauthnAbort(err)) toast.error(t('settings.passkey.cancelled'))
else toast.error(getApiErrorMessage(err, t('settings.passkey.addError')))
} finally {
setBusy(false)
}
}
const handleRename = async (id: number) => {
const name = renameVal.trim()
if (!name) {
setRenamingId(null)
return
}
try {
await authApi.passkey.rename(id, name)
setRenamingId(null)
refresh()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleDelete = async (id: number) => {
setBusy(true)
try {
await authApi.passkey.delete(id, deletePwd)
toast.success(t('settings.passkey.deleted'))
setDeletingId(null)
setDeletePwd('')
refresh()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusy(false)
}
}
if (demoMode) return null
// Nothing to show: feature off and no credentials left to manage.
if (!loading && !enabled && creds.length === 0) return null
const passkeys = usePasskeys({ demoMode })
if (passkeys.hidden) return null
return (
<MSetCard title={t('settings.passkey.title')} icon={Fingerprint} className="mt-3">
<p className="text-[0.75rem] leading-relaxed text-m-muted">{t('settings.passkey.description')}</p>
{enabled && !configured && (
{passkeys.notConfigured && (
<p className="mt-2 text-[0.75rem] font-semibold text-[color:var(--m-st-pending)]">{t('settings.passkey.notConfigured')}</p>
)}
{creds.length > 0 && (
{passkeys.creds.length > 0 && (
<ul className="m-0 mt-3 flex list-none flex-col gap-2 p-0">
{creds.map((c) => (
{passkeys.creds.map((c) => (
<li key={c.id} className="flex items-center gap-[10px] rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3">
<Fingerprint size={15} className="flex-none text-m-muted" />
<div className="min-w-0 flex-1">
{renamingId === c.id ? (
{passkeys.renamingId === c.id ? (
<div className="flex items-center gap-2">
<MSetInput
autoFocus
value={renameVal}
onChange={(e) => setRenameVal(e.target.value)}
value={passkeys.renameVal}
onChange={(e) => passkeys.setRenameVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRename(c.id)
if (e.key === 'Escape') setRenamingId(null)
if (e.key === 'Enter') void passkeys.handleRename(c.id)
if (e.key === 'Escape') passkeys.setRenamingId(null)
}}
/>
<button type="button" onClick={() => handleRename(c.id)} className="p-1 text-[color:var(--m-st-confirmed)]" aria-label={t('common.save')}>
<button type="button" onClick={() => passkeys.handleRename(c.id)} className="p-1 text-[color:var(--m-st-confirmed)]" aria-label={t('common.save')}>
<Check size={16} />
</button>
<button type="button" onClick={() => setRenamingId(null)} className="p-1 text-m-muted" aria-label={t('common.cancel')}>
<button type="button" onClick={() => passkeys.setRenamingId(null)} className="p-1 text-m-muted" aria-label={t('common.cancel')}>
<X size={16} />
</button>
</div>
@@ -678,14 +577,11 @@ function MPasskeysCard({ demoMode }: { demoMode?: boolean }): React.ReactElement
</>
)}
</div>
{renamingId !== c.id && (
{passkeys.renamingId !== c.id && (
<div className="flex flex-none items-center gap-1">
<button
type="button"
onClick={() => {
setRenamingId(c.id)
setRenameVal(c.name || '')
}}
onClick={() => passkeys.startRename(c)}
className="rounded p-[6px] text-m-muted"
aria-label={t('settings.passkey.rename')}
>
@@ -693,10 +589,7 @@ function MPasskeysCard({ demoMode }: { demoMode?: boolean }): React.ReactElement
</button>
<button
type="button"
onClick={() => {
setDeletingId(c.id)
setDeletePwd('')
}}
onClick={() => passkeys.startDelete(c.id)}
className="rounded p-[6px] text-[color:var(--m-st-danger)]"
aria-label={t('common.delete')}
>
@@ -710,26 +603,23 @@ function MPasskeysCard({ demoMode }: { demoMode?: boolean }): React.ReactElement
)}
{/* Delete confirmation (password step-up) */}
{deletingId !== null && (
{passkeys.deletingId !== null && (
<div className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
<p className="m-0 text-[0.78125rem] font-bold text-m-ink">{t('settings.passkey.deleteConfirm')}</p>
<MSetInput
type="password"
className="mt-2"
value={deletePwd}
onChange={(e) => setDeletePwd(e.target.value)}
value={passkeys.deletePwd}
onChange={(e) => passkeys.setDeletePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
/>
<div className="mt-2 flex gap-2">
<MSetButton variant="danger" disabled={busy || !deletePwd} onClick={() => handleDelete(deletingId)}>
<MSetButton variant="danger" disabled={passkeys.busy || !passkeys.deletePwd} onClick={() => passkeys.handleDelete(passkeys.deletingId)}>
{t('common.delete')}
</MSetButton>
<MSetButton
variant="ghost"
onClick={() => {
setDeletingId(null)
setDeletePwd('')
}}
onClick={passkeys.cancelDelete}
>
{t('common.cancel')}
</MSetButton>
@@ -738,42 +628,38 @@ function MPasskeysCard({ demoMode }: { demoMode?: boolean }): React.ReactElement
)}
{/* Add a passkey */}
{canAdd &&
(addOpen ? (
{passkeys.canAdd &&
(passkeys.addOpen ? (
<div className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3">
<p className="m-0 text-[0.78125rem] font-bold text-m-ink">{t('settings.passkey.addTitle')}</p>
<MSetHint className="mt-1">{t('settings.passkey.passwordPrompt')}</MSetHint>
<MSetInput
type="password"
className="mt-2"
value={addPwd}
onChange={(e) => setAddPwd(e.target.value)}
value={passkeys.addPwd}
onChange={(e) => passkeys.setAddPwd(e.target.value)}
placeholder={t('settings.currentPassword')}
/>
<MSetInput
className="mt-2"
value={addName}
onChange={(e) => setAddName(e.target.value)}
value={passkeys.addName}
onChange={(e) => passkeys.setAddName(e.target.value)}
placeholder={t('settings.passkey.namePlaceholder')}
/>
<div className="mt-3 flex gap-2">
<MSetButton disabled={busy || !addPwd} onClick={handleAdd}>
<MSetButton disabled={passkeys.busy || !passkeys.addPwd} onClick={passkeys.handleAdd}>
{t('settings.passkey.add')}
</MSetButton>
<MSetButton
variant="ghost"
onClick={() => {
setAddOpen(false)
setAddPwd('')
setAddName('')
}}
onClick={passkeys.cancelAdd}
>
{t('common.cancel')}
</MSetButton>
</div>
</div>
) : (
<MSetButton className="mt-3" variant="ghost" onClick={() => setAddOpen(true)}>
<MSetButton className="mt-3" variant="ghost" onClick={() => passkeys.setAddOpen(true)}>
<Fingerprint size={13} />
{t('settings.passkey.add')}
</MSetButton>