"use client"; import { useEffect, useRef, useState, useTransition } from "react"; import { beginRegistration, finishRegistration, deletePasskey, } from "@/app/auth-actions"; import { startRegistration } from "@simplewebauthn/browser"; import ConfirmDialog from "@/components/confirm-dialog"; import FormDialogShell, { Field, inputClass } from "@/components/form-dialog-shell"; import Toast, { type ToastMessage } from "@/components/toast"; type Passkey = { id: string; counter: number; transports: string[]; name: string; createdAt: string; }; type Props = { initial: Passkey[]; username: string; }; function relativeTime(iso: string): string { const then = new Date(iso).getTime(); if (!Number.isFinite(then)) return iso; const now = Date.now(); const seconds = Math.max(1, Math.round((now - then) / 1000)); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.round(hours / 24); if (days < 30) return `${days}d ago`; return new Date(iso).toLocaleDateString(); } export default function PasskeyList({ initial, username: _username }: Props) { const [list, setList] = useState(initial); const [addOpen, setAddOpen] = useState(false); const [deviceName, setDeviceName] = useState(""); const [addError, setAddError] = useState(null); const [addPending, startAdd] = useTransition(); const [removeTarget, setRemoveTarget] = useState(null); const [removePending, startRemove] = useTransition(); const [removeError, setRemoveError] = useState(null); const [toast, setToast] = useState(null); const nameInputRef = useRef(null); useEffect(() => { setList(initial); }, [initial]); useEffect(() => { if (!addOpen) return; setDeviceName(""); setAddError(null); if (typeof window !== "undefined") { const isPointerDevice = window.matchMedia( "(hover: hover) and (pointer: fine)", ).matches; if (isPointerDevice) { requestAnimationFrame(() => nameInputRef.current?.focus()); } } }, [addOpen]); function submitAdd() { const name = deviceName.trim(); if (!name) { setAddError("Device name is required"); return; } setAddError(null); startAdd(async () => { try { const options = await beginRegistration(); const response = await startRegistration({ optionsJSON: options }); const result = await finishRegistration(response, name); if (result.ok) { setAddOpen(false); setToast({ type: "success", message: "Passkey added" }); } else { setAddError(result.error); } } catch (err) { const message = err instanceof Error ? err.message : "Enrollment failed"; if ( message.toLowerCase().includes("notallowed") || message.toLowerCase().includes("cancel") ) { setAddError("Enrollment was cancelled"); } else { setAddError(message); } } }); } function submitRemove() { const target = removeTarget; if (!target) return; setRemoveError(null); startRemove(async () => { const result = await deletePasskey(target.id); if (result.ok) { setRemoveTarget(null); setToast({ type: "success", message: "Passkey removed" }); } else { setRemoveError(result.error); } }); } return (

Settings

Passkeys {list.length}

{list.length === 0 ? (

No passkeys enrolled yet

Add one to sign in with Face ID, Touch ID, or fingerprint on this device.

) : (
    {list.map((p) => (
  • {p.name}

    Added {relativeTime(p.createdAt)}

  • ))}
)} { if (!addPending) setAddOpen(false); }} onSubmit={submitAdd} title="Add passkey" submitLabel="Continue" pending={addPending} error={addError} > setDeviceName(e.target.value)} disabled={addPending} autoComplete="off" spellCheck={false} placeholder="iPhone 15" className={inputClass} />

Your browser will prompt for Face ID, Touch ID, or fingerprint.

{ if (!removePending) setRemoveTarget(null); }} onConfirm={submitRemove} title="Remove passkey?" message={ <> Remove the passkey{" "} {removeTarget?.name ?? ""} ? You’ll lose the ability to sign in from this device until you enroll it again. {removeError && ( {removeError} )} } confirmLabel="Remove" destructive pending={removePending} /> setToast(null)} />
); }