"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { startRegistration } from "@simplewebauthn/browser"; import { Fingerprint, KeyRound, Loader2, LogOut, Monitor, Pencil, Save, ShieldCheck, Trash2, } from "lucide-react"; import { TotpSection, type TotpState } from "@/components/account/TotpSection"; import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants"; import { cn, formatDateTime, formatRelative } from "@/lib/utils"; export interface PasskeyEntry { id: string; name: string | null; deviceType: string | null; backedUp: boolean; createdAt: string | Date; lastUsedAt: string | Date | null; } export interface SessionEntry { id: string; userAgent: string | null; ip: string | null; createdAt: string | Date; lastSeenAt: string | Date; } export interface AccountData { username: string; displayName: string | null; email: string | null; role: "ADMIN" | "USER"; hasPassword: boolean; passkeys: PasskeyEntry[]; sessions: SessionEntry[]; passkeysAvailable: boolean; totp: TotpState; } type Notice = { ok: boolean; text: string } | null; export function AccountManager({ data }: { data: AccountData }) { return (
); } function SectionCard({ title, description, icon: Icon, children, }: { title: string; description?: string; icon: typeof KeyRound; children: React.ReactNode; }) { return (

{title}

{description ? (

{description}

) : null}
{children}
); } function NoticeLine({ notice }: { notice: Notice }) { if (!notice) return null; return (

{notice.text}

); } function ProfileSection({ data }: { data: AccountData }) { const router = useRouter(); const [displayName, setDisplayName] = useState(data.displayName ?? ""); const [email, setEmail] = useState(data.email ?? ""); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); async function save(event: React.FormEvent) { event.preventDefault(); setBusy(true); setNotice(null); try { const response = await fetch("/api/account/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName, email }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen."); setNotice({ ok: true, text: "Profil gespeichert." }); router.refresh(); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } return (
Benutzername

@{data.username}

{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar

setDisplayName(event.target.value)} />
setEmail(event.target.value)} placeholder="optional" />
); } function PasswordSection({ hasPassword }: { hasPassword: boolean }) { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [repeat, setRepeat] = useState(""); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); async function save(event: React.FormEvent) { event.preventDefault(); if (newPassword !== repeat) { setNotice({ ok: false, text: "Die Passwoerter stimmen nicht ueberein." }); return; } setBusy(true); setNotice(null); try { const response = await fetch("/api/account/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword, newPassword }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error ?? "Aenderung fehlgeschlagen."); setNotice({ ok: true, text: "Passwort gesetzt. Alle anderen Sitzungen wurden abgemeldet.", }); setCurrentPassword(""); setNewPassword(""); setRepeat(""); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } return (
{hasPassword ? (
setCurrentPassword(event.target.value)} autoComplete="current-password" required />
) : null}
setNewPassword(event.target.value)} autoComplete="new-password" minLength={PASSWORD_MIN_LENGTH} required />
setRepeat(event.target.value)} autoComplete="new-password" required />
); } function PasskeySection({ passkeys, available, }: { passkeys: PasskeyEntry[]; available: boolean; }) { const router = useRouter(); const [busy, setBusy] = useState(false); const [busyId, setBusyId] = useState(null); const [notice, setNotice] = useState(null); async function addPasskey() { setBusy(true); setNotice(null); try { const optionsResponse = await fetch("/api/auth/passkey/register/options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }); const optionsPayload = await optionsResponse.json().catch(() => ({})); if (!optionsResponse.ok) { throw new Error(optionsPayload.error ?? "Passkey konnte nicht vorbereitet werden."); } const attestation = await startRegistration({ optionsJSON: optionsPayload.options }); const suggestion = typeof navigator !== "undefined" && /mac/i.test(navigator.platform) ? "Mac" : typeof navigator !== "undefined" && /win/i.test(navigator.platform) ? "Windows" : "Geraet"; const verifyResponse = await fetch("/api/auth/passkey/register/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ response: attestation, name: suggestion }), }); const verifyPayload = await verifyResponse.json().catch(() => ({})); if (!verifyResponse.ok) { throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert."); } setNotice({ ok: true, text: "Passkey hinzugefuegt." }); router.refresh(); } catch (caught) { const message = caught instanceof Error ? caught.message : String(caught); if (!/abort|NotAllowed/i.test(message)) setNotice({ ok: false, text: message }); } finally { setBusy(false); } } async function rename(entry: PasskeyEntry) { const name = window.prompt("Name des Passkeys", entry.name ?? ""); if (name === null) return; setBusyId(entry.id); await fetch(`/api/account/passkeys/${entry.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }).catch(() => undefined); setBusyId(null); router.refresh(); } async function remove(entry: PasskeyEntry) { if (!window.confirm(`Passkey „${entry.name ?? "ohne Namen"}“ entfernen?`)) return; setBusyId(entry.id); try { const response = await fetch(`/api/account/passkeys/${entry.id}`, { method: "DELETE" }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen."); router.refresh(); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusyId(null); } } return ( {!available ? (

Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext: APP_URL muss auf eine https-Adresse oder auf http://localhost zeigen.

) : ( <>
{passkeys.length === 0 ? (

Noch kein Passkey hinterlegt.

) : ( passkeys.map((entry) => (

{entry.name ?? "Unbenannter Passkey"} {entry.backedUp ? ( synchronisiert ) : null}

angelegt {formatDateTime(entry.createdAt)} {entry.lastUsedAt ? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}` : " · noch nie genutzt"}

{busyId === entry.id ? ( ) : ( <> )}
)) )}
)}
); } function SessionSection({ sessions }: { sessions: SessionEntry[] }) { const router = useRouter(); const [busy, setBusy] = useState(false); async function logoutEverywhere() { if (!window.confirm("Auf allen anderen Geraeten abmelden?")) return; setBusy(true); await fetch("/api/account/sessions", { method: "DELETE" }).catch(() => undefined); setBusy(false); router.refresh(); } return (
{sessions.map((session) => (

{shortenUserAgent(session.userAgent)}

{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} · angemeldet {formatDateTime(session.createdAt)}

))}
{sessions.length <= 1 ? (

Nur dieses Geraet ist angemeldet.

) : null}
); } /** Aus dem User-Agent etwas Lesbares machen, ohne Bibliothek. */ function shortenUserAgent(userAgent: string | null): string { if (!userAgent) return "Unbekanntes Geraet"; const browser = /Edg\//.test(userAgent) ? "Edge" : /OPR\//.test(userAgent) ? "Opera" : /Firefox\//.test(userAgent) ? "Firefox" : /Chrome\//.test(userAgent) ? "Chrome" : /Safari\//.test(userAgent) ? "Safari" : "Browser"; const platform = /Windows/.test(userAgent) ? "Windows" : /Macintosh|Mac OS/.test(userAgent) ? "macOS" : /Android/.test(userAgent) ? "Android" : /iPhone|iPad/.test(userAgent) ? "iOS" : /Linux/.test(userAgent) ? "Linux" : ""; return platform ? `${browser} auf ${platform}` : browser; }