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
+665
View File
@@ -0,0 +1,665 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Ban,
CheckCircle2,
Fingerprint,
FolderGit2,
Loader2,
Lock,
Plus,
ShieldCheck,
Trash2,
User,
UserCog,
Users,
} from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
type Mode = "SINGLE_USER" | "MULTI_USER";
type Role = "ADMIN" | "USER";
export interface AdminUser {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: Role;
isActive: boolean;
lastLoginAt: string | Date | null;
lockedUntil: string | Date | null;
createdAt: string | Date;
_count: { credentials: number; projects: number; sessions: number };
}
export interface AdminData {
mode: Mode;
allowRegistration: boolean;
users: AdminUser[];
currentUserId: string;
appUrl: string;
passkeysAvailable: boolean;
}
type Notice = { ok: boolean; text: string } | null;
export function AdminManager({ data }: { data: AdminData }) {
const router = useRouter();
const [mode, setMode] = useState<Mode>(data.mode);
const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
async function patchSettings(payload: Partial<{ mode: Mode; allowRegistration: boolean }>) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
setMode(body.settings.mode);
setAllowRegistration(body.settings.allowRegistration);
setNotice({ ok: true, text: "Einstellungen gespeichert." });
router.refresh();
} catch (caught) {
// Umschalter zuruecksetzen, wenn der Server ablehnt.
setMode(data.mode);
setAllowRegistration(data.allowRegistration);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
{/* ── Betriebsart ─────────────────────────────────── */}
<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">
<Users className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Betriebsart
</h2>
<p className="mt-1 text-sm text-slate-400">
Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={mode === "SINGLE_USER"}
disabled={busy}
icon={User}
title="Einzelbetrieb"
hint="Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung."
onClick={() => {
setMode("SINGLE_USER");
void patchSettings({ mode: "SINGLE_USER" });
}}
/>
<ModeCard
active={mode === "MULTI_USER"}
disabled={busy}
icon={Users}
title="Mehrbenutzerbetrieb"
hint="Mehrere Konten. Projekte, Notizen und Git-Verbindungen bleiben streng getrennt."
onClick={() => {
setMode("MULTI_USER");
void patchSettings({ mode: "MULTI_USER" });
}}
/>
</div>
{mode === "MULTI_USER" ? (
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<input
type="checkbox"
checked={allowRegistration}
disabled={busy}
onChange={(event) => {
setAllowRegistration(event.target.checked);
void patchSettings({ allowRegistration: event.target.checked });
}}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">Selbstregistrierung erlauben</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
Ist das aus, legt nur ein Administrator neue Konten an. Bei einer aus dem
Internet erreichbaren Instanz besser aus lassen.
</span>
</span>
</label>
) : null}
{notice ? (
<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>
) : null}
</section>
{/* ── Instanz ─────────────────────────────────────── */}
<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-cyan-500/12 text-cyan-300">
<ShieldCheck className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Instanz
</h2>
<p className="mt-1 text-sm text-slate-400">
Diese Werte kommen aus der <code className="text-violet-300">.env</code> und
lassen sich nur dort aendern.
</p>
</div>
</div>
<dl className="grid gap-3 sm:grid-cols-2">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">APP_URL</dt>
<dd className="mt-1 truncate font-mono text-sm text-slate-200">{data.appUrl}</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">Passkeys</dt>
<dd className="mt-1 flex items-center gap-1.5 text-sm text-slate-200">
{data.passkeysAvailable ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
verfuegbar
</>
) : (
<>
<Ban className="h-3.5 w-3.5 text-amber-300" />
kein sicherer Kontext
</>
)}
</dd>
</div>
</dl>
</section>
{/* ── Benutzer ────────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-emerald-500/12 text-emerald-300">
<UserCog className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Benutzer ({data.users.length})
</h2>
<p className="mt-1 text-sm text-slate-400">
Administratoren verwalten Konten – Projekte anderer sehen sie nicht.
</p>
</div>
</div>
<button
type="button"
className="btn btn-primary px-3 py-1.5 text-xs"
onClick={() => setCreateOpen(true)}
disabled={mode === "SINGLE_USER"}
title={
mode === "SINGLE_USER"
? "Erst in den Mehrbenutzerbetrieb wechseln"
: undefined
}
>
<Plus className="h-3.5 w-3.5" />
Konto anlegen
</button>
</div>
<div className="space-y-2">
{data.users.map((user) => (
<UserRow
key={user.id}
user={user}
isSelf={user.id === data.currentUserId}
onEdit={() => setEditing(user)}
/>
))}
</div>
</section>
<UserDialog
open={createOpen}
mode="create"
onClose={() => setCreateOpen(false)}
/>
<UserDialog
open={editing !== null}
mode="edit"
user={editing}
isSelf={editing?.id === data.currentUserId}
onClose={() => setEditing(null)}
/>
</div>
);
}
function ModeCard({
active,
disabled,
icon: Icon,
title,
hint,
onClick,
}: {
active: boolean;
disabled: boolean;
icon: typeof User;
title: string;
hint: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"rounded-xl border px-4 py-3.5 text-left transition-all disabled:opacity-60",
active
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_28px_-12px_rgba(139,92,246,0.9)]"
: "border-white/9 hover:border-white/20",
)}
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
{title}
{active ? (
<CheckCircle2 className="ml-auto h-4 w-4 text-violet-300" />
) : null}
</span>
<span className="mt-1.5 block text-[11px] leading-snug text-slate-500">{hint}</span>
</button>
);
}
function UserRow({
user,
isSelf,
onEdit,
}: {
user: AdminUser;
isSelf: boolean;
onEdit: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
user.lockedUntil !== null && new Date(user.lockedUntil).getTime() > Date.now();
async function remove() {
if (
!window.confirm(
`Konto „${user.username}“ mit ${user._count.projects} Projekt(en) unwiderruflich loeschen?`,
)
) {
return;
}
setBusy(true);
setError(null);
try {
const response = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<div className="glass-soft px-3 py-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-lg text-[11px] font-semibold text-white",
user.isActive
? "bg-gradient-to-br from-violet-500/80 to-cyan-400/70"
: "bg-slate-700/60",
)}
>
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
<span className="truncate">{user.displayName ?? user.username}</span>
<span className="font-mono text-[11px] text-slate-500">@{user.username}</span>
{user.role === "ADMIN" ? (
<span className="inline-flex items-center gap-1 rounded bg-violet-500/12 px-1.5 py-0.5 text-[10px] text-violet-200">
<ShieldCheck className="h-2.5 w-2.5" />
Admin
</span>
) : null}
{isSelf ? (
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
du
</span>
) : null}
{!user.isActive ? (
<span className="rounded bg-rose-500/12 px-1.5 py-0.5 text-[10px] text-rose-200">
deaktiviert
</span>
) : null}
{locked ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] text-amber-200">
<Lock className="h-2.5 w-2.5" />
gesperrt
</span>
) : null}
</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<FolderGit2 className="h-3 w-3" />
{user._count.projects} Projekte
</span>
<span className="inline-flex items-center gap-1">
<Fingerprint className="h-3 w-3" />
{user._count.credentials} Passkeys
</span>
<span suppressHydrationWarning>
{user.lastLoginAt
? `zuletzt ${formatRelative(user.lastLoginAt)}`
: "noch nie angemeldet"}
</span>
<span suppressHydrationWarning>angelegt {formatDateTime(user.createdAt)}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onEdit}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
disabled={busy}
>
<UserCog className="h-3.5 w-3.5" />
Bearbeiten
</button>
<button
type="button"
onClick={remove}
className="btn btn-danger px-2.5 py-1.5 text-xs"
disabled={busy || isSelf}
title={isSelf ? "Das eigene Konto kann nicht geloescht werden" : "Loeschen"}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
</div>
);
}
function UserDialog({
open,
mode,
user,
isSelf,
onClose,
}: {
open: boolean;
mode: "create" | "edit";
user?: AdminUser | null;
isSelf?: boolean;
onClose: () => void;
}) {
const router = useRouter();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("USER");
const [isActive, setIsActive] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [loadedFor, setLoadedFor] = useState<string | null>(null);
// Formular beim Oeffnen einmalig aus dem Datensatz fuellen.
const key = open ? (user?.id ?? "new") : null;
if (key !== null && key !== loadedFor) {
setLoadedFor(key);
setUsername(user?.username ?? "");
setDisplayName(user?.displayName ?? "");
setEmail(user?.email ?? "");
setPassword("");
setRole(user?.role ?? "USER");
setIsActive(user?.isActive ?? true);
setError(null);
setFieldErrors({});
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldErrors({});
const body =
mode === "create"
? { username, displayName, email, password, role }
: { displayName, email, role, isActive, password: password || null };
try {
const response = await fetch(
mode === "create" ? "/api/admin/users" : `/api/admin/users/${user?.id}`,
{
method: mode === "create" ? "POST" : "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload.fields ?? {});
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
}
setLoadedFor(null);
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={() => {
setLoadedFor(null);
onClose();
}}
title={mode === "create" ? "Neues Konto" : `Konto bearbeiten`}
description={
mode === "create"
? "Das Konto kann sich sofort anmelden und einen eigenen Passkey hinterlegen."
: "Ein neues Passwort beendet alle Sitzungen dieses Kontos."
}
width="md"
>
<form onSubmit={submit} className="space-y-4">
{mode === "create" ? (
<div>
<label className="field-label" htmlFor="admin-username">
Benutzername *
</label>
<input
id="admin-username"
className="field"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoCapitalize="none"
spellCheck={false}
required
/>
{fieldErrors.username ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
) : null}
</div>
) : (
<p className="font-mono text-sm text-slate-400">@{user?.username}</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="admin-display">
Anzeigename
</label>
<input
id="admin-display"
className="field"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="admin-email">
E-Mail
</label>
<input
id="admin-email"
className="field"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
{fieldErrors.email ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="admin-password">
{mode === "create" ? "Passwort *" : "Neues Passwort (leer = unveraendert)"}
</label>
<input
id="admin-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined}
required={mode === "create"}
/>
{fieldErrors.password ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
) : null}
</div>
<div>
<span className="field-label">Rolle</span>
<div className="grid grid-cols-2 gap-2">
{(["USER", "ADMIN"] as Role[]).map((value) => (
<button
key={value}
type="button"
onClick={() => setRole(value)}
disabled={isSelf && value === "USER"}
title={
isSelf && value === "USER"
? "Die eigene Administratorrolle kann hier nicht abgegeben werden"
: undefined
}
className={cn(
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
role === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
{value === "ADMIN" ? "Administrator" : "Benutzer"}
</button>
))}
</div>
</div>
{mode === "edit" ? (
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
<input
type="checkbox"
checked={isActive}
onChange={(event) => setIsActive(event.target.checked)}
disabled={isSelf}
className="h-4 w-4 accent-violet-500"
/>
Konto aktiv
{isSelf ? (
<span className="text-[11px] text-slate-600">
(das eigene Konto laesst sich nicht deaktivieren)
</span>
) : null}
</label>
) : null}
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setLoadedFor(null);
onClose();
}}
disabled={busy}
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Speichern
</button>
</div>
</form>
</Modal>
);
}