diff --git a/CHANGELOG.md b/CHANGELOG.md index 4628e84..bd5b4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/issues/015-rollen-audit.md b/docs/issues/015-rollen-audit.md index 917e2f7..2e4f440 100644 --- a/docs/issues/015-rollen-audit.md +++ b/docs/issues/015-rollen-audit.md @@ -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). diff --git a/docs/issues/README.md b/docs/issues/README.md index c91ff30..a773c74 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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 | – | diff --git a/prisma/migrations/20261012144053_sitzungen_ab/migration.sql b/prisma/migrations/20261012144053_sitzungen_ab/migration.sql new file mode 100644 index 0000000..3bab05b --- /dev/null +++ b/prisma/migrations/20261012144053_sitzungen_ab/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92c521a..314688f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app/(app)/einstellungen/benutzer-verwaltung.tsx b/src/app/(app)/einstellungen/benutzer-verwaltung.tsx new file mode 100644 index 0000000..f611bfb --- /dev/null +++ b/src/app/(app)/einstellungen/benutzer-verwaltung.tsx @@ -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 } | 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(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>, 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 ( + +
+
    + {anfang.map((b) => ( +
  • +
    +
    +

    + {b.anzeigename} · {b.benutzername} + {b.benutzername === ich ? · du : null} +

    +

    + {[ + 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(" · ") || "–"} +

    +
    +
    + {!b.aktiv ? gesperrt : null} + + + + setPasswortFuer(passwortFuer?.id === b.id ? null : { id: b.id, passwort: "" }) + } + aria-label={`Passwort von ${b.benutzername} setzen`} + > + Passwort + + {b.benutzername !== ich ? ( + + 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"} + + ) : null} +
    +
    + {passwortFuer?.id === b.id ? ( +
    void passwortSetzen(e, b)} + className="flex flex-col gap-2 sm:flex-row sm:items-end" + > + + setPasswortFuer({ id: b.id, passwort: e.target.value })} + /> + + + Setzen + +
    + ) : null} +
  • + ))} +
+ +
+ {meldung ? {meldung.text} : null} +
+ +
void anlegen(e)} className="border-linie flex flex-col gap-4 border-t pt-5"> +

Neuer Benutzer

+
+ + setNeu({ ...neu, benutzername: e.target.value })} + /> + + + setNeu({ ...neu, anzeigename: e.target.value })} + /> + + + setNeu({ ...neu, email: e.target.value })} + /> + + + + + + setNeu({ ...neu, passwort: e.target.value })} + /> + +
+
+ + Benutzer anlegen + +
+
+
+
+ ); +} diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index 15f977d..d95cf21 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -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 (

Einstellungen

-

Angemeldet als {benutzer.benutzername}

+

+ Angemeldet als {benutzer.benutzername} · {benutzer.rolle === "ADMIN" ? "Admin" : "Operator"} +

- + + {benutzer.passwortHash ? : null} + {admin ? : null}
); } diff --git a/src/app/(app)/einstellungen/passwort-aendern.tsx b/src/app/(app)/einstellungen/passwort-aendern.tsx new file mode 100644 index 0000000..3fdad9d --- /dev/null +++ b/src/app/(app)/einstellungen/passwort-aendern.tsx @@ -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; + } | 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 ( + +
void speichern(e)} className="flex flex-col gap-4"> +
+ + setWerte({ ...werte, alt: e.target.value })} + /> + + + setWerte({ ...werte, neu: e.target.value })} + /> + + + setWerte({ ...werte, wiederholt: e.target.value })} + /> + +
+
+ + Passwort ändern + +
+ {meldung ? {meldung.text} : null} +
+
+
+
+ ); +} diff --git a/src/app/(app)/protokoll/page.tsx b/src/app/(app)/protokoll/page.tsx new file mode 100644 index 0000000..15612c4 --- /dev/null +++ b/src/app/(app)/protokoll/page.tsx @@ -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; + +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 }) { + 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 ( +
+
+

Audit-Log

+

{gesamt} Einträge

+
+ +
+ + + + + + + + + + + + + + + +
+ + + Zurücksetzen + +
+
+ + + {eintraege.length === 0 ? ( + Keine Einträge. + ) : ( +
    + {eintraege.map((e) => ( +
  1. + +
    +

    + {e.aktion} + {e.ziel ? ( + · {e.ziel} + ) : null} +

    +

    + {e.benutzer ?? "–"} · {QUELLEN[e.quelle]} + {e.ip ? ` · ${e.ip}` : ""} +

    + {e.details ? ( +
    + Details +
    +                        {JSON.stringify(e.details, null, 2)}
    +                      
    +
    + ) : null} +
    +
  2. + ))} +
+ )} +
+ + {seiten > 1 ? ( + + ) : null} +
+ ); +} diff --git a/src/app/api/v1/benutzer/[id]/passwort/route.ts b/src/app/api/v1/benutzer/[id]/passwort/route.ts new file mode 100644 index 0000000..09ee1b0 --- /dev/null +++ b/src/app/api/v1/benutzer/[id]/passwort/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/benutzer/[id]/route.ts b/src/app/api/v1/benutzer/[id]/route.ts new file mode 100644 index 0000000..2ad89c3 --- /dev/null +++ b/src/app/api/v1/benutzer/[id]/route.ts @@ -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), + ), + ); +}); diff --git a/src/app/api/v1/benutzer/route.ts b/src/app/api/v1/benutzer/route.ts new file mode 100644 index 0000000..17ab8f4 --- /dev/null +++ b/src/app/api/v1/benutzer/route.ts @@ -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 }, + ); +}); diff --git a/src/app/api/v1/ich/passwort/route.ts b/src/app/api/v1/ich/passwort/route.ts new file mode 100644 index 0000000..32396ba --- /dev/null +++ b/src/app/api/v1/ich/passwort/route.ts @@ -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; +}); diff --git a/src/app/api/v1/protokoll/route.ts b/src/app/api/v1/protokoll/route.ts new file mode 100644 index 0000000..1eb258a --- /dev/null +++ b/src/app/api/v1/protokoll/route.ts @@ -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)); +}); diff --git a/src/components/seitenleiste.tsx b/src/components/seitenleiste.tsx index ff6ba6c..742ed26 100644 --- a/src/components/seitenleiste.tsx +++ b/src/components/seitenleiste.tsx @@ -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, }; diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 0503a8a..2f2d558 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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. */ diff --git a/src/server/auth/zugang.ts b/src/server/auth/zugang.ts index e6546e7..2409a24 100644 --- a/src/server/auth/zugang.ts +++ b/src/server/auth/zugang.ts @@ -19,7 +19,10 @@ async function benutzerAusSitzung(jwt: string | undefined): Promise 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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 { + 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, + ); + }); +} diff --git a/src/server/services/protokoll.ts b/src/server/services/protokoll.ts index bf020f3..f58bdec 100644 --- a/src/server/services/protokoll.ts +++ b/src/server/services/protokoll.ts @@ -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; + +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, +): ProtokollFilter { + const geprueft: Record = {}; + for (const schluessel of Object.keys(ProtokollFilter.shape) as Array) { + 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)), + }; +} diff --git a/tests/benutzer.test.ts b/tests/benutzer.test.ts new file mode 100644 index 0000000..9e4a0a2 --- /dev/null +++ b/tests/benutzer.test.ts @@ -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 { + 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>; + let operator: Awaited>; + 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); + }); +}); diff --git a/tests/e2e/benutzer.spec.ts b/tests/e2e/benutzer.spec.ts new file mode 100644 index 0000000..37da01c --- /dev/null +++ b/tests/e2e/benutzer.spec.ts @@ -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); +});