"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 (
{passkeys.length > 0 ? ( ) : (

Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.

)}
setName(event.target.value)} placeholder={geraeteVorschlag()} aria-label="Name für den neuen Passkey" maxLength={60} className="fl-input w-56 py-1.5 text-sm" />
{passkeys.some((passkey) => !passkey.backedUp) && (

Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs Erste einen zweiten Anmeldeweg bereit.

)}
); } /** Ein brauchbarer Vorschlag, damit niemand „Passkey 1" tippen muss. */ function geraeteVorschlag(): string { if (typeof navigator === "undefined") return "Mein Gerät"; const ua = navigator.userAgent; if (/iPhone/.test(ua)) return "iPhone"; if (/iPad/.test(ua)) return "iPad"; if (/Android/.test(ua)) return "Android-Gerät"; if (/Mac OS X/.test(ua)) return "Mac"; if (/Windows/.test(ua)) return "Windows-PC"; if (/Linux/.test(ua)) return "Linux-Rechner"; return "Mein Gerät"; } /** Knopf auf der Anmeldeseite. */ export function PasskeyLoginButton({ label }: { label: string }) { const router = useRouter(); const [pending, setPending] = useState(false); const [fehler, setFehler] = useState(null); async function anmelden() { setFehler(null); setPending(true); try { const antwort = await fetch("/api/auth/passkey/anmeldung/start", { method: "POST" }); const optionen = await antwort.json(); if (!antwort.ok) { setFehler(optionen.error ?? "Das hat nicht geklappt."); return; } const ergebnis = await startAuthentication({ optionsJSON: optionen }); const fertig = await fetch("/api/auth/passkey/anmeldung/abschluss", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ergebnis), }); const daten = await fertig.json(); if (!fertig.ok) { setFehler(daten.error ?? "Das hat nicht geklappt."); return; } router.replace("/"); router.refresh(); } catch (error) { setFehler(passkeyFehlertext(error)); } finally { setPending(false); } } return (
{fehler && (

{fehler}

)}
); } function KeyIcon() { return ( ); }