276 lines
9.0 KiB
TypeScript
276 lines
9.0 KiB
TypeScript
"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<Passkey[]>(initial);
|
||
const [addOpen, setAddOpen] = useState(false);
|
||
const [deviceName, setDeviceName] = useState("");
|
||
const [addError, setAddError] = useState<string | null>(null);
|
||
const [addPending, startAdd] = useTransition();
|
||
const [removeTarget, setRemoveTarget] = useState<Passkey | null>(null);
|
||
const [removePending, startRemove] = useTransition();
|
||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
||
const nameInputRef = useRef<HTMLInputElement>(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 (
|
||
<div className="space-y-8">
|
||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||
Settings
|
||
</p>
|
||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
|
||
Passkeys
|
||
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
|
||
{list.length}
|
||
</span>
|
||
</h1>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setAddOpen(true)}
|
||
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
|
||
>
|
||
<span aria-hidden="true">+</span>
|
||
Add passkey
|
||
</button>
|
||
</div>
|
||
|
||
{list.length === 0 ? (
|
||
<div className="rounded-2xl bg-white p-10 text-center ring-1 ring-zinc-200/60">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-50 ring-1 ring-emerald-100">
|
||
<svg
|
||
aria-hidden="true"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
className="h-5 w-5 text-emerald-700"
|
||
>
|
||
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
|
||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||
</svg>
|
||
</div>
|
||
<p className="mt-4 text-sm font-medium text-zinc-900">
|
||
No passkeys enrolled yet
|
||
</p>
|
||
<p className="mx-auto mt-1 max-w-sm text-[13px] text-zinc-500">
|
||
Add one to sign in with Face ID, Touch ID, or fingerprint on this
|
||
device.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{list.map((p) => (
|
||
<li
|
||
key={p.id}
|
||
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 ring-1 ring-zinc-200/60"
|
||
>
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600">
|
||
<svg
|
||
aria-hidden="true"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.8"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
className="h-4 w-4"
|
||
>
|
||
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
|
||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||
</svg>
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-semibold text-zinc-900">
|
||
{p.name}
|
||
</p>
|
||
<p className="text-[11px] text-zinc-500">
|
||
Added {relativeTime(p.createdAt)}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setRemoveError(null);
|
||
setRemoveTarget(p);
|
||
}}
|
||
aria-label={`Remove passkey ${p.name}`}
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||
>
|
||
<span aria-hidden="true" className="text-base leading-none">
|
||
×
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<FormDialogShell
|
||
open={addOpen}
|
||
onCancel={() => {
|
||
if (!addPending) setAddOpen(false);
|
||
}}
|
||
onSubmit={submitAdd}
|
||
title="Add passkey"
|
||
submitLabel="Continue"
|
||
pending={addPending}
|
||
error={addError}
|
||
>
|
||
<Field label="Device name" required hint="e.g. iPhone 15, MacBook Pro">
|
||
<input
|
||
ref={nameInputRef}
|
||
value={deviceName}
|
||
onChange={(e) => setDeviceName(e.target.value)}
|
||
disabled={addPending}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="iPhone 15"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
<p className="text-[11px] text-zinc-500">
|
||
Your browser will prompt for Face ID, Touch ID, or fingerprint.
|
||
</p>
|
||
</FormDialogShell>
|
||
|
||
<ConfirmDialog
|
||
open={removeTarget !== null}
|
||
onCancel={() => {
|
||
if (!removePending) setRemoveTarget(null);
|
||
}}
|
||
onConfirm={submitRemove}
|
||
title="Remove passkey?"
|
||
message={
|
||
<>
|
||
Remove the passkey{" "}
|
||
<span className="font-mono text-zinc-900">
|
||
{removeTarget?.name ?? ""}
|
||
</span>
|
||
? You’ll lose the ability to sign in from this device until you
|
||
enroll it again.
|
||
{removeError && (
|
||
<span className="mt-2 block font-mono text-[11px] text-red-600">
|
||
{removeError}
|
||
</span>
|
||
)}
|
||
</>
|
||
}
|
||
confirmLabel="Remove"
|
||
destructive
|
||
pending={removePending}
|
||
/>
|
||
|
||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||
</div>
|
||
);
|
||
}
|