"use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { startAuthentication, startRegistration, WebAuthnError, } from "@simplewebauthn/browser"; import { useToasts } from "@/components/Toasts"; import { formatDate } from "@/lib/labels"; /** * Passkeys anlegen und verwalten. * * Der Browser übernimmt den heiklen Teil; hier laufen nur zwei Anfragen — * Optionen holen, Antwort einreichen. Fehler des Authenticators tragen * kryptische Namen, deshalb werden die häufigen übersetzt. */ export type PasskeyEintrag = { id: string; name: string; deviceType: string; backedUp: boolean; createdAt: string; lastUsedAt: string | null; }; /** Übersetzt die Fehler des Browsers in Sätze, mit denen man etwas anfangen kann. */ export function passkeyFehlertext(error: unknown): string | null { if (error instanceof WebAuthnError) { switch (error.name) { case "InvalidStateError": return "Dieses Gerät ist hier schon hinterlegt."; case "NotAllowedError": // Auch der Abbruch durch den Nutzer landet hier — dann ist Schweigen // die richtige Antwort. return null; case "SecurityError": return "Passkeys brauchen HTTPS oder localhost. Über diese Adresse geht es nicht."; default: return "Der Passkey konnte nicht verwendet werden."; } } if (error instanceof DOMException && error.name === "NotAllowedError") return null; return "Das hat nicht geklappt."; } export function PasskeyList({ passkeys, available, hinweis, }: { passkeys: PasskeyEintrag[]; available: boolean; hinweis: string | null; }) { const router = useRouter(); const { show } = useToasts(); const [pending, startTransition] = useTransition(); const [name, setName] = useState(""); async function anlegen() { try { const antwort = await fetch("/api/auth/passkey/registrierung/start", { method: "POST" }); const optionen = await antwort.json(); if (!antwort.ok) { show(optionen.error ?? "Das hat nicht geklappt.", { tone: "error" }); return; } const ergebnis = await startRegistration({ optionsJSON: optionen }); const fertig = await fetch("/api/auth/passkey/registrierung/abschluss", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() || geraeteVorschlag(), response: ergebnis }), }); const daten = await fertig.json(); if (!fertig.ok) { show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" }); return; } setName(""); show("Passkey angelegt"); startTransition(() => router.refresh()); } catch (error) { const text = passkeyFehlertext(error); if (text) show(text, { tone: "error" }); } } async function umbenennen(id: string, alt: string, neu: string) { const name = neu.trim(); if (!name || name === alt) return; const antwort = await fetch(`/api/passkeys/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ name }), }); if (!antwort.ok) { const daten = await antwort.json().catch(() => ({})); show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" }); return; } show("Umbenannt"); startTransition(() => router.refresh()); } async function entfernen(id: string, wie: string) { const antwort = await fetch(`/api/passkeys/${id}`, { method: "DELETE" }); const daten = await antwort.json().catch(() => ({})); if (!antwort.ok) { show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" }); return; } show(`„${wie}" entfernt`); startTransition(() => router.refresh()); } if (!available) { return (
{hinweis}
); } return ({passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"} {" · angelegt "} {formatDate(passkey.createdAt)} {passkey.lastUsedAt ? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}` : " · noch nicht benutzt"}
Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
)}Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs Erste einen zweiten Anmeldeweg bereit.
)}{fehler}
)}