From 231acced7ef27fb220924add16d5aadf9e441121 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:43:52 +0200 Subject: [PATCH] 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. --- .../components/Settings/PasskeysSection.tsx | 165 +++----------- .../components/Settings/usePasskeys.test.ts | 212 ++++++++++++++++++ client/src/components/Settings/usePasskeys.ts | 175 +++++++++++++++ .../screens/settings/MSettingsAccount.tsx | 176 +++------------ 4 files changed, 452 insertions(+), 276 deletions(-) create mode 100644 client/src/components/Settings/usePasskeys.test.ts create mode 100644 client/src/components/Settings/usePasskeys.ts diff --git a/client/src/components/Settings/PasskeysSection.tsx b/client/src/components/Settings/PasskeysSection.tsx index 2ba436010..bd3bd8bf6 100644 --- a/client/src/components/Settings/PasskeysSection.tsx +++ b/client/src/components/Settings/PasskeysSection.tsx @@ -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([]) - 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(null) - const [renameVal, setRenameVal] = useState('') - - const [deletingId, setDeletingId] = useState(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 (
0 ? {creds.length} : undefined} + badge={pk.creds.length > 0 ? {pk.creds.length} : undefined} // The way in sits on the band, like the planner's "add" actions; the step-up opens in the body. - action={canAdd && !addOpen ? ( - @@ -135,39 +38,39 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R > {t('settings.passkey.description')} - {enabled && !configured && ( + {pk.notConfigured && (

{t('settings.passkey.notConfigured')}

)} - {creds.length > 0 && ( + {pk.creds.length > 0 && (
    - {creds.map(c => ( + {pk.creds.map(c => (
  • - {renamingId === c.id ? ( + {pk.renamingId === c.id ? (
    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`} /> - - @@ -190,12 +93,12 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R )}
    - {renamingId !== c.id && ( + {pk.renamingId !== c.id && (
    diff --git a/client/src/components/Settings/usePasskeys.test.ts b/client/src/components/Settings/usePasskeys.test.ts new file mode 100644 index 000000000..28d39a47c --- /dev/null +++ b/client/src/components/Settings/usePasskeys.test.ts @@ -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>); + 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); + }); +}); diff --git a/client/src/components/Settings/usePasskeys.ts b/client/src/components/Settings/usePasskeys.ts new file mode 100644 index 000000000..89d89cc68 --- /dev/null +++ b/client/src/components/Settings/usePasskeys.ts @@ -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([]); + 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(null); + const [renameVal, setRenameVal] = useState(''); + + const [deletingId, setDeletingId] = useState(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, + }; +} diff --git a/client/src/mobile/screens/settings/MSettingsAccount.tsx b/client/src/mobile/screens/settings/MSettingsAccount.tsx index dcc64af75..6a6ef4d02 100644 --- a/client/src/mobile/screens/settings/MSettingsAccount.tsx +++ b/client/src/mobile/screens/settings/MSettingsAccount.tsx @@ -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([]) - 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(null) - const [renameVal, setRenameVal] = useState('') - - const [deletingId, setDeletingId] = useState(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 (

    {t('settings.passkey.description')}

    - {enabled && !configured && ( + {passkeys.notConfigured && (

    {t('settings.passkey.notConfigured')}

    )} - {creds.length > 0 && ( + {passkeys.creds.length > 0 && (
      - {creds.map((c) => ( + {passkeys.creds.map((c) => (
    • - {renamingId === c.id ? ( + {passkeys.renamingId === c.id ? (
      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) }} /> - -
      @@ -678,14 +577,11 @@ function MPasskeysCard({ demoMode }: { demoMode?: boolean }): React.ReactElement )}
      - {renamingId !== c.id && ( + {passkeys.renamingId !== c.id && (