feat(admin): Kontoverwaltung und Admin-Einstellungen

"Mein Konto": Profil, Passwort setzen oder wechseln, Passkeys anlegen,
umbenennen und entfernen, aktive Sitzungen einsehen und ueberall abmelden.
Der letzte Passkey laesst sich nicht entfernen, solange kein Passwort
gesetzt ist - sonst waere das Konto ausgesperrt.

Admin-Einstellungen: Betriebsart umschalten, Selbstregistrierung freigeben,
Konten anlegen, bearbeiten, deaktivieren und loeschen. Die Instanzwerte aus
der .env werden angezeigt, damit sichtbar ist, warum Passkeys gegebenenfalls
nicht verfuegbar sind.

Schutzregeln:
- Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein
  Konto existiert.
- Der letzte aktive Administrator kann weder herabgestuft noch deaktiviert
  oder geloescht werden.
- Das eigene Konto laesst sich nicht loeschen oder deaktivieren.
- Deaktivieren oder ein neues Passwort beenden sofort alle Sitzungen des
  Kontos.

Administratoren verwalten Konten, keine Inhalte: fremde Projekte sehen auch
sie nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 21:30:33 +02:00
co-authored by Claude Opus 5
parent 4f85e00016
commit cf4103a46c
11 changed files with 1810 additions and 0 deletions
+530
View File
@@ -0,0 +1,530 @@
"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 { 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;
}
type Notice = { ok: boolean; text: string } | null;
export function AccountManager({ data }: { data: AccountData }) {
return (
<div className="space-y-6">
<ProfileSection data={data} />
<PasswordSection hasPassword={data.hasPassword} />
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
<SessionSection sessions={data.sessions} />
</div>
);
}
function SectionCard({
title,
description,
icon: Icon,
children,
}: {
title: string;
description?: string;
icon: typeof KeyRound;
children: React.ReactNode;
}) {
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Icon className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
{title}
</h2>
{description ? (
<p className="mt-1 text-sm text-slate-400">{description}</p>
) : null}
</div>
</div>
{children}
</section>
);
}
function NoticeLine({ notice }: { notice: Notice }) {
if (!notice) return null;
return (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
);
}
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<Notice>(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 (
<SectionCard title="Profil" icon={Pencil}>
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
<div>
<span className="field-label">Benutzername</span>
<p className="font-mono text-sm text-slate-400">@{data.username}</p>
<p className="mt-1 text-[11px] text-slate-600">
{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar
</p>
</div>
<div />
<div>
<label className="field-label" htmlFor="account-display">
Anzeigename
</label>
<input
id="account-display"
className="field"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="account-email">
E-Mail
</label>
<input
id="account-email"
className="field"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="optional"
/>
</div>
<div className="sm:col-span-2">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Speichern
</button>
<NoticeLine notice={notice} />
</div>
</form>
</SectionCard>
);
}
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<Notice>(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 (
<SectionCard
title={hasPassword ? "Passwort aendern" : "Passwort vergeben"}
icon={KeyRound}
description={
hasPassword
? "Nach der Aenderung werden alle anderen Sitzungen beendet."
: "Dieses Konto hat bisher kein Passwort und nutzt nur Passkeys."
}
>
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
{hasPassword ? (
<div className="sm:col-span-2">
<label className="field-label" htmlFor="account-current">
Aktuelles Passwort
</label>
<input
id="account-current"
className="field"
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
) : null}
<div>
<label className="field-label" htmlFor="account-new">
Neues Passwort
</label>
<input
id="account-new"
className="field"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
minLength={PASSWORD_MIN_LENGTH}
required
/>
</div>
<div>
<label className="field-label" htmlFor="account-repeat">
Wiederholen
</label>
<input
id="account-repeat"
className="field"
type="password"
value={repeat}
onChange={(event) => setRepeat(event.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="sm:col-span-2">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
{hasPassword ? "Passwort aendern" : "Passwort setzen"}
</button>
<NoticeLine notice={notice} />
</div>
</form>
</SectionCard>
);
}
function PasskeySection({
passkeys,
available,
}: {
passkeys: PasskeyEntry[];
available: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice>(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 (
<SectionCard
title="Passkeys"
icon={Fingerprint}
description="Anmeldung per Fingerabdruck, Gesichtserkennung oder Sicherheitsschluessel – ohne Passwort."
>
{!available ? (
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext:
<code className="mx-1 text-violet-300">APP_URL</code> muss auf eine https-Adresse
oder auf http://localhost zeigen.
</p>
) : (
<>
<div className="space-y-2">
{passkeys.length === 0 ? (
<p className="text-sm text-slate-500">Noch kein Passkey hinterlegt.</p>
) : (
passkeys.map((entry) => (
<div
key={entry.id}
className="glass-soft flex items-center justify-between gap-3 px-3 py-2.5"
>
<div className="min-w-0">
<p className="flex items-center gap-2 truncate text-sm text-slate-200">
<Fingerprint className="h-3.5 w-3.5 shrink-0 text-violet-300" />
{entry.name ?? "Unbenannter Passkey"}
{entry.backedUp ? (
<span className="rounded bg-emerald-500/12 px-1.5 py-0.5 text-[10px] text-emerald-200">
synchronisiert
</span>
) : null}
</p>
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
angelegt {formatDateTime(entry.createdAt)}
{entry.lastUsedAt
? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}`
: " · noch nie genutzt"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{busyId === entry.id ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
) : (
<>
<button
type="button"
onClick={() => rename(entry)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
aria-label="Umbenennen"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => remove(entry)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
))
)}
</div>
<button type="button" className="btn btn-ghost mt-4" onClick={addPasskey} disabled={busy}>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Passkey hinzufuegen
</button>
</>
)}
<NoticeLine notice={notice} />
</SectionCard>
);
}
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 (
<SectionCard
title="Aktive Sitzungen"
icon={Monitor}
description="Jede Anmeldung erzeugt eine Sitzung. Sie laeuft nach 30 Tagen ohne Nutzung ab."
>
<div className="space-y-2">
{sessions.map((session) => (
<div key={session.id} className="glass-soft px-3 py-2.5">
<p className="truncate text-sm text-slate-300">
{shortenUserAgent(session.userAgent)}
</p>
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} ·
angemeldet {formatDateTime(session.createdAt)}
</p>
</div>
))}
</div>
<button
type="button"
className="btn btn-danger mt-4"
onClick={logoutEverywhere}
disabled={busy || sessions.length <= 1}
>
{busy ? <Loader2 className="h-4 w-4" /> : <LogOut className="h-4 w-4" />}
Ueberall abmelden
</button>
{sessions.length <= 1 ? (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-600">
<ShieldCheck className="h-3 w-3" />
Nur dieses Geraet ist angemeldet.
</p>
) : null}
</SectionCard>
);
}
/** 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;
}