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>
168 lines
5.2 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|