diff --git a/web/app/cm-passkeys/page.tsx b/web/app/cm-passkeys/page.tsx new file mode 100644 index 0000000..c945f77 --- /dev/null +++ b/web/app/cm-passkeys/page.tsx @@ -0,0 +1,12 @@ +import { requireSession } from "@/lib/auth"; +import { readPasskeys } from "@/lib/auth-store"; +import PasskeyList from "./passkey-list"; + +export default async function PasskeysPage() { + const session = await requireSession(); + const list = await readPasskeys(session.username); + const visible = list.map(({ publicKey: _pk, ...rest }) => rest); + return ; +} + +export const dynamic = "force-dynamic"; diff --git a/web/app/cm-passkeys/passkey-list.tsx b/web/app/cm-passkeys/passkey-list.tsx new file mode 100644 index 0000000..87f9659 --- /dev/null +++ b/web/app/cm-passkeys/passkey-list.tsx @@ -0,0 +1,275 @@ +"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)} /> +
+ ); +}