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>
This commit is contained in:
2026-10-12 16:49:47 +02:00
co-authored by Claude Opus 5.5
parent 7f6cf00a0e
commit 99357d59bd
21 changed files with 1292 additions and 22 deletions
@@ -0,0 +1,258 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Benutzer = {
id: string;
benutzername: string;
anzeigename: string;
email: string | null;
rolle: "ADMIN" | "OPERATOR";
aktiv: boolean;
passwort: boolean;
oidc: boolean;
tokens: number;
};
type Meldung = { art: "erledigt" | "fehler"; text: string; felder?: Record<string, string> } | null;
const leer = { benutzername: "", anzeigename: "", email: "", rolle: "OPERATOR" as const, passwort: "" };
/**
* Benutzer anlegen, Rolle ändern, sperren, Passwort neu setzen. Admin: Server, Katalog, Benutzer;
* Operator: ansehen und ausrollen. Das eigene Konto lässt sich nicht sperren, der letzte Admin
* nicht herabstufen – das prüft der Server.
*/
export function BenutzerVerwaltung({ anfang, ich }: { anfang: Benutzer[]; ich: string }) {
const router = useRouter();
const [neu, setNeu] = useState<{
benutzername: string;
anzeigename: string;
email: string;
rolle: "ADMIN" | "OPERATOR";
passwort: string;
}>(leer);
const [meldung, setMeldung] = useState<Meldung>(null);
const [passwortFuer, setPasswortFuer] = useState<{ id: string; passwort: string } | null>(null);
const fehlerZeigen = (fehler: unknown) =>
setMeldung({
art: "fehler",
text:
fehler instanceof ApiFehler ? [fehler.message, fehler.hinweis].filter(Boolean).join(" ") : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
async function aendern(b: Benutzer, aenderung: Partial<Pick<Benutzer, "rolle" | "aktiv">>, text: string) {
try {
await apiAufruf("PATCH", `/api/v1/benutzer/${b.id}`, aenderung);
setMeldung({ art: "erledigt", text });
router.refresh();
} catch (fehler) {
fehlerZeigen(fehler);
}
}
async function anlegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/benutzer", neu);
setMeldung({ art: "erledigt", text: `${neu.benutzername} angelegt.` });
setNeu(leer);
router.refresh();
} catch (fehler) {
fehlerZeigen(fehler);
}
}
async function passwortSetzen(ereignis: React.FormEvent, b: Benutzer) {
ereignis.preventDefault();
if (!passwortFuer) return;
try {
await apiAufruf("POST", `/api/v1/benutzer/${b.id}/passwort`, { passwort: passwortFuer.passwort });
setPasswortFuer(null);
setMeldung({
art: "erledigt",
text: `Passwort von ${b.benutzername} gesetzt – alle Sitzungen beendet.`,
});
router.refresh();
} catch (fehler) {
fehlerZeigen(fehler);
}
}
return (
<Karte titel="Benutzer">
<div className="flex flex-col gap-5">
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((b) => (
<li key={b.id} className="flex flex-col gap-3 py-3 first:pt-0">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{b.anzeigename} <span className="text-muted font-mono text-sm">· {b.benutzername}</span>
{b.benutzername === ich ? <span className="text-muted text-sm"> · du</span> : null}
</p>
<p className="text-muted text-xs">
{[
b.email,
b.passwort ? "Passwort" : null,
b.oidc ? "OIDC" : null,
b.tokens > 0 ? `${b.tokens} API-Token${b.tokens === 1 ? "" : "s"}` : null,
]
.filter(Boolean)
.join(" · ") || "–"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!b.aktiv ? <Status art="fehler">gesperrt</Status> : null}
<label className="sr-only" htmlFor={`rolle-${b.id}`}>
Rolle von {b.benutzername}
</label>
<select
id={`rolle-${b.id}`}
className={`${EINGABE_KLASSE} w-36`}
value={b.rolle}
onChange={(e) =>
void aendern(
b,
{ rolle: e.target.value as Benutzer["rolle"] },
`${b.benutzername} ist jetzt ${e.target.value === "ADMIN" ? "Admin" : "Operator"}.`,
)
}
>
<option value="OPERATOR">Operator</option>
<option value="ADMIN">Admin</option>
</select>
<Knopf
variante="sekundaer"
onClick={() =>
setPasswortFuer(passwortFuer?.id === b.id ? null : { id: b.id, passwort: "" })
}
aria-label={`Passwort von ${b.benutzername} setzen`}
>
Passwort
</Knopf>
{b.benutzername !== ich ? (
<Knopf
variante={b.aktiv ? "gefahr" : "sekundaer"}
onClick={() =>
void aendern(
b,
{ aktiv: !b.aktiv },
b.aktiv
? `${b.benutzername} gesperrt – Sitzungen beendet.`
: `${b.benutzername} entsperrt.`,
)
}
aria-label={`${b.benutzername} ${b.aktiv ? "sperren" : "entsperren"}`}
>
{b.aktiv ? "Sperren" : "Entsperren"}
</Knopf>
) : null}
</div>
</div>
{passwortFuer?.id === b.id ? (
<form
onSubmit={(e) => void passwortSetzen(e, b)}
className="flex flex-col gap-2 sm:flex-row sm:items-end"
>
<Feld
id={`pw-${b.id}`}
label={`Neues Passwort für ${b.benutzername}`}
hilfe="Mindestens 12 Zeichen"
>
<input
id={`pw-${b.id}`}
type="password"
autoComplete="new-password"
className={`${EINGABE_KLASSE} sm:w-72`}
value={passwortFuer.passwort}
onChange={(e) => setPasswortFuer({ id: b.id, passwort: e.target.value })}
/>
</Feld>
<Knopf type="submit" disabled={passwortFuer.passwort.length < 12}>
Setzen
</Knopf>
</form>
) : null}
</li>
))}
</ul>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
<form onSubmit={(e) => void anlegen(e)} className="border-linie flex flex-col gap-4 border-t pt-5">
<p className="font-semibold">Neuer Benutzer</p>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="b-name" label="Benutzername" fehler={meldung?.felder?.["benutzername"]}>
<input
id="b-name"
autoComplete="off"
className={EINGABE_KLASSE}
value={neu.benutzername}
onChange={(e) => setNeu({ ...neu, benutzername: e.target.value })}
/>
</Feld>
<Feld id="b-anzeige" label="Anzeigename" fehler={meldung?.felder?.["anzeigename"]}>
<input
id="b-anzeige"
className={EINGABE_KLASSE}
value={neu.anzeigename}
onChange={(e) => setNeu({ ...neu, anzeigename: e.target.value })}
/>
</Feld>
<Feld id="b-email" label="E-Mail (optional)" fehler={meldung?.felder?.["email"]}>
<input
id="b-email"
type="email"
className={EINGABE_KLASSE}
value={neu.email}
onChange={(e) => setNeu({ ...neu, email: e.target.value })}
/>
</Feld>
<Feld id="b-rolle" label="Rolle">
<select
id="b-rolle"
className={EINGABE_KLASSE}
value={neu.rolle}
onChange={(e) => setNeu({ ...neu, rolle: e.target.value as Benutzer["rolle"] })}
>
<option value="OPERATOR">Operator – ansehen und ausrollen</option>
<option value="ADMIN">Admin – auch Server, Katalog, Benutzer</option>
</select>
</Feld>
<Feld
id="b-passwort"
label="Erstes Passwort"
hilfe="Mindestens 12 Zeichen; der Benutzer kann es in den Einstellungen ändern."
fehler={meldung?.felder?.["passwort"]}
>
<input
id="b-passwort"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={neu.passwort}
onChange={(e) => setNeu({ ...neu, passwort: e.target.value })}
/>
</Feld>
</div>
<div>
<Knopf type="submit" disabled={!neu.benutzername || !neu.anzeigename || neu.passwort.length < 12}>
Benutzer anlegen
</Knopf>
</div>
</form>
</div>
</Karte>
);
}
+12 -7
View File
@@ -1,31 +1,36 @@
import type { Metadata } from "next";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { benutzerListe } from "@/server/services/benutzer";
import { schluesselFuer } from "@/server/services/schluessel";
import { tokensVon } from "@/server/services/tokens";
import { BenutzerVerwaltung } from "./benutzer-verwaltung";
import { PasswortAendern } from "./passwort-aendern";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
import { TokenVerwaltung } from "./token-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel und CLI-Tokens. Benutzer und Standardwerte folgen mit #015. */
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
const admin = darf(benutzer.rolle, "benutzerVerwalten");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
<p className="text-muted text-sm">
Angemeldet als {benutzer.benutzername} · {benutzer.rolle === "ADMIN" ? "Admin" : "Operator"}
</p>
</header>
<SchluesselVerwaltung
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
<SchluesselVerwaltung anfang={await schluesselFuer(benutzer)} teamErlaubt={admin} />
<TokenVerwaltung
anfang={await tokensVon(benutzer.id)}
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
/>
{benutzer.passwortHash ? <PasswortAendern /> : null}
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
/** Eigenes Passwort ändern. Andere Sitzungen (andere Geräte) enden, diese bleibt angemeldet. */
export function PasswortAendern() {
const [werte, setWerte] = useState({ alt: "", neu: "", wiederholt: "" });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
const abweichend = werte.wiederholt !== "" && werte.neu !== werte.wiederholt;
async function speichern(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/ich/passwort", { alt: werte.alt, neu: werte.neu });
setWerte({ alt: "", neu: "", wiederholt: "" });
setMeldung({ art: "erledigt", text: "Passwort geändert. Andere Sitzungen sind beendet." });
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
return (
<Karte titel="Passwort">
<form onSubmit={(e) => void speichern(e)} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="pw-alt" label="Bisheriges Passwort" fehler={meldung?.felder?.["alt"]}>
<input
id="pw-alt"
type="password"
autoComplete="current-password"
className={EINGABE_KLASSE}
value={werte.alt}
onChange={(e) => setWerte({ ...werte, alt: e.target.value })}
/>
</Feld>
<Feld
id="pw-neu"
label="Neues Passwort"
hilfe="Mindestens 12 Zeichen"
fehler={meldung?.felder?.["neu"]}
>
<input
id="pw-neu"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={werte.neu}
onChange={(e) => setWerte({ ...werte, neu: e.target.value })}
/>
</Feld>
<Feld
id="pw-wieder"
label="Neues Passwort wiederholen"
fehler={abweichend ? "Stimmt nicht überein." : undefined}
>
<input
id="pw-wieder"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={werte.wiederholt}
onChange={(e) => setWerte({ ...werte, wiederholt: e.target.value })}
/>
</Feld>
</div>
<div className="flex flex-wrap items-center gap-3">
<Knopf
type="submit"
disabled={!werte.alt || werte.neu.length < 12 || werte.neu !== werte.wiederholt}
>
Passwort ändern
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</Karte>
);
}
+172
View File
@@ -0,0 +1,172 @@
import type { Metadata } from "next";
import Link from "next/link";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { prisma } from "@/lib/db";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
export const metadata: Metadata = { title: "Audit-Log" };
type Suche = Record<string, string | string[] | undefined>;
const QUELLEN = { WEB: "Web", CLI: "CLI", API: "API", SYSTEM: "System" } as const;
/**
* Audit-Log: jede Aktion mit Benutzer, Quelle und Adresse. Nur lesbar – die Datenbank verweigert
* Ändern und Löschen per Trigger. Der Filter ist ein schlichtes GET-Formular, der Stand steht in der URL.
*/
export default async function Protokoll({ searchParams }: { searchParams: Promise<Suche> }) {
await seiteSchuetzen("protokollLesen", "/protokoll");
const filter = protokollFilterAus(await searchParams);
const [{ eintraege, gesamt, seite, seiten }, benutzer] = await Promise.all([
protokollSuchen(filter),
prisma.benutzer.findMany({ select: { benutzername: true }, orderBy: { benutzername: "asc" } }),
]);
const seitenLink = (ziel: number) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
if (ziel > 1) p.set("seite", String(ziel));
return `/protokoll${p.size > 0 ? `?${p.toString()}` : ""}`;
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Audit-Log</h1>
<p className="text-muted text-sm">{gesamt} Einträge</p>
</header>
<form
method="get"
action="/protokoll"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:flex lg:items-end"
>
<Feld id="p-suche" label="Suche" hilfe="Aktion oder Ziel">
<input
id="p-suche"
name="suche"
defaultValue={filter.suche ?? ""}
className={`${EINGABE_KLASSE} lg:w-48`}
/>
</Feld>
<Feld id="p-benutzer" label="Benutzer">
<select
id="p-benutzer"
name="benutzer"
defaultValue={filter.benutzer ?? ""}
className={`${EINGABE_KLASSE} lg:w-40`}
>
<option value="">alle</option>
{benutzer.map((b) => (
<option key={b.benutzername}>{b.benutzername}</option>
))}
</select>
</Feld>
<Feld id="p-quelle" label="Quelle">
<select
id="p-quelle"
name="quelle"
defaultValue={filter.quelle ?? ""}
className={`${EINGABE_KLASSE} lg:w-32`}
>
<option value="">alle</option>
{Object.entries(QUELLEN).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</select>
</Feld>
<Feld id="p-von" label="Von">
<input
id="p-von"
name="von"
type="date"
defaultValue={filter.von ?? ""}
className={`${EINGABE_KLASSE} lg:w-40`}
/>
</Feld>
<Feld id="p-bis" label="Bis">
<input
id="p-bis"
name="bis"
type="date"
defaultValue={filter.bis ?? ""}
className={`${EINGABE_KLASSE} lg:w-40`}
/>
</Feld>
<div className="flex items-end gap-3">
<button
type="submit"
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold hover:opacity-90 sm:h-10"
>
Filtern
</button>
<Link href="/protokoll" className="text-muted hover:text-text text-sm">
Zurücksetzen
</Link>
</div>
</form>
<Karte>
{eintraege.length === 0 ? (
<Status art="offen">Keine Einträge.</Status>
) : (
<ol className="divide-linie flex flex-col divide-y" aria-label="Einträge">
{eintraege.map((e) => (
<li key={e.id} className="flex flex-col gap-1 py-3 first:pt-0 sm:flex-row sm:gap-4">
<time className="text-muted shrink-0 font-mono text-xs sm:w-36 sm:pt-0.5" dateTime={e.zeit}>
{new Date(e.zeit).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" })}
</time>
<div className="min-w-0 flex-1">
<p>
<span className="font-medium">{e.aktion}</span>
{e.ziel ? (
<span className="text-muted font-mono text-sm break-all"> · {e.ziel}</span>
) : null}
</p>
<p className="text-muted text-xs">
{e.benutzer ?? "–"} · {QUELLEN[e.quelle]}
{e.ip ? ` · ${e.ip}` : ""}
</p>
{e.details ? (
<details className="mt-1">
<summary className="text-muted cursor-pointer text-xs">Details</summary>
<pre className="bg-grund border-linie mt-1 overflow-x-auto rounded-lg border p-2 font-mono text-xs">
{JSON.stringify(e.details, null, 2)}
</pre>
</details>
) : null}
</div>
</li>
))}
</ol>
)}
</Karte>
{seiten > 1 ? (
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
{seite > 1 ? (
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
← Neuere
</Link>
) : (
<span />
)}
<span className="text-muted">
Seite {seite} von {seiten}
</span>
{seite < seiten ? (
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
Ältere →
</Link>
) : (
<span />
)}
</nav>
) : null}
</div>
);
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { passwortZuruecksetzen } from "@/server/services/benutzer";
type Kontext = { params: Promise<{ id: string }> };
/** Admin setzt ein neues Passwort `{ passwort }`; alle Sitzungen des Benutzers enden. */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
await passwortZuruecksetzen(
(await params).id,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
);
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { benutzerAendern } from "@/server/services/benutzer";
type Kontext = { params: Promise<{ id: string }> };
/** `{ anzeigename?, email?, rolle?, aktiv? }` – Sperren beendet alle Sitzungen. */
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
return NextResponse.json(
await benutzerAendern(
(await params).id,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
),
);
});
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { benutzerAnlegen, benutzerListe } from "@/server/services/benutzer";
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "benutzerVerwalten");
return NextResponse.json(await benutzerListe());
});
/** `{ benutzername, anzeigename, email?, rolle, passwort }` */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
return NextResponse.json(
await benutzerAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { aufruferAdresse, istHttps } from "@/lib/adressen";
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { eigenesPasswortAendern } from "@/server/services/benutzer";
/**
* Eigenes Passwort ändern `{ alt, neu }`. Alle anderen Sitzungen enden; die aufrufende Web-Sitzung
* bekommt ein frisches Cookie, damit man angemeldet bleibt.
*/
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await eigenesPasswortAendern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
const antwort = new NextResponse(null, { status: 204 });
if (aufrufer.quelle === "WEB") {
antwort.cookies.set(
SITZUNG_COOKIE,
await sitzungErzeugen({ benutzerId: aufrufer.benutzer.id, herkunft: "passwort" }),
{
httpOnly: true,
sameSite: "lax",
secure: istHttps(anfrage.headers, anfrage.url),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
},
);
}
return antwort;
});
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
/** Audit-Log, neueste zuerst: `?benutzer=&quelle=&suche=&von=YYYY-MM-DD&bis=YYYY-MM-DD&seite=`. Nur Admins. */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "protokollLesen");
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
return NextResponse.json(await protokollSuchen(filter));
});
+2
View File
@@ -9,6 +9,7 @@ import {
LogOut,
Menu,
Rocket,
ScrollText,
Server,
Settings,
Layers,
@@ -33,6 +34,7 @@ const SYMBOLE: Record<
vorlagen: FileStack,
katalog: BookOpen,
server: Server,
protokoll: ScrollText,
einstellungen: Settings,
};
+4 -8
View File
@@ -14,8 +14,9 @@ export type NavigationsEintrag = {
| "vorlagen"
| "katalog"
| "server"
| "protokoll"
| "einstellungen";
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge" | "protokollLesen";
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
ab?: string;
};
@@ -28,13 +29,8 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{
pfad: "/einstellungen",
titel: "Einstellungen",
symbol: "einstellungen",
recht: "eigeneZugaenge",
ab: "#015",
},
{ pfad: "/protokoll", titel: "Audit-Log", symbol: "protokoll", recht: "protokollLesen" },
{ pfad: "/einstellungen", titel: "Einstellungen", symbol: "einstellungen", recht: "eigeneZugaenge" },
];
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
+4 -1
View File
@@ -19,7 +19,10 @@ async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | n
const sitzung = await sitzungLesen(jwt);
if (!sitzung) return null;
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
return benutzer?.aktiv ? benutzer : null;
if (!benutzer?.aktiv) return null;
// Nach Passwortwechsel oder Sperre gelten ältere Sitzungen nicht mehr.
if (benutzer.sitzungenAb && sitzung.ausgestelltAm * 1000 < benutzer.sitzungenAb.getTime()) return null;
return benutzer;
}
/** Für Server Components. */
+265
View File
@@ -0,0 +1,265 @@
import type { Benutzer, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/**
* Benutzerverwaltung (nur Admins) und das eigene Passwort. Zwei Schutzregeln: der letzte aktive
* Admin bleibt Admin und aktiv, und niemand sperrt sich selbst aus. Passwortwechsel und Sperre
* beenden alle bestehenden Sitzungen (`sitzungenAb`); API-Tokens prüfen den Aktiv-Status je Anfrage.
*/
export type BenutzerSicht = {
id: string;
benutzername: string;
anzeigename: string;
email: string | null;
rolle: "ADMIN" | "OPERATOR";
aktiv: boolean;
/** Lokales Passwort gesetzt (sonst nur OIDC). */
passwort: boolean;
oidc: boolean;
erstelltAm: string;
tokens: number;
};
const Passwort = z
.string()
.min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`)
.max(1024, "Höchstens 1024 Zeichen.");
export const BenutzerAnlegen = z.object({
benutzername: z
.string()
.trim()
.toLowerCase()
.regex(
/^[a-z][a-z0-9._-]{1,31}$/,
"2–32 Zeichen: Kleinbuchstaben, Ziffern, Punkt, _ und -; vorne ein Buchstabe.",
),
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
email: z
.email("Keine gültige E-Mail-Adresse.")
.trim()
.optional()
.or(z.literal("").transform(() => undefined)),
rolle: z.enum(["ADMIN", "OPERATOR"]).default("OPERATOR"),
passwort: Passwort,
});
export const BenutzerAendern = z.object({
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80).optional(),
email: z
.email("Keine gültige E-Mail-Adresse.")
.trim()
.nullable()
.optional()
.or(z.literal("").transform(() => null)),
rolle: z.enum(["ADMIN", "OPERATOR"]).optional(),
aktiv: z.boolean().optional(),
});
const MIT_TOKENS = { _count: { select: { tokens: true } } } as const;
function sicht(b: Benutzer & { _count: { tokens: number } }): BenutzerSicht {
return {
id: b.id,
benutzername: b.benutzername,
anzeigename: b.anzeigename,
email: b.email,
rolle: b.rolle,
aktiv: b.aktiv,
passwort: Boolean(b.passwortHash),
oidc: Boolean(b.oidcSubjekt),
erstelltAm: b.erstelltAm.toISOString(),
tokens: b._count.tokens,
};
}
/** Auf volle Sekunden: JWT-`iat` hat Sekunden, eine im selben Augenblick neu ausgestellte Sitzung gilt. */
const jetztSekunde = () => new Date(Math.floor(Date.now() / 1000) * 1000);
export async function benutzerListe(): Promise<BenutzerSicht[]> {
const liste = await prisma.benutzer.findMany({ include: MIT_TOKENS, orderBy: { benutzername: "asc" } });
return liste.map(sicht);
}
export async function benutzerAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<BenutzerSicht> {
const e = BenutzerAnlegen.parse(roh);
try {
const neu = await prisma.$transaction(async (tx) => {
const b = await tx.benutzer.create({
data: {
benutzername: e.benutzername,
anzeigename: e.anzeigename,
email: e.email ?? null,
rolle: e.rolle,
passwortHash: await passwortHashen(e.passwort),
},
include: MIT_TOKENS,
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Benutzer angelegt",
ziel: `benutzer:${b.benutzername}`,
details: { rolle: b.rolle },
ip,
},
tx,
);
return b;
});
return sicht(neu);
} catch (fehler) {
if ((fehler as { code?: string }).code === "P2002") {
throw new AnwendungsFehler(409, "vergeben", `„${e.benutzername}“ gibt es schon.`, undefined, {
benutzername: "Benutzername schon vergeben.",
});
}
throw fehler;
}
}
async function letzterAdmin(tx: Prisma.TransactionClient, ohne: string): Promise<boolean> {
return (await tx.benutzer.count({ where: { rolle: "ADMIN", aktiv: true, id: { not: ohne } } })) === 0;
}
export async function benutzerAendern(
id: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<BenutzerSicht> {
const e = BenutzerAendern.parse(roh);
const ergebnis = await prisma.$transaction(async (tx) => {
// Sperre auf die Admin-Zeilen: zwei gleichzeitige Herabstufungen dürfen nicht beide durchgehen.
await tx.$queryRaw`SELECT id FROM "Benutzer" WHERE rolle = 'ADMIN' FOR UPDATE`;
const alt = await tx.benutzer.findUnique({ where: { id } });
if (!alt) throw nichtGefunden("Den Benutzer");
const selbst = alt.id === aufrufer.benutzer.id;
if (selbst && e.aktiv === false) {
throw new AnwendungsFehler(409, "selbst", "Das eigene Konto lässt sich nicht sperren.");
}
const verliertAdmin = alt.rolle === "ADMIN" && alt.aktiv && (e.rolle === "OPERATOR" || e.aktiv === false);
if (verliertAdmin && (await letzterAdmin(tx, alt.id))) {
throw new AnwendungsFehler(
409,
"letzter_admin",
"Das ist der letzte aktive Admin.",
"Erst einen anderen Benutzer zum Admin machen.",
);
}
const gesperrt = e.aktiv === false && alt.aktiv;
const neu = await tx.benutzer.update({
where: { id },
data: {
...(e.anzeigename !== undefined ? { anzeigename: e.anzeigename } : {}),
...(e.email !== undefined ? { email: e.email } : {}),
...(e.rolle !== undefined ? { rolle: e.rolle } : {}),
...(e.aktiv !== undefined ? { aktiv: e.aktiv } : {}),
...(gesperrt ? { sitzungenAb: jetztSekunde() } : {}),
},
include: MIT_TOKENS,
});
const aenderungen: Record<string, unknown> = {};
if (e.rolle !== undefined && e.rolle !== alt.rolle) aenderungen["rolle"] = [alt.rolle, e.rolle];
if (e.aktiv !== undefined && e.aktiv !== alt.aktiv) aenderungen["aktiv"] = [alt.aktiv, e.aktiv];
if (e.anzeigename !== undefined && e.anzeigename !== alt.anzeigename) aenderungen["anzeigename"] = true;
if (e.email !== undefined && e.email !== alt.email) aenderungen["email"] = true;
if (Object.keys(aenderungen).length > 0) {
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: gesperrt
? "Benutzer gesperrt"
: e.aktiv === true && !alt.aktiv
? "Benutzer entsperrt"
: "Benutzer geändert",
ziel: `benutzer:${alt.benutzername}`,
details: aenderungen as Prisma.InputJsonValue,
ip,
},
tx,
);
}
return neu;
});
return sicht(ergebnis);
}
/** Admin setzt ein neues Passwort (z. B. vergessen); alle Sitzungen des Benutzers enden. */
export async function passwortZuruecksetzen(
id: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const { passwort } = z.object({ passwort: Passwort }).parse(roh);
const ziel = await prisma.benutzer.findUnique({ where: { id } });
if (!ziel) throw nichtGefunden("Den Benutzer");
const hashWert = await passwortHashen(passwort);
await prisma.$transaction(async (tx) => {
await tx.benutzer.update({
where: { id },
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Passwort zurückgesetzt",
ziel: `benutzer:${ziel.benutzername}`,
ip,
},
tx,
);
});
}
/** Eigenes Passwort ändern – mit dem alten. Andere Sitzungen enden; die aufrufende stellt der Handler neu aus. */
export async function eigenesPasswortAendern(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const e = z
.object({ alt: z.string().min(1, "Bisheriges Passwort fehlt.").max(1024), neu: Passwort })
.parse(roh);
const ich = await prisma.benutzer.findUniqueOrThrow({ where: { id: aufrufer.benutzer.id } });
if (!ich.passwortHash || !(await passwortPruefen(ich.passwortHash, e.alt))) {
throw new AnwendungsFehler(400, "ungueltig", "Das bisherige Passwort stimmt nicht.", undefined, {
alt: "Stimmt nicht.",
});
}
if (e.alt === e.neu) {
throw new AnwendungsFehler(400, "ungueltig", "Das neue Passwort ist das alte.", undefined, {
neu: "Bitte ein anderes Passwort wählen.",
});
}
const hashWert = await passwortHashen(e.neu);
await prisma.$transaction(async (tx) => {
await tx.benutzer.update({
where: { id: ich.id },
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
});
await protokollieren(
{
benutzerId: ich.id,
quelle: aufrufer.quelle,
aktion: "Eigenes Passwort geändert",
ziel: `benutzer:${ich.benutzername}`,
ip,
},
tx,
);
});
}
+90
View File
@@ -1,4 +1,5 @@
import type { Quelle } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
@@ -28,3 +29,92 @@ export async function protokollieren(eintrag: ProtokollEintrag, client: Client =
},
});
}
/** Filter der Audit-Log-Ansicht (`/protokoll?…`, `GET /api/v1/protokoll?…`). */
export const ProtokollFilter = z.object({
benutzer: z.string().trim().optional(),
quelle: z.enum(["WEB", "CLI", "API", "SYSTEM"]).optional(),
/** Teilwort in Aktion oder Ziel, z. B. „Server“ oder „d-7f3k2“. */
suche: z.string().trim().max(100).optional(),
von: z.iso.date().optional(),
bis: z.iso.date().optional(),
seite: z.coerce.number().int().min(1).max(100_000).default(1),
});
export type ProtokollFilter = z.infer<typeof ProtokollFilter>;
export const PROTOKOLL_PRO_SEITE = 50;
export type ProtokollZeile = {
id: string;
zeit: string;
benutzer: string | null;
quelle: Quelle;
aktion: string;
ziel: string | null;
details: unknown;
ip: string | null;
};
/** Wie beim Verlauf: Gültiges übernehmen, Unsinn still fallen lassen. */
export function protokollFilterAus(
parameter: Record<string, string | string[] | undefined>,
): ProtokollFilter {
const geprueft: Record<string, unknown> = {};
for (const schluessel of Object.keys(ProtokollFilter.shape) as Array<keyof ProtokollFilter>) {
const roh = parameter[schluessel];
const wert = Array.isArray(roh) ? roh[0] : roh;
if (!wert) continue;
const feld = ProtokollFilter.shape[schluessel].safeParse(wert);
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
}
return ProtokollFilter.parse(geprueft);
}
export async function protokollSuchen(filter: ProtokollFilter): Promise<{
eintraege: ProtokollZeile[];
gesamt: number;
seite: number;
seiten: number;
}> {
const zeit: Prisma.DateTimeFilter = {};
if (filter.von) zeit.gte = new Date(`${filter.von}T00:00:00`);
if (filter.bis) zeit.lt = new Date(new Date(`${filter.bis}T00:00:00`).getTime() + 86_400_000);
const where: Prisma.ProtokollWhereInput = {
...(filter.benutzer ? { benutzer: { benutzername: filter.benutzer } } : {}),
...(filter.quelle ? { quelle: filter.quelle } : {}),
...(filter.suche
? {
OR: [
{ aktion: { contains: filter.suche, mode: "insensitive" as const } },
{ ziel: { contains: filter.suche, mode: "insensitive" as const } },
],
}
: {}),
...(filter.von || filter.bis ? { zeit } : {}),
};
const [gesamt, zeilen] = await prisma.$transaction([
prisma.protokoll.count({ where }),
prisma.protokoll.findMany({
where,
include: { benutzer: { select: { benutzername: true } } },
orderBy: { id: "desc" },
skip: (filter.seite - 1) * PROTOKOLL_PRO_SEITE,
take: PROTOKOLL_PRO_SEITE,
}),
]);
return {
eintraege: zeilen.map((z) => ({
id: z.id.toString(),
zeit: z.zeit.toISOString(),
benutzer: z.benutzer?.benutzername ?? null,
quelle: z.quelle,
aktion: z.aktion,
ziel: z.ziel,
details: z.details,
ip: z.ip,
})),
gesamt,
seite: filter.seite,
seiten: Math.max(1, Math.ceil(gesamt / PROTOKOLL_PRO_SEITE)),
};
}