"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { Ban, CheckCircle2, Columns3, Database, Download, Fingerprint, FolderGit2, Hourglass, Loader2, Lock, Plus, RefreshCw, ShieldAlert, 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 AutoSyncReport { at: string; trigger: "zeitplan" | "manuell"; candidates: number; synced: number; failed: number; skipped: number; durationMs: number; } export interface AdminData { mode: Mode; allowRegistration: boolean; users: AdminUser[]; currentUserId: string; appUrl: string; passkeysAvailable: boolean; autoSync: { enabled: boolean; intervalMinutes: number; activeOnly: boolean; batchSize: number; lastRunAt: string | Date | null; lastReport: AutoSyncReport | null; /** Projekte mit Repository, die grundsaetzlich in Frage kommen. */ eligible: number; }; stale: { afterDays: number; lastCheckAt: string | Date | null; /** Wie viele Konten ueberhaupt auf Stillstand hoeren. */ listeners: number; }; tasks: { /** Karten je Spalte im Aufgabenbrett; 0 = ohne Begrenzung. */ columnLimit: number; /** Laengste Spalte ueber alle Projekte – ohne die ist die Zahl blind. */ laengsteSpalte: number; }; } type Notice = { ok: boolean; text: string } | null; export function AdminManager({ data }: { data: AdminData }) { const router = useRouter(); const [mode, setMode] = useState(data.mode); const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(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 (
{/* ── Betriebsart ─────────────────────────────────── */}

Betriebsart

Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.

{ setMode("SINGLE_USER"); void patchSettings({ mode: "SINGLE_USER" }); }} /> { setMode("MULTI_USER"); void patchSettings({ mode: "MULTI_USER" }); }} />
{mode === "MULTI_USER" ? ( ) : null} {notice ? (

{notice.text}

) : null}
{/* ── Instanz ─────────────────────────────────────── */}

Instanz

Diese Werte kommen aus der .env und lassen sich nur dort aendern.

APP_URL
{data.appUrl}
Passkeys
{data.passkeysAvailable ? ( <> verfuegbar ) : ( <> kein sicherer Kontext )}
{/* ── Sicherung ───────────────────────────────────── */}

Sicherung

Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten, Verbindungen, Projekte und Notizen.

Sicherung herunterladen

Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens sind nur mit demselben APP_SECRET wieder lesbar, Passwoerter liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine byteweise Sicherung der Datenbank gibt es scripts/backup.ps1{" "} beziehungsweise scripts/backup.sh mit pg_dump.

{/* ── Benutzer ────────────────────────────────────── */}

Benutzer ({data.users.length})

Administratoren verwalten Konten – Projekte anderer sehen sie nicht.

{data.users.map((user) => ( setEditing(user)} /> ))}
setCreateOpen(false)} /> setEditing(null)} />
); } const INTERVALS = [ { value: 15, label: "alle 15 Minuten" }, { value: 30, label: "alle 30 Minuten" }, { value: 60, label: "stuendlich" }, { value: 180, label: "alle 3 Stunden" }, { value: 360, label: "alle 6 Stunden" }, { value: 720, label: "alle 12 Stunden" }, { value: 1440, label: "taeglich" }, ]; function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) { const router = useRouter(); const [enabled, setEnabled] = useState(data.enabled); const [interval, setIntervalMinutes] = useState(data.intervalMinutes); const [activeOnly, setActiveOnly] = useState(data.activeOnly); const [batchSize, setBatchSize] = useState(data.batchSize); const [busy, setBusy] = useState(false); const [running, setRunning] = useState(false); const [notice, setNotice] = useState(null); async function patch(payload: Record) { 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."); setNotice({ ok: true, text: "Gespeichert." }); router.refresh(); } catch (caught) { setEnabled(data.enabled); setIntervalMinutes(data.intervalMinutes); setActiveOnly(data.activeOnly); setBatchSize(data.batchSize); setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } async function runNow() { setRunning(true); setNotice(null); try { const response = await fetch("/api/admin/sync/run", { method: "POST" }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? "Abgleich fehlgeschlagen."); const report = body.report as AutoSyncReport; setNotice({ ok: true, text: `${report.synced} aktualisiert, ${report.failed} fehlgeschlagen, ` + `${report.skipped} zurueckgestellt (${Math.round(report.durationMs / 1000)} s).`, }); router.refresh(); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setRunning(false); } } const nextRun = enabled && data.lastRunAt ? new Date(new Date(data.lastRunAt).getTime() + interval * 60_000) : null; return (

Automatischer Abgleich

Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was ohnehin verknuepft ist.

{enabled ? ( <>
setBatchSize(Number(event.target.value))} onMouseUp={() => patch({ autoSyncBatchSize: batchSize })} onTouchEnd={() => patch({ autoSyncBatchSize: batchSize })} onKeyUp={() => patch({ autoSyncBatchSize: batchSize })} className="range mt-2" style={{ "--fill": `${batchSize}%` } as React.CSSProperties} />

Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal.

) : null} {/* Zustand */}
Verknuepfte Projekte
{data.eligible}
Letzter Durchlauf
{data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"}
Naechster Durchlauf
{enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"}
{data.lastReport ? (

Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "} {data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt in {Math.round(data.lastReport.durationMs / 1000)} s.

) : null} {notice ? (

{notice.text}

) : null}
); } const STALE_OPTIONS = [14, 30, 60, 90, 180, 365]; function StaleSection({ data }: { data: AdminData["stale"] }) { const router = useRouter(); const [days, setDays] = useState(data.afterDays); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); async function save(value: number) { setBusy(true); setNotice(null); try { const response = await fetch("/api/admin/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staleAfterDays: value }), }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen."); setNotice({ ok: true, text: "Gespeichert." }); router.refresh(); } catch (caught) { setDays(data.afterDays); setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } return (

Stillstand

Ab wann ein Projekt als eingeschlafen gilt. Massstab ist der letzte Commit im verknuepften Repository – das Aenderungsdatum taugt nicht, weil jeder Abgleich es anfasst. Projekte ohne Repository bleiben aussen vor, ebenso fertige und archivierte.

Letzte Pruefung
{data.lastCheckAt ? formatRelative(data.lastCheckAt) : "noch keine"}
Empfaenger
{data.listeners}
{data.listeners === 0 ? (

Niemand hoert derzeit auf diesen Anlass – die Pruefung laeuft dann gar nicht erst. Einzurichten unter „Mein Konto → Benachrichtigungen“.

) : (

Die Pruefung laeuft hoechstens einmal am Tag. Je Projekt wird einmal gemeldet; erneut erst, wenn danach wieder ein Commit kam und es wieder still wurde.

)} {notice ? (

{notice.text}

) : null}
); } function ModeCard({ active, disabled, icon: Icon, title, hint, onClick, }: { active: boolean; disabled: boolean; icon: typeof User; title: string; hint: string; onClick: () => void; }) { return ( ); } function UserRow({ user, isSelf, onEdit, }: { user: AdminUser; isSelf: boolean; onEdit: () => void; }) { const router = useRouter(); const [busy, setBusy] = useState(false); const [error, setError] = useState(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 (
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}

{user.displayName ?? user.username} @{user.username} {user.role === "ADMIN" ? ( Admin ) : null} {isSelf ? ( du ) : null} {!user.isActive ? ( deaktiviert ) : null} {locked ? ( gesperrt ) : null}

{user._count.projects} Projekte {user._count.credentials} Passkeys {user.lastLoginAt ? `zuletzt ${formatRelative(user.lastLoginAt)}` : "noch nie angemeldet"} angelegt {formatDateTime(user.createdAt)}

{error ?

{error}

: null}
); } 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("USER"); const [isActive, setIsActive] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const [loadedFor, setLoadedFor] = useState(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 ( { 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" >
{mode === "create" ? (
setUsername(event.target.value)} autoCapitalize="none" spellCheck={false} required /> {fieldErrors.username ? (

{fieldErrors.username}

) : null}
) : (

@{user?.username}

)}
setDisplayName(event.target.value)} />
setEmail(event.target.value)} /> {fieldErrors.email ? (

{fieldErrors.email}

) : null}
setPassword(event.target.value)} autoComplete="new-password" minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined} required={mode === "create"} /> {fieldErrors.password ? (

{fieldErrors.password}

) : null}
Rolle
{(["USER", "ADMIN"] as Role[]).map((value) => ( ))}
{mode === "edit" ? ( ) : null} {error ? (

{error}

) : null}
); } const SPALTEN_OPTIONEN = [0, 5, 10, 15, 20, 30, 50]; /** * Wie viele Karten eine Spalte des Aufgabenbretts hoechstens zeigt. * * Begrenzt wird nur die Anzeige: der Rest steht hinter einem Knopf, und die * Reihenfolge wird weiterhin ueber die vollstaendige Spalte gespeichert. * Eine Aufgabe verschwindet hier also nirgends – sie ist nur einen Klick * weiter weg. */ function TaskLimitSection({ data }: { data: AdminData["tasks"] }) { const router = useRouter(); const [limit, setLimit] = useState(data.columnLimit); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); async function save(value: number) { setBusy(true); setNotice(null); try { const response = await fetch("/api/admin/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskColumnLimit: value }), }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen."); setNotice({ ok: true, text: "Gespeichert." }); router.refresh(); } catch (caught) { setLimit(data.columnLimit); setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } const greift = limit > 0 && data.laengsteSpalte > limit; return (

Aufgabenbrett

Wie viele Karten eine Spalte hoechstens zeigt. Was darueber hinausgeht, kommt hinter einen Knopf – verborgen wird nichts, und die Reihenfolge bleibt vollstaendig gespeichert. Gilt fuer alle Projekte.

Laengste Spalte
{data.laengsteSpalte}
Wirkung
{limit === 0 ? "aus" : greift ? "greift" : "noch nicht"}

{limit === 0 ? "Jede Spalte zeigt alles. Bei langen Listen wird das Brett unuebersichtlich." : greift ? "Mindestens eine Spalte ist laenger als die Grenze – dort erscheint der Knopf." : "Derzeit reicht keine Spalte an die Grenze heran; sichtbar wird die Einstellung erst danach."}

{notice ? (

{notice.text}

) : null}
); }