cm_bot_v2/web/app/cm-passkeys/passkey-list.tsx

276 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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&rsquo;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>
);
}