Files
proxdeploy/src/components/seitenleiste.tsx
T
florian.wildeandClaude Opus 5.5 99357d59bd feat(benutzer): Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht
Admins verwalten Benutzer (anlegen, Rolle, sperren, Passwort setzen); jeder ändert sein Passwort; das Audit-Log ist filterbar einsehbar. Sperre und Passwortwechsel beenden ältere Sitzungen über Benutzer.sitzungenAb. Der letzte Admin und das eigene Konto sind geschützt.

Closes #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:49:47 +02:00

168 lines
5.2 KiB
TypeScript

"use client";
import { clsx } from "clsx";
import {
BookOpen,
FileStack,
History,
LayoutDashboard,
LogOut,
Menu,
Rocket,
ScrollText,
Server,
Settings,
Layers,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { istAktiv, type NavigationsEintrag } from "@/lib/navigation";
import type { Theme } from "@/lib/theme";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { ThemeUmschalter } from "@/components/theme-umschalter";
const SYMBOLE: Record<
NavigationsEintrag["symbol"],
React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>
> = {
uebersicht: LayoutDashboard,
ausrollen: Rocket,
verlauf: History,
templates: Layers,
vorlagen: FileStack,
katalog: BookOpen,
server: Server,
protokoll: ScrollText,
einstellungen: Settings,
};
type Eigenschaften = {
eintraege: readonly NavigationsEintrag[];
benutzer: { anzeigename: string; rolle: string };
version: string;
theme: Theme;
serverUmschalter: React.ReactNode;
};
export function Seitenleiste({ eintraege, benutzer, version, theme, serverUmschalter }: Eigenschaften) {
const pfad = usePathname();
const router = useRouter();
const [offen, setOffen] = useState(false);
async function abmelden() {
await fetch("/api/v1/auth/abmelden", { method: "POST" });
router.replace("/anmelden");
router.refresh();
}
const inhalt = (
<div className="flex h-full flex-col gap-6 p-4">
<Link href="/" className="flex items-center gap-2.5 px-2 pt-1" onClick={() => setOffen(false)}>
<Symbol className="h-8 w-8 shrink-0" />
<Wortmarke className="text-lg" />
</Link>
{serverUmschalter}
<nav aria-label="Hauptnavigation" className="flex-1">
<ul className="flex flex-col gap-0.5">
{eintraege.map((eintrag) => {
const Icon = SYMBOLE[eintrag.symbol];
const aktiv = istAktiv(eintrag.pfad, pfad);
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={clsx(
"flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition sm:h-10",
aktiv ? "bg-panel-hoch text-text" : "text-muted hover:text-text hover:bg-panel-hoch/60",
)}
>
<span className={aktiv ? "text-akzent" : undefined}>
<Icon size={18} aria-hidden={true} />
</span>
{eintrag.titel}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-linie flex flex-col gap-3 border-t pt-4">
<div className="flex items-center justify-between gap-2 px-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{benutzer.anzeigename}</p>
<p className="text-muted text-xs">{benutzer.rolle === "ADMIN" ? "Administrator" : "Operator"}</p>
</div>
<div className="flex items-center">
<ThemeUmschalter anfang={theme} />
<button
type="button"
onClick={abmelden}
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
aria-label="Abmelden"
title="Abmelden"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</div>
<p className="text-muted px-2 font-mono text-xs">{version}</p>
</div>
</div>
);
return (
<>
{/* Handy: Kopfleiste mit Menüknopf, Navigation als Schublade. */}
<div className="border-linie bg-panel sticky top-0 z-30 flex h-14 items-center justify-between border-b px-4 lg:hidden">
<Link href="/" className="flex items-center gap-2">
<Symbol className="h-7 w-7" />
<Wortmarke />
</Link>
<button
type="button"
onClick={() => setOffen(true)}
className="inline-flex h-11 w-11 items-center justify-center rounded-lg"
aria-label="Navigation öffnen"
aria-expanded={offen}
aria-controls="seitenleiste"
>
<Menu size={22} aria-hidden="true" />
</button>
</div>
{offen ? (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
aria-hidden="true"
onClick={() => setOffen(false)}
/>
) : null}
<aside
id="seitenleiste"
className={clsx(
"border-linie bg-panel fixed inset-y-0 left-0 z-50 w-72 border-r transition-transform lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
type="button"
onClick={() => setOffen(false)}
className="absolute top-3 right-3 inline-flex h-11 w-11 items-center justify-center rounded-lg lg:hidden"
aria-label="Navigation schließen"
>
<X size={20} aria-hidden="true" />
</button>
{inhalt}
</aside>
</>
);
}