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:
@@ -8,6 +8,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### Hinzugefügt
|
||||
- Benutzerverwaltung für Admins: anlegen, Rolle ändern, sperren und entsperren, Passwort neu setzen; der letzte Admin und das eigene Konto sind geschützt (#015)
|
||||
- Eigenes Passwort in den Einstellungen ändern (#015)
|
||||
- Audit-Log-Ansicht für Admins, filterbar nach Benutzer, Quelle, Text und Zeitraum; auch `GET /api/v1/protokoll` (#015)
|
||||
- Verlauf filterbar nach Server, Typ, Distribution und Status, mit Seitenblättern; die Filter stehen in der URL. Auch per API und `proxdeploy history` (#014)
|
||||
- „Wiederholen“: der Assistent übernimmt alle Werte eines früheren Auftrags, Name, VMID und feste IP gibt man neu an (#014)
|
||||
- „Aufräumen“ nach Fehlschlag oder Abbruch: löscht nach Bestätigung den halb angelegten Gast – nur, wenn seine Beschreibung auf den Auftrag verweist; Templates nie. Web, API und `proxdeploy cleanup` (#014)
|
||||
@@ -15,11 +18,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
### Geändert
|
||||
|
||||
### Behoben
|
||||
- „Einstellungen“ fehlte in der Navigation (#015)
|
||||
- Erzeugte SSH-Schlüssel (je Server und Bereitstellungsschlüssel) waren in etwa 4 von 1000 Fällen für ssh2 selbst nicht lesbar; jetzt wird geprüft und neu erzeugt (#014)
|
||||
|
||||
### Entfernt
|
||||
|
||||
### Sicherheit
|
||||
- Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
|
||||
|
||||
## [0.4.0] – 2026-10-12
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** feature, sicherheit
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
@@ -8,16 +8,23 @@
|
||||
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
||||
- [x] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) – seit #001, jetzt mit den neuen Routen
|
||||
- [x] Benutzerverwaltung (anlegen, Rolle, sperren, Passwort setzen), eigenes Passwort ändern; CLI-Tokens seit v0.4.0
|
||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||
- [ ] Audit-Log-Ansicht mit Filter
|
||||
- [x] Audit-Log-Ansicht mit Filter (Benutzer, Quelle, Text, Zeitraum), auch `GET /api/v1/protokoll`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Operator kann keine Server/Katalog/Benutzer ändern (Tests)
|
||||
- [x] Operator kann keine Server/Katalog/Benutzer ändern (Tests in `tests/benutzer.test.ts`)
|
||||
|
||||
## Abhängigkeiten
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Sitzungen beenden ohne Sitzungstabelle:** `Benutzer.sitzungenAb`; ältere JWTs gelten nicht mehr.
|
||||
Gesetzt bei Sperre, Passwort-Reset und eigenem Passwortwechsel (die eigene Sitzung bekommt ein
|
||||
neues Cookie). Auf volle Sekunden, weil `iat` Sekunden hat.
|
||||
- **Schutzregeln serverseitig:** der letzte aktive Admin bleibt Admin und aktiv (Zeilensperre gegen
|
||||
zwei gleichzeitige Herabstufungen), das eigene Konto lässt sich nicht sperren.
|
||||
- Benutzer werden nicht gelöscht, nur gesperrt – Aufträge und Audit-Log verweisen auf sie.
|
||||
- „Einstellungen“ fehlte seit v0.3.0 in der Navigation (`ab: "#015"`) – jetzt sichtbar, dazu „Audit-Log“ für Admins.
|
||||
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
||||
|
||||
@@ -18,7 +18,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | – |
|
||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
|
||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | – |
|
||||
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
|
||||
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3);
|
||||
@@ -33,6 +33,8 @@ model Benutzer {
|
||||
oidcSubjekt String? @unique
|
||||
rolle Rolle @default(OPERATOR)
|
||||
aktiv Boolean @default(true)
|
||||
/// Sitzungen, die vorher ausgestellt wurden, gelten nicht mehr (Passwortwechsel, Sperre).
|
||||
sitzungenAb DateTime?
|
||||
/// Persönliche Standardwerte im Assistenten.
|
||||
standards Json?
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as benutzerListe from "@/app/api/v1/benutzer/route";
|
||||
import * as benutzerEinzeln from "@/app/api/v1/benutzer/[id]/route";
|
||||
import * as benutzerPasswort from "@/app/api/v1/benutzer/[id]/passwort/route";
|
||||
import * as ich from "@/app/api/v1/ich/route";
|
||||
import * as ichPasswort from "@/app/api/v1/ich/passwort/route";
|
||||
import * as profile from "@/app/api/v1/profile/route";
|
||||
import * as protokoll from "@/app/api/v1/protokoll/route";
|
||||
import * as server from "@/app/api/v1/server/route";
|
||||
import * as tokens from "@/app/api/v1/tokens/route";
|
||||
import { passwortHashen } from "@/lib/passwort";
|
||||
import { SITZUNG_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { protokollFilterAus } from "@/server/services/protokoll";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||
|
||||
/** Eine Sitzung, die vor zehn Sekunden ausgestellt wurde – damit `sitzungenAb` sie sicher trifft. */
|
||||
async function alteSitzung(benutzerId: string): Promise<string> {
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() - 10_000 });
|
||||
try {
|
||||
return `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId, herkunft: "passwort" })}`;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
const wer = async (cookie: string) => (await ich.GET(anfrage("GET", "/api/v1/ich", cookie))).status;
|
||||
|
||||
describe("Audit-Log-Filter", () => {
|
||||
it("übernimmt Gültiges, verwirft Unsinn", () => {
|
||||
expect(protokollFilterAus({ quelle: "CLI", von: "2026-10-01", suche: "Server", seite: "2" })).toEqual({
|
||||
quelle: "CLI",
|
||||
von: "2026-10-01",
|
||||
suche: "Server",
|
||||
seite: 2,
|
||||
});
|
||||
expect(protokollFilterAus({ quelle: "FAX", von: "gestern" })).toEqual({ seite: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Benutzer, Rollen, Audit-Log", () => {
|
||||
let admin: Awaited<ReturnType<typeof angemeldet>>;
|
||||
let operator: Awaited<ReturnType<typeof angemeldet>>;
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
admin = await angemeldet("ADMIN", "chefin");
|
||||
operator = await angemeldet("OPERATOR", "florian");
|
||||
});
|
||||
|
||||
const patch = (cookie: string, id: string, rumpf: unknown) =>
|
||||
benutzerEinzeln.PATCH(anfrage("PATCH", `/api/v1/benutzer/${id}`, cookie, rumpf), kontext({ id }));
|
||||
|
||||
it("Operator darf weder Benutzer noch Server noch Katalog ändern und kein Audit-Log lesen", async () => {
|
||||
const c = operator.cookie;
|
||||
const versuche = [
|
||||
await benutzerListe.GET(anfrage("GET", "/api/v1/benutzer", c)),
|
||||
await benutzerListe.POST(
|
||||
anfrage("POST", "/api/v1/benutzer", c, {
|
||||
benutzername: "x",
|
||||
anzeigename: "x",
|
||||
passwort: "x".repeat(12),
|
||||
}),
|
||||
),
|
||||
await patch(c, admin.benutzer.id, { rolle: "OPERATOR" }),
|
||||
await patch(c, operator.benutzer.id, { rolle: "ADMIN" }),
|
||||
await benutzerPasswort.POST(
|
||||
anfrage("POST", `/api/v1/benutzer/${admin.benutzer.id}/passwort`, c, { passwort: "y".repeat(12) }),
|
||||
kontext({ id: admin.benutzer.id }),
|
||||
),
|
||||
await server.POST(anfrage("POST", "/api/v1/server", c, { name: "x" })),
|
||||
await profile.POST(anfrage("POST", "/api/v1/profile", c, { quelltext: "id: x" })),
|
||||
await protokoll.GET(anfrage("GET", "/api/v1/protokoll", c)),
|
||||
];
|
||||
expect(versuche.map((v) => v.status)).toEqual([403, 403, 403, 403, 403, 403, 403, 403]);
|
||||
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: operator.benutzer.id } })).rolle).toBe(
|
||||
"OPERATOR",
|
||||
);
|
||||
});
|
||||
|
||||
it("Admin legt an, ändert die Rolle; doppelte Namen und kurze Passwörter sind Feldfehler", async () => {
|
||||
const neu = await benutzerListe.POST(
|
||||
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
|
||||
benutzername: "Max.Muster",
|
||||
anzeigename: "Max Muster",
|
||||
email: "",
|
||||
rolle: "OPERATOR",
|
||||
passwort: "ein-langes-passwort",
|
||||
}),
|
||||
);
|
||||
expect(neu.status).toBe(201);
|
||||
const sicht = (await neu.json()) as { id: string; benutzername: string; passwort: boolean; email: null };
|
||||
expect(sicht).toMatchObject({ benutzername: "max.muster", passwort: true, email: null });
|
||||
expect(JSON.stringify(sicht)).not.toContain("argon2");
|
||||
|
||||
const doppelt = await benutzerListe.POST(
|
||||
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
|
||||
benutzername: "max.muster",
|
||||
anzeigename: "x",
|
||||
passwort: "ein-langes-passwort",
|
||||
}),
|
||||
);
|
||||
expect(doppelt.status).toBe(409);
|
||||
expect((await doppelt.json()).fehler.felder.benutzername).toBeTruthy();
|
||||
const kurz = await benutzerListe.POST(
|
||||
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
|
||||
benutzername: "kurz",
|
||||
anzeigename: "x",
|
||||
passwort: "123",
|
||||
}),
|
||||
);
|
||||
expect((await kurz.json()).fehler.felder.passwort).toMatch(/12 Zeichen/);
|
||||
|
||||
expect((await patch(admin.cookie, sicht.id, { rolle: "ADMIN" })).status).toBe(200);
|
||||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Benutzer geändert" } });
|
||||
expect(eintrag.details).toEqual({ rolle: ["OPERATOR", "ADMIN"] });
|
||||
});
|
||||
|
||||
it("der letzte Admin bleibt Admin; niemand sperrt sich selbst", async () => {
|
||||
const herab = await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" });
|
||||
expect(herab.status).toBe(409);
|
||||
expect((await herab.json()).fehler.code).toBe("letzter_admin");
|
||||
expect((await patch(admin.cookie, admin.benutzer.id, { aktiv: false })).status).toBe(409);
|
||||
|
||||
// Mit zweitem Admin geht es.
|
||||
await patch(admin.cookie, operator.benutzer.id, { rolle: "ADMIN" });
|
||||
expect((await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" })).status).toBe(200);
|
||||
});
|
||||
|
||||
it("Sperren beendet Sitzungen und Tokens; Entsperren lässt neue Anmeldungen zu", async () => {
|
||||
const alt = await alteSitzung(operator.benutzer.id);
|
||||
const token = (await (
|
||||
await tokens.POST(anfrage("POST", "/api/v1/tokens", alt, { name: "cli" }))
|
||||
).json()) as {
|
||||
token: string;
|
||||
};
|
||||
const mitToken = anfrage("GET", "/api/v1/ich", null);
|
||||
mitToken.headers.set("authorization", `Bearer ${token.token}`);
|
||||
expect(await wer(alt)).toBe(200);
|
||||
|
||||
expect((await patch(admin.cookie, operator.benutzer.id, { aktiv: false })).status).toBe(200);
|
||||
expect(await wer(alt)).toBe(401);
|
||||
expect((await ich.GET(mitToken)).status).toBe(401);
|
||||
|
||||
await patch(admin.cookie, operator.benutzer.id, { aktiv: true });
|
||||
expect(await wer(alt)).toBe(401); // die alte Sitzung bleibt beendet
|
||||
const frisch = `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId: operator.benutzer.id, herkunft: "passwort" })}`;
|
||||
expect(await wer(frisch)).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await testPrisma.protokoll.findMany({ where: { ziel: "benutzer:florian" }, orderBy: { id: "asc" } })
|
||||
).map((p) => p.aktion),
|
||||
).toEqual(["Benutzer gesperrt", "Benutzer entsperrt"]);
|
||||
});
|
||||
|
||||
it("Passwort zurücksetzen (Admin) und selbst ändern beenden alte Sitzungen", async () => {
|
||||
await testPrisma.benutzer.update({
|
||||
where: { id: operator.benutzer.id },
|
||||
data: { passwortHash: await passwortHashen("das-alte-passwort") },
|
||||
});
|
||||
const alt = await alteSitzung(operator.benutzer.id);
|
||||
|
||||
const falsch = await ichPasswort.POST(
|
||||
anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "falsch", neu: "das-neue-passwort" }),
|
||||
);
|
||||
expect(falsch.status).toBe(400);
|
||||
expect((await falsch.json()).fehler.felder.alt).toBeTruthy();
|
||||
|
||||
const geaendert = await ichPasswort.POST(
|
||||
anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "das-alte-passwort", neu: "das-neue-passwort" }),
|
||||
);
|
||||
expect(geaendert.status).toBe(204);
|
||||
const neuesCookie = geaendert.headers.get("set-cookie")!.split(";")[0]!;
|
||||
expect(await wer(alt)).toBe(401);
|
||||
expect(await wer(neuesCookie)).toBe(200);
|
||||
|
||||
const alt2 = await alteSitzung(operator.benutzer.id);
|
||||
// alt2 ist älter als der Passwortwechsel – schon ungültig. Nach dem Zurücksetzen auch das neue Cookie:
|
||||
expect(await wer(alt2)).toBe(401);
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 2000 });
|
||||
try {
|
||||
const zurueck = await benutzerPasswort.POST(
|
||||
anfrage("POST", `/api/v1/benutzer/${operator.benutzer.id}/passwort`, admin.cookie, {
|
||||
passwort: "vom-admin-gesetzt",
|
||||
}),
|
||||
kontext({ id: operator.benutzer.id }),
|
||||
);
|
||||
expect(zurueck.status).toBe(204);
|
||||
expect(await wer(neuesCookie)).toBe(401);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Audit-Log: neueste zuerst, filterbar nach Benutzer, Quelle und Text", async () => {
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{ benutzerId: admin.benutzer.id, quelle: "WEB", aktion: "Server angelegt", ziel: "server:rz1" },
|
||||
{
|
||||
benutzerId: operator.benutzer.id,
|
||||
quelle: "CLI",
|
||||
aktion: "Ausrollen gestartet",
|
||||
ziel: "auftrag:d-abcde",
|
||||
},
|
||||
{ benutzerId: null, quelle: "SYSTEM", aktion: "Erster Administrator angelegt" },
|
||||
],
|
||||
});
|
||||
const holen = async (abfrage: string) =>
|
||||
(await (await protokoll.GET(anfrage("GET", `/api/v1/protokoll?${abfrage}`, admin.cookie))).json()) as {
|
||||
eintraege: Array<{ aktion: string; benutzer: string | null }>;
|
||||
gesamt: number;
|
||||
};
|
||||
expect((await holen("")).eintraege.map((e) => e.aktion)).toEqual([
|
||||
"Erster Administrator angelegt",
|
||||
"Ausrollen gestartet",
|
||||
"Server angelegt",
|
||||
]);
|
||||
expect((await holen("benutzer=florian")).eintraege).toEqual([
|
||||
expect.objectContaining({ aktion: "Ausrollen gestartet", benutzer: "florian" }),
|
||||
]);
|
||||
expect((await holen("quelle=SYSTEM")).gesamt).toBe(1);
|
||||
expect((await holen("suche=d-abc")).gesamt).toBe(1);
|
||||
expect((await holen("suche=server")).gesamt).toBe(1);
|
||||
// Der Filter rechnet in lokaler Zeit – also auch „heute“ lokal bilden.
|
||||
const d = new Date();
|
||||
const heute = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
expect((await holen(`von=${heute}&bis=${heute}`)).gesamt).toBe(3);
|
||||
expect((await holen("bis=2000-01-01")).gesamt).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||
|
||||
test("Admin legt einen Benutzer an, stuft ihn hoch, sperrt ihn; das Audit-Log zeigt es", async ({ page }) => {
|
||||
const name = `${E2E_PRAEFIX}-benutzer-${test.info().project.name}`;
|
||||
await anmelden(page, "/einstellungen");
|
||||
|
||||
const vorhanden = (await (await page.request.get("/api/v1/benutzer")).json()) as Array<{
|
||||
id: string;
|
||||
benutzername: string;
|
||||
}>;
|
||||
const alt = vorhanden.find((b) => b.benutzername === name);
|
||||
if (alt) {
|
||||
// Vom letzten Lauf: zurück auf Operator und aktiv.
|
||||
await page.request.patch(`/api/v1/benutzer/${alt.id}`, { data: { rolle: "OPERATOR", aktiv: true } });
|
||||
await page.reload();
|
||||
} else {
|
||||
await page.locator("#b-name").fill(name);
|
||||
await page.locator("#b-anzeige").fill("E2E Test");
|
||||
await page.locator("#b-passwort").fill("e2e-passwort-lang");
|
||||
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
|
||||
await expect(page.getByText(`${name} angelegt.`)).toBeVisible();
|
||||
}
|
||||
await keinQuerscrollen(page);
|
||||
|
||||
await page.getByLabel(`Rolle von ${name}`).selectOption("ADMIN");
|
||||
await expect(page.getByText(`${name} ist jetzt Admin.`)).toBeVisible();
|
||||
await page.getByRole("button", { name: `${name} sperren` }).click();
|
||||
await expect(page.getByText(`${name} gesperrt – Sitzungen beendet.`)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: `${name} entsperren` })).toBeVisible();
|
||||
|
||||
// Am Handy steckt die Navigation in der Schublade.
|
||||
const menue = page.getByRole("button", { name: "Navigation öffnen" });
|
||||
if (await menue.isVisible()) await menue.click();
|
||||
await page.getByRole("link", { name: "Audit-Log" }).filter({ visible: true }).click();
|
||||
await expect(page).toHaveURL(/\/protokoll/);
|
||||
await page.getByLabel("Suche").fill(`benutzer:${name}`);
|
||||
await page.getByRole("button", { name: "Filtern" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`suche=benutzer%3A${name}`));
|
||||
await expect(page.getByText("Benutzer gesperrt").first()).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
});
|
||||
Reference in New Issue
Block a user