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]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### 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)
|
- 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)
|
- „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)
|
- „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
|
### Geändert
|
||||||
|
|
||||||
### Behoben
|
### 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)
|
- 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
|
### Entfernt
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
- Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
|
||||||
|
|
||||||
## [0.4.0] – 2026-10-12
|
## [0.4.0] – 2026-10-12
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** feature, sicherheit
|
**Labels:** feature, sicherheit
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||||
@@ -8,16 +8,23 @@
|
|||||||
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
- [x] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) – seit #001, jetzt mit den neuen Routen
|
||||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
- [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
|
- [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
|
## 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
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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).
|
- 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 |
|
| [#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 |
|
| [#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 | – |
|
| [#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 | – |
|
| [#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 | – |
|
| [#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 | – |
|
| [#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
|
oidcSubjekt String? @unique
|
||||||
rolle Rolle @default(OPERATOR)
|
rolle Rolle @default(OPERATOR)
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
/// Sitzungen, die vorher ausgestellt wurden, gelten nicht mehr (Passwortwechsel, Sperre).
|
||||||
|
sitzungenAb DateTime?
|
||||||
/// Persönliche Standardwerte im Assistenten.
|
/// Persönliche Standardwerte im Assistenten.
|
||||||
standards Json?
|
standards Json?
|
||||||
erstelltAm DateTime @default(now())
|
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 type { Metadata } from "next";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
import { benutzerListe } from "@/server/services/benutzer";
|
||||||
import { schluesselFuer } from "@/server/services/schluessel";
|
import { schluesselFuer } from "@/server/services/schluessel";
|
||||||
import { tokensVon } from "@/server/services/tokens";
|
import { tokensVon } from "@/server/services/tokens";
|
||||||
|
import { BenutzerVerwaltung } from "./benutzer-verwaltung";
|
||||||
|
import { PasswortAendern } from "./passwort-aendern";
|
||||||
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
||||||
import { TokenVerwaltung } from "./token-verwaltung";
|
import { TokenVerwaltung } from "./token-verwaltung";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
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() {
|
export default async function Einstellungen() {
|
||||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||||
|
const admin = darf(benutzer.rolle, "benutzerVerwalten");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
<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>
|
</header>
|
||||||
<SchluesselVerwaltung
|
<SchluesselVerwaltung anfang={await schluesselFuer(benutzer)} teamErlaubt={admin} />
|
||||||
anfang={await schluesselFuer(benutzer)}
|
|
||||||
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
|
||||||
/>
|
|
||||||
<TokenVerwaltung
|
<TokenVerwaltung
|
||||||
anfang={await tokensVon(benutzer.id)}
|
anfang={await tokensVon(benutzer.id)}
|
||||||
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
||||||
/>
|
/>
|
||||||
|
{benutzer.passwortHash ? <PasswortAendern /> : null}
|
||||||
|
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
|
||||||
</div>
|
</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,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -33,6 +34,7 @@ const SYMBOLE: Record<
|
|||||||
vorlagen: FileStack,
|
vorlagen: FileStack,
|
||||||
katalog: BookOpen,
|
katalog: BookOpen,
|
||||||
server: Server,
|
server: Server,
|
||||||
|
protokoll: ScrollText,
|
||||||
einstellungen: Settings,
|
einstellungen: Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export type NavigationsEintrag = {
|
|||||||
| "vorlagen"
|
| "vorlagen"
|
||||||
| "katalog"
|
| "katalog"
|
||||||
| "server"
|
| "server"
|
||||||
|
| "protokoll"
|
||||||
| "einstellungen";
|
| "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. */
|
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
||||||
ab?: string;
|
ab?: string;
|
||||||
};
|
};
|
||||||
@@ -28,13 +29,8 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
|||||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
||||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||||
{
|
{ pfad: "/protokoll", titel: "Audit-Log", symbol: "protokoll", recht: "protokollLesen" },
|
||||||
pfad: "/einstellungen",
|
{ pfad: "/einstellungen", titel: "Einstellungen", symbol: "einstellungen", recht: "eigeneZugaenge" },
|
||||||
titel: "Einstellungen",
|
|
||||||
symbol: "einstellungen",
|
|
||||||
recht: "eigeneZugaenge",
|
|
||||||
ab: "#015",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
/** 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);
|
const sitzung = await sitzungLesen(jwt);
|
||||||
if (!sitzung) return null;
|
if (!sitzung) return null;
|
||||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
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. */
|
/** 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 type { Quelle } from "@/generated/prisma/enums";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
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