import React, { useEffect, useState } from 'react' import { Fingerprint, Plus, Trash2, Pencil, Check, X } 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' /** 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). * The "Add a passkey" action only appears when the instance toggle is on AND a * usable RP ID resolves; the existing-credential list stays reachable even when * the feature is later disabled so users can always clean up. */ 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 const handleAdd = async () => { if (!addPwd) { toast.error(t('settings.passkey.passwordRequired')); return } 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) => { if (!deletePwd) { toast.error(t('settings.passkey.passwordRequired')); return } 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 return (

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

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

{enabled && !configured && (

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

)} {creds.length > 0 && (
    {creds.map(c => (
  • {renamingId === c.id ? (
    setRenameVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }} className="flex-1 px-2 py-1 border border-slate-300 rounded text-sm" />
    ) : ( <>
    {c.name || t('settings.passkey.defaultName')} {c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}

    {t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'} {' · '} {c.last_used_at ? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}` : t('settings.passkey.neverUsed')}

    )}
    {renamingId !== c.id && (
    )}
  • ))}
)} {/* Delete confirmation (password step-up) */} {deletingId !== null && (

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

setDeletePwd(e.target.value)} placeholder={t('settings.currentPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" />
)} {/* Add a passkey */} {canAdd && (addOpen ? (

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

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

setAddPwd(e.target.value)} placeholder={t('settings.currentPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" /> setAddName(e.target.value)} placeholder={t('settings.passkey.namePlaceholder')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" />
) : ( ))}
) }