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 && (