feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+15
View File
@@ -40,6 +40,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Benutzerverwaltung für Administratoren: Rolle setzen, Konto abschalten, lokales Konto - Benutzerverwaltung für Administratoren: Rolle setzen, Konto abschalten, lokales Konto
anlegen (#007) anlegen (#007)
- Navigation zeigt nur, was die eigene Rolle darf (#007) - Navigation zeigt nur, was die eigene Rolle darf (#007)
- Betriebsmodus **Intern** und **Dienstleistung**, umschaltbar ohne Neustart. Im Modus Intern
erscheint die eigene Organisation nirgends; der Wechsel nimmt alle Geräte, Personen,
Abteilungen und Läufe mit, weil jede Zeile ohnehin schon eine Kunden-Zuordnung trägt (#008)
- Bestätigungsdialog für den Moduswechsel, der die Folgen aus derselben Prüfung zeigt, die der
Wechsel danach durchsetzt (#008)
- Kundenverwaltung im Modus Dienstleistung: anlegen, bearbeiten, archivieren statt löschen (#008)
- Admin → Einstellungen: Betriebsmodus, Name der eigenen Organisation, globale Namensregeln,
Sperr-OU und die Anzeige der Adresse, die in den QR-Codes steckt (#008)
- Navigation und Beschriftungen folgen dem Betriebsmodus — ein Codepfad, nur ein- und
ausgeblendete Einträge (#008)
### Sicherheit ### Sicherheit
@@ -62,3 +72,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
`LESER` herabzustufen (#007) `LESER` herabzustufen (#007)
- Der letzte aktive Administrator kann sich weder herabstufen noch abschalten (#007) - Der letzte aktive Administrator kann sich weder herabstufen noch abschalten (#007)
- Beim Abschalten eines Kontos werden laufende Sitzungen sofort ungültig (#007) - Beim Abschalten eines Kontos werden laufende Sitzungen sofort ungültig (#007)
- Im Modus Intern wird eine vom Client mitgeschickte Kunden-Zuordnung verworfen, nicht bloß
abgelehnt — es gibt in diesem Modus keine Kundenwahl (#008)
- Der Rückweg nach Intern wird abgelehnt, solange mehr als ein nicht archivierter Kunde
existiert (#008)
- Die eigene Organisation lässt sich nicht archivieren (#008)
+4
View File
@@ -9,6 +9,10 @@ Variablennamen, Prisma-Modelle und -Felder, Enum-Werte, Fehlerklassen, Dateiname
Testbeschreibungen, Commit-Betreffs und Dokumentation sind deutsch. **Umlaute werden Testbeschreibungen, Commit-Betreffs und Dokumentation sind deutsch. **Umlaute werden
ASCII-gefaltet**: `Geraet`, `geaendertAm`, `laeufe`, `veroeffentlichtAm`. ASCII-gefaltet**: `Geraet`, `geaendertAm`, `laeufe`, `veroeffentlichtAm`.
**Gefaltet werden Bezeichner, nicht Fließtext.** Alles, was ein Mensch zu lesen bekommt, schreibt
sich richtig: Oberflächentexte, Fehlermeldungen, Protokoll-Aktionen (`"Rolle geändert"`),
Überschriften. „Kuerzel" in einer Fehlermeldung ist ein Fehler, `kuerzel` als Feldname nicht.
Englisch bleiben nur: Englisch bleiben nur:
- vom Framework vorgegebene Namen: `GET`, `POST`, `layout.tsx`, `route.ts`, `page.tsx`, `proxy.ts`, - vom Framework vorgegebene Namen: `GET`, `POST`, `layout.tsx`, `route.ts`, `page.tsx`, `proxy.ts`,
+1 -1
View File
@@ -24,7 +24,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
## Betriebsmodus ## Betriebsmodus
- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige - [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige (Ausnahme: die Einstellungsseite und der Wechsel-Dialog zeigen die eigene Organisation — dort ist sie zu pflegen)
- [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl - [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl
- [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden - [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden
- [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt - [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt
+50 -2
View File
@@ -10,11 +10,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min | | #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min |
| #006 | M1 – Datenmodell | 1 h 10 min | | #006 | M1 – Datenmodell | 1 h 10 min |
| #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min | | #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min |
| | **Summe** | **4 h 10 min** | | #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
| | **Summe** | **5 h 30 min** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 4 h 10 min | in Arbeit | | v0.1.0 | 5 h 30 min | in Arbeit |
--- ---
@@ -223,3 +224,50 @@ als Unterpunkte mit Aufgabe und Dauer.
- #008 Betriebsmodus und Kunden: `betriebsmodus()`, `kundeErmitteln()`, Moduswechsel in beiden - #008 Betriebsmodus und Kunden: `betriebsmodus()`, `kundeErmitteln()`, Moduswechsel in beiden
Richtungen, Kundenverwaltung, Admin-Einstellungen. Richtungen, Kundenverwaltung, Admin-Einstellungen.
---
## 2026-09-25 · Sitzung 4 · 05:35–07:00
**Issue:** #008 (M3)
**Version:** 0.1.0
### Erledigt
- `src/server/mandant.ts` als die eine Stelle für die Mandantenregel
- Moduswechsel in beiden Richtungen, Kundenverwaltung (Sub-Agent, 25 Tests), Admin-Einstellungen
- Wechsel per Playwright **durch die Oberfläche** gefahren, nicht nur im Service: Navigation
wechselt sofort, `/kunden` wird erreichbar
- Unabhängiger Review: drei Blocker, sechs weitere Befunde, alle abgearbeitet
- **336 Tests** grün (vorher 274)
### Entscheidungen
- **Die Sichtbarkeit gehört in `kundeErmitteln`,** nicht in eine zweite Funktion daneben. Sonst
müsste jeder künftige Service daran denken — und #009 ff. übernehmen diese Signatur.
- **Lesemodelle filtern selbst.** Sie verlangen den Benutzer, statt sich auf den Aufrufer zu
verlassen. `undefined` heißt „keine Einschränkung", `[]` heißt „sieht nichts"; die Umsetzung
steht an einer Stelle, und beide Fälle haben einen Test.
- **`pg_advisory_xact_lock` statt Serializable.** Der Wechsel ist selten und kurz; ein Schloss auf
Transaktionsebene ist verständlicher als eine Isolationsstufe, die man beim Lesen übersieht, und
wird auch bei einem Abbruch freigegeben.
- **Die Prüfung läuft zweimal** — einmal für den Dialog, einmal innerhalb der Transaktion. Beide
Male derselbe Code, damit es keine nachgebaute zweite Wahrheit gibt.
- **ASCII-Faltung gilt für Bezeichner, nicht für Fließtext.** Steht jetzt ausdrücklich in
`CLAUDE.md`; Protokoll-Aktionen sind entsprechend nachgezogen.
### Probleme
- **Ich habe in einem Kommentar etwas behauptet, das nicht stimmte** („Alles in einer
Transaktion"), während der Protokolleintrag dahinter stand. Ein Kommentar, der eine Zusage
macht, gehört genauso geprüft wie der Code darunter.
- **`sichtbareKundenIds` hatte keinen einzigen Aufrufer.** Eine Funktion zu schreiben, die eine
Regel durchsetzt, und sie dann nirgends zu rufen, sieht im Review aus wie Absicht und ist es
nicht. Der Volltextlauf über `src` hätte das in zehn Sekunden gezeigt.
- **Der Wettlauf war reproduzierbar**, nicht theoretisch: zwei gleichzeitige Wechsel liefen beide
durch.
### Nächster Schritt
- #009 Vorlagen-Versionierung: Veröffentlichen, tiefe Kopie als Entwurf, höchstens ein Entwurf,
Unveränderlichkeit, JSON-Export und -Import.
+51 -19
View File
@@ -1,9 +1,9 @@
# #008 M3 – Betriebsmodus und Kunden # #008 M3 – Betriebsmodus und Kunden
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** phase-1, backend, frontend **Labels:** phase-1, backend, frontend
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min
## Ziel ## Ziel
@@ -11,23 +11,55 @@ Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vol
## Aufgaben ## Aufgaben
- [ ] `betriebsmodus()` als zentraler, gecachter Zugriff auf `Einstellung.modus`; alle Listen und Formulare fragen ihn ab - [x] `betriebsmodus()` als zentraler, gecachter Zugriff auf `Einstellung.modus`; alle Listen und Formulare fragen ihn ab
- [ ] `kundeErmitteln(eingabe)`: im Modus `INTERN` immer die eigene Organisation, eine mitgeschickte `kundeId` wird **verworfen** - [x] `kundeErmitteln(eingabe)`: im Modus `INTERN` immer die eigene Organisation, eine mitgeschickte `kundeId` wird **verworfen**
- [ ] Moduswechsel-Service: `INTERN → DIENSTLEISTUNG` jederzeit, die eigene Organisation wird zum ersten Kunden (Name und Kürzel werden abgefragt), alle Daten bleiben daran hängen - [x] Moduswechsel-Service: `INTERN → DIENSTLEISTUNG` jederzeit, die eigene Organisation wird zum ersten Kunden (Name und Kürzel werden abgefragt), alle Daten bleiben daran hängen
- [ ] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis - [x] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis
- [ ] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN` - [x] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN`
- [ ] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen - [x] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen
- [ ] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen" - [x] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen"
- [ ] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung - [x] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung
- [ ] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes - [x] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes
- [ ] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/` - [x] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/`
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Im Modus `INTERN` erscheint die eigene Organisation in keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett - [x] Im Modus `INTERN` erscheint die eigene Organisation in keiner Liste, keinem Filter und auf keinem Etikett. **Eine Ausnahme, bewusst:** auf der Einstellungsseite und im Wechsel-Dialog ist sie sichtbar — dort muss der Admin ihren Namen pflegen und vor dem Wechsel wissen, was aus ihr wird.
- [ ] Test: eine Server Action mit mitgeschickter fremder `kundeId` schreibt im Modus `INTERN` trotzdem auf die eigene Organisation - [x] Test: eine Server Action mit mitgeschickter fremder `kundeId` schreibt im Modus `INTERN` trotzdem auf die eigene Organisation
- [ ] Test: Wechsel `INTERN → DIENSTLEISTUNG` erhält alle Geräte, Mitarbeiter, Abteilungen und Läufe am ersten Kunden - [x] Test: Wechsel `INTERN → DIENSTLEISTUNG` erhält alle Geräte, Mitarbeiter, Abteilungen und Läufe am ersten Kunden
- [ ] Test: Wechsel `DIENSTLEISTUNG → INTERN` wird bei zwei nicht archivierten Kunden mit Hinweis abgelehnt, bei einem gelingt er - [x] Test: Wechsel `DIENSTLEISTUNG → INTERN` wird bei zwei nicht archivierten Kunden mit Hinweis abgelehnt, bei einem gelingt er
- [ ] Test: ein Wechsel schreibt einen `Protokoll`-Eintrag mit Vorher- und Nachher-Zustand - [x] Test: ein Wechsel schreibt einen `Protokoll`-Eintrag mit Vorher- und Nachher-Zustand
- [ ] Test: `LESER` und `TECHNIKER` dürfen den Modus nicht wechseln - [x] Test: `LESER` und `TECHNIKER` dürfen den Modus nicht wechseln
- [ ] Der Modus liegt in der Datenbank und ist ohne Neustart umschaltbar - [x] Der Modus liegt in der Datenbank und ist ohne Neustart umschaltbar
## Beim Bauen aufgefallen
- **Die Lesemodelle haben die Mandantensicht nicht angewandt.** `kundenListe()` gab jedem alle
Kunden zurück, `sichtbareKundenIds` hatte in der Produktion **keinen einzigen Aufrufer**. Ein
gebundener Techniker hätte Namen, Notizen und Bestandszahlen fremder Kunden gesehen. Die
Funktionen verlangen jetzt den Benutzer und filtern selbst — `CLAUDE.md` verlangt genau das,
„damit kein Aufrufer sie vergessen kann".
- **Mein Kommentar „Alles in einer Transaktion" war falsch.** Der Protokolleintrag stand hinter
der Transaktion: schlug er fehl, war der Modus schon gewechselt und der Vorgang stand nirgends.
Der Review hat es reproduziert. Jetzt läuft alles in einer Transaktion.
- **Wettlauf beim Moduswechsel.** Prüfung und Vollzug lagen auseinander: zwei gleichzeitige
Wechsel liefen beide durch, und ein zwischen Prüfung und Vollzug angelegter Kunde führte zu
Modus `INTERN` bei zwei nicht archivierten Kunden — genau der Zustand, den die Regel verhindern
soll. Ein `pg_advisory_xact_lock` als erste Anweisung der Transaktion schließt das.
- **Die Sichtbarkeit steckte in einer zweiten Funktion**, an die jeder künftige Aufrufer selbst
hätte denken müssen. Sie ist jetzt Teil von `kundeErmitteln` — der einen Stelle, an der die
Mandantenregel steht. Die Signatur ändert sich damit vor #009, das sie übernimmt.
- **ASCII-Faltung im falschen Text.** „Kuerzel", „gehoert", „Grossbuchstaben" standen in
Fehlermeldungen, die der Admin liest, während die Oberfläche sonst „Kürzel" schreibt. Die Regel
in `CLAUDE.md` gilt für Bezeichner, nicht für Fließtext — steht jetzt ausdrücklich dort.
- **Die Erfolgsmeldung nach dem Wechsel war unsichtbar.** Sie wurde im Formularzweig gerendert,
den der gelungene Wechsel gerade durch die „Rückweg gesperrt"-Box ersetzt.
- **Der Dialog versprach etwas, das der Code nicht durchsetzt** („Das Verzeichnis bleibt der
eigenen Organisation vorbehalten"). Eine Zusage auf Vorrat in einem Dialog, dessen ganzer Wert
darin liegt, nur zu zeigen was wirklich passiert — Satz entfernt.
- **Der Handy-Screenshot des Wechsel-Dialogs zeigte das Navigationsmenü**, weil
`getByRole("group").first()` bei 390 px das erste `details` der Seite trifft und das die
Navigation ist.
- **Der Screenshot-Lauf im Modus Dienstleistung stürzte ab** — und das war korrektes Verhalten:
mit zwei nicht archivierten Kunden gibt es keinen Dialog zum Aufklappen. Daraus ist ein eigener
Screenshot geworden, der die Ablehnung zeigt.
+2 -2
View File
@@ -12,7 +12,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min | | #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min |
| #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min | | #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min |
| #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min | | #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min |
| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – | | #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – | | #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – |
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – | | #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – | | #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – |
@@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
| | **Summe** | | | **136 h** | **4 h 10 min** | | | **Summe** | | | **136 h** | **5 h 30 min** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+35 -2
View File
@@ -15,7 +15,7 @@
*/ */
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { chromium, type Browser, type Cookie } from "@playwright/test"; import { chromium, type Browser, type Cookie, type Page } from "@playwright/test";
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000"; const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
@@ -29,13 +29,41 @@ type Seite = {
readonly pfad: string; readonly pfad: string;
/** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */ /** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */
readonly ohneAnmeldung?: boolean; readonly ohneAnmeldung?: boolean;
/**
* Nur aufnehmen, wenn der Betriebsmodus passt. Der Modus steht in der
* Datenbank; wer beide Seiten braucht, laesst das Skript zweimal laufen und
* stellt dazwischen um.
*/
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
readonly vorbereiten?: (tab: Page) => Promise<void>;
}; };
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */ /** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
const SEITEN: readonly Seite[] = [ const SEITEN: readonly Seite[] = [
{ name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true }, { name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true },
{ name: "uebersicht", pfad: "/" }, { name: "uebersicht", pfad: "/" },
{ name: "admin-einstellungen", pfad: "/admin" },
{
name: "admin-moduswechsel",
pfad: "/admin",
nurModus: "INTERN",
vorbereiten: async (tab) => {
// Gezielt den Wechsel-Dialog aufklappen. `getByRole("group").first()`
// traf bei 390 px das Navigationsmenue, weil das dort das erste details ist.
await tab.locator("summary", { hasText: "wechseln" }).click();
await tab.waitForTimeout(150);
},
},
{
// Mit mehr als einem nicht archivierten Kunden ist der Rueckweg gesperrt.
// Genau das soll die Anleitung zeigen.
name: "admin-moduswechsel-abgelehnt",
pfad: "/admin",
nurModus: "DIENSTLEISTUNG",
},
{ name: "admin-benutzer", pfad: "/admin/benutzer" }, { name: "admin-benutzer", pfad: "/admin/benutzer" },
{ name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" },
]; ];
function argument(name: string): string | undefined { function argument(name: string): string | undefined {
@@ -101,6 +129,8 @@ async function kachelnErzeugen(browser: Browser): Promise<void> {
const zielMeilenstein = argument("ziel"); const zielMeilenstein = argument("ziel");
const nur = argument("nur"); const nur = argument("nur");
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
const modus = (argument("modus") ?? "INTERN") as "INTERN" | "DIENSTLEISTUNG";
const ziel = zielMeilenstein const ziel = zielMeilenstein
? join("docs", "screenshots", zielMeilenstein) ? join("docs", "screenshots", zielMeilenstein)
: join("docs", "anleitung", "bilder"); : join("docs", "anleitung", "bilder");
@@ -112,7 +142,9 @@ const browser = await chromium.launch();
try { try {
await kachelnErzeugen(browser); await kachelnErzeugen(browser);
const auswahl = SEITEN.filter((s) => !nur || s.name === nur); const auswahl = SEITEN.filter(
(s) => (!nur || s.name === nur) && (s.nurModus === undefined || s.nurModus === modus),
);
const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : []; const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : [];
for (const seite of auswahl) { for (const seite of auswahl) {
@@ -127,6 +159,7 @@ try {
if (!seite.ohneAnmeldung) await kontext.addCookies(kekse); if (!seite.ohneAnmeldung) await kontext.addCookies(kekse);
const tab = await kontext.newPage(); const tab = await kontext.newPage();
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" }); await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" });
if (seite.vorbereiten) await seite.vorbereiten(tab);
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen. // Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
await tab.evaluate(() => document.fonts.ready); await tab.evaluate(() => document.fonts.ready);
await tab.waitForLoadState("domcontentloaded"); await tab.waitForLoadState("domcontentloaded");
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { headers } from "next/headers";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
* darunter: Next rendert ein Layout bei Navigation innerhalb desselben Layouts
* nicht neu, es ist also das zweite Netz und nicht das erste (siehe CLAUDE.md).
*/
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await sitzungErforderlich("verwaltung", "/admin");
const pfad = (await headers()).get("x-pfad") ?? "/admin";
return (
<>
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const aktiv = pfad === reiter.pfad;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
aria-current={aktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
aktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
</Link>
);
})}
</nav>
{children}
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
const MODUS_NAME: Record<Betriebsmodus, string> = {
INTERN: "Intern",
DIENSTLEISTUNG: "Dienstleistung",
};
const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
INTERN:
"Einsatz in einem Unternehmen für die eigenen Mitarbeiter. Ein Verzeichnis per LDAP, keine Kundenebene.",
DIENSTLEISTUNG:
"Einsatz als IT-Dienstleister für mehrere Kunden. Geräte, Personen und Abteilungen hängen an einem Kunden; kein LDAP zu Kunden-Verzeichnissen.",
};
export default async function EinstellungenSeite() {
await sitzungErforderlich("verwaltung", "/admin");
const einstellungen = await einstellungenHolen();
const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
const pruefung = await modusWechselPruefen(gegenteil);
return (
<>
<h1 className="text-2xl font-bold">Einstellungen</h1>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="text-lg font-semibold">Betriebsmodus</h2>
<span className="rounded-pille bg-akzent-flaeche px-3 py-1 text-xs font-semibold text-akzent">
{MODUS_NAME[einstellungen.modus]}
</span>
</div>
<p className="mt-2 mb-5 max-w-prose text-sm text-nebentext">
{MODUS_ERKLAERUNG[einstellungen.modus]}
</p>
<ModusWechsel
auskunft={{
zielModus: gegenteil,
zielName: MODUS_NAME[gegenteil],
moeglich: pruefung.moeglich,
...(pruefung.grund === undefined ? {} : { grund: pruefung.grund }),
brauchtAngaben: pruefung.brauchtAngaben,
folgen: pruefung.folgen,
vorschlagName: einstellungen.eigeneOrganisation.name,
vorschlagKuerzel: einstellungen.eigeneOrganisation.kuerzel,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<EinstellungsFormular
werte={{
eigeneOrganisationName: einstellungen.eigeneOrganisation.name,
gesperrteOu: einstellungen.gesperrteOu ?? "",
regeln: {
upn: einstellungen.namensregeln.upn ?? "",
sam: einstellungen.namensregeln.sam ?? "",
mail: einstellungen.namensregeln.mail ?? "",
hostname: einstellungen.namensregeln.hostname ?? "",
},
kundenEbeneSichtbar: einstellungen.modus === Betriebsmodus.DIENSTLEISTUNG,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
Jeder gedruckte QR-Code enthält diese Adresse plus den Tag-Code. Sie kommt aus der Umgebung (
<code className="mono">APP_URL</code>) und lässt sich hier nicht ändern — gedruckte Aufkleber würden
sonst ins Leere zeigen.
</p>
<p className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-sm">
{einstellungen.appUrl ? (
`${einstellungen.appUrl}/t/LZ-7K3F9Q`
) : (
<span className="text-faellig">
APP_URL ist nicht gesetzt — Etiketten können noch nicht gedruckt werden.
</span>
)}
</p>
</section>
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { redirect } from "next/navigation";
import { KundeAnlegen } from "@/components/kunde-anlegen";
import { KundeBearbeiten } from "@/components/kunde-bearbeiten";
import { KundeArchivieren } from "@/components/kunde-archivieren";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe, type KundenZeile } from "@/server/queries/kunden";
/**
* Kundenliste – nur im Modus Dienstleistung.
*
* Im Modus Intern gibt es genau einen Kunden, und der erscheint nirgends. Die
* Seite leitet dann auf die Uebersicht, statt eine leere Liste zu zeigen.
*/
function Zaehlung({ zeile }: { zeile: KundenZeile }) {
const teile = [
[zeile.anzahlGeraete, "Gerät", "Geräte"],
[zeile.anzahlMitarbeiter, "Person", "Personen"],
[zeile.anzahlLaeufe, "Lauf", "Läufe"],
] as const;
return (
<p className="mt-1 text-xs text-nebentext">
{teile.map(([anzahl, eins, viele]) => `${anzahl} ${anzahl === 1 ? eins : viele}`).join(" · ")}
</p>
);
}
export default async function KundenSeite() {
const { benutzer } = await sitzungErforderlich("lesen", "/kunden");
if (!(await kundenEbeneSichtbar())) {
redirect("/");
}
const darfVerwalten = darf(benutzer.rolle, "verwaltung");
const zeilen = await kundenListe(benutzer, true);
return (
<>
<h1 className="text-2xl font-bold">Kunden</h1>
<p className="mt-2 max-w-prose text-nebentext">
Geräte, Personen, Abteilungen und Läufe hängen an einem Kunden. Kunden werden archiviert, nicht
gelöscht — sonst verlöre das Archiv seine Bezüge.
</p>
<ul className="mt-8 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kuerzel}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.name}
</p>
{zeile.istEigeneOrganisation ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
eigene Organisation
</span>
) : null}
</div>
<Zaehlung zeile={zeile} />
{zeile.notizen ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{zeile.notizen}</p>
) : null}
</div>
{darfVerwalten && !zeile.istEigeneOrganisation ? (
<KundeArchivieren kundeId={zeile.id} archiviert={zeile.archiviert} />
) : null}
</div>
{darfVerwalten ? (
<details className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text">
Bearbeiten
</summary>
<KundeBearbeiten
kunde={{
id: zeile.id,
name: zeile.name,
kuerzel: zeile.kuerzel,
notizen: zeile.notizen,
}}
/>
</details>
) : null}
</li>
))}
</ul>
{darfVerwalten ? (
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Kunde anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Das Kürzel erscheint auf den Etiketten und lässt sich in der Hostname-Regel als{" "}
<code className="mono">{"{kundenkuerzel}"}</code> verwenden.
</p>
<KundeAnlegen />
</section>
) : null}
</>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { Huelle } from "@/components/huelle"; import { Huelle } from "@/components/huelle";
import { betriebsmodus } from "@/server/mandant";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/** /**
@@ -16,9 +17,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe. // /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe.
const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? ""); const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? "");
const { benutzer } = await sitzungErforderlich(undefined, ziel); const { benutzer } = await sitzungErforderlich(undefined, ziel);
// Der Modus steuert Navigation und Beschriftungen – aber nur ein- und
// ausgeblendete Eintraege, nicht zwei Codepfade.
const modus = await betriebsmodus();
return ( return (
<Huelle aktiv={pfad} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}> <Huelle aktiv={pfad} modus={modus} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
{children} {children}
</Huelle> </Huelle>
); );
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { einstellungenSpeichernAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export type EinstellungsWerte = {
readonly eigeneOrganisationName: string;
readonly gesperrteOu: string;
readonly regeln: {
readonly upn: string;
readonly sam: string;
readonly mail: string;
readonly hostname: string;
};
/** Im Modus Dienstleistung heisst die Organisation in der Oberflaeche "Kunde". */
readonly kundenEbeneSichtbar: boolean;
};
const REGELN = [
{
name: "regel_upn",
beschriftung: "UPN",
schluessel: "upn",
beispiel: "{vorname}.{nachname}@firma.de",
},
{ name: "regel_sam", beschriftung: "Anmeldename (sam)", schluessel: "sam", beispiel: "{v}{nachname}" },
{ name: "regel_mail", beschriftung: "Mail", schluessel: "mail", beispiel: "{vorname}.{nachname}@firma.de" },
{ name: "regel_hostname", beschriftung: "Hostname", schluessel: "hostname", beispiel: "PC-{nnn}" },
] as const;
export function EinstellungsFormular({ werte }: { werte: EinstellungsWerte }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
einstellungenSpeichernAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-8">
<section>
<h2 className="text-lg font-semibold">
{werte.kundenEbeneSichtbar ? "Eigene Organisation" : "Organisation"}
</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
{werte.kundenEbeneSichtbar
? "Der eigene Betrieb, der im Modus Dienstleistung als erster Kunde geführt wird."
: "Im Modus Intern erscheint dieser Name nirgends in der Oberfläche. Er wird erst sichtbar, wenn du auf Dienstleistung umstellst."}
</p>
<label className="flex max-w-md flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input
name="eigeneOrganisationName"
defaultValue={werte.eigeneOrganisationName}
required
className={FELD}
/>
</label>
</section>
<section>
<h2 className="text-lg font-semibold">Namensregeln</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Vorlagen für UPN, Anmeldename, Mail und Hostname. Beim Start eines Laufs werden daraus Vorschläge
berechnet, die der Techniker überschreiben kann. Platzhalter:{" "}
<code className="mono">{"{vorname}"}</code>, <code className="mono">{"{nachname}"}</code>,{" "}
<code className="mono">{"{v}"}</code> für den ersten Buchstaben des Vornamens,{" "}
<code className="mono">{"{nnn}"}</code> für eine fortlaufende Nummer
{werte.kundenEbeneSichtbar ? (
<>
{" "}
und <code className="mono">{"{kundenkuerzel}"}</code>
</>
) : null}
.
</p>
<div className="grid gap-4 sm:grid-cols-2">
{REGELN.map((regel) => (
<label key={regel.name} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{regel.beschriftung}</span>
<input
name={regel.name}
defaultValue={werte.regeln[regel.schluessel]}
placeholder={regel.beispiel}
className={`${FELD} mono`}
/>
</label>
))}
</div>
</section>
<section>
<h2 className="text-lg font-semibold">Sperr-OU</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Ziel-OU für deaktivierte Konten. Wird beim Offboarding vorbelegt.
</p>
<label className="flex max-w-lg flex-col gap-1.5 text-sm">
<span className="text-nebentext">Organisationseinheit</span>
<input
name="gesperrteOu"
defaultValue={werte.gesperrteOu}
placeholder="OU=Gesperrt,OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
</section>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}
+6 -2
View File
@@ -137,12 +137,16 @@ function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) {
*/ */
export function Huelle({ export function Huelle({
children, children,
modus = "INTERN", modus,
aktiv = "/", aktiv = "/",
benutzer, benutzer,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
modus?: Betriebsmodus; /**
* Pflicht, ohne stillen Standard: ein vergessener Parameter wuerde sonst die
* Kundenebene ausblenden, statt aufzufallen.
*/
modus: Betriebsmodus;
aktiv?: string; aktiv?: string;
benutzer?: AngemeldeterBenutzer; benutzer?: AngemeldeterBenutzer;
}) { }) {
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { kundeAnlegenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export function KundeAnlegen() {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeAnlegenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kürzel (2–6)</span>
<input name="kuerzel" required pattern="[A-Za-z0-9]{2,6}" className={`${FELD} mono uppercase`} />
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen (optional)</span>
<textarea name="notizen" rows={2} className={`${FELD} py-2`} />
</label>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Kunde anlegen"}
</Button>
</form>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useActionState } from "react";
import { kundeArchivierenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
/**
* Archivieren und Freigeben eines Kunden.
*
* Als Client-Komponente, damit die Begruendung sichtbar wird, wenn der Service
* ablehnt — etwa beim Versuch, die eigene Organisation zu archivieren. Zuvor
* endete das in einer Fehlerseite.
*/
export function KundeArchivieren({ kundeId, archiviert }: { kundeId: string; archiviert: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeArchivierenAktion, {});
return (
<div className="shrink-0">
<form action={aktion}>
<input type="hidden" name="id" value={kundeId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button
type="submit"
disabled={laeuft}
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
archiviert
? "bg-flaeche-hoch text-nebentext hover:text-text"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-xs text-xs text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { kundeBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export function KundeBearbeiten({
kunde,
}: {
kunde: {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
readonly notizen: string | null;
};
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeBearbeitenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-3 border-t border-linie p-4">
<input type="hidden" name="id" value={kunde.id} />
<div className="grid gap-3 sm:grid-cols-[1fr_10rem]">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" defaultValue={kunde.name} required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kürzel</span>
<input
name="kuerzel"
defaultValue={kunde.kuerzel}
required
pattern="[A-Za-z0-9]{2,6}"
className={`${FELD} mono uppercase`}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen</span>
<textarea name="notizen" defaultValue={kunde.notizen ?? ""} rows={2} className={`${FELD} py-2`} />
</label>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
variant="outline"
className="h-11 self-start rounded-pille px-4 text-sm md:h-9"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useActionState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { modusWechselnAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export type WechselAuskunft = {
readonly zielModus: "INTERN" | "DIENSTLEISTUNG";
readonly zielName: string;
readonly moeglich: boolean;
readonly grund?: string;
readonly brauchtAngaben: boolean;
readonly folgen: readonly string[];
readonly vorschlagName: string;
readonly vorschlagKuerzel: string;
};
/**
* Bestaetigungsdialog fuer den Moduswechsel.
*
* Die Folgen kommen aus derselben Pruefung, die der Wechsel danach durchsetzt –
* keine zweite, nachgebaute Wahrheit. Aufgeklappt wird mit `details`, damit der
* Dialog auch ohne JavaScript zugaenglich ist; das Haekchen ist Pflicht, weil
* der Wechsel aendert, was die ganze Anwendung zeigt.
*/
export function ModusWechsel({ auskunft }: { auskunft: WechselAuskunft }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(modusWechselnAktion, {});
// Die Rueckmeldung steht ausserhalb der beiden Zweige: nach einem gelungenen
// Wechsel zeigt die Seite die Gegenrichtung, und die ist meist gesperrt – der
// Erfolgstext waere sonst genau dann verschwunden, wenn er gebraucht wird.
const rueckmeldung = (
<>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</>
);
if (!auskunft.moeglich) {
return (
<div className="flex flex-col gap-3">
{rueckmeldung}
<div className="rounded-karte border border-linie bg-flaeche-hoch p-4">
<p className="text-sm text-nebentext">
<span className="font-medium text-text">
Wechsel zu {auskunft.zielName} gerade nicht möglich.
</span>{" "}
{auskunft.grund}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{ergebnis.erfolg || ergebnis.fehler ? rueckmeldung : null}
<details className="group rounded-karte border border-linie bg-flaeche-hoch">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium">
<AlertTriangle className="size-[18px] text-faellig" strokeWidth={1.75} aria-hidden="true" />
Zu {auskunft.zielName} wechseln
<span className="ml-auto text-xs text-nebentext group-open:hidden">aufklappen</span>
</summary>
<form action={aktion} className="flex flex-col gap-4 border-t border-linie p-4">
<input type="hidden" name="ziel" value={auskunft.zielModus} />
<div>
<p className="text-sm font-medium">Das passiert:</p>
<ul className="mt-2 flex list-disc flex-col gap-1 pl-5 text-sm text-nebentext">
{auskunft.folgen.map((folge) => (
<li key={folge}>{folge}</li>
))}
</ul>
</div>
{auskunft.brauchtAngaben ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name als erster Kunde</span>
<input name="name" defaultValue={auskunft.vorschlagName} required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kürzel (2–6 Zeichen)</span>
<input
name="kuerzel"
defaultValue={auskunft.vorschlagKuerzel}
required
pattern="[A-Za-z0-9]{2,6}"
className={`${FELD} mono uppercase`}
/>
</label>
</div>
) : null}
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
name="bestaetigt"
value="ja"
required
className="mt-1 size-4 accent-[var(--color-akzent)]"
/>
<span>Ja, den Betriebsmodus jetzt auf {auskunft.zielName} umstellen.</span>
</label>
{rueckmeldung}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird umgestellt …" : `Auf ${auskunft.zielName} umstellen`}
</Button>
</form>
</details>
</div>
);
}
+2 -17
View File
@@ -12,8 +12,6 @@ export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
export type NavEintrag = { export type NavEintrag = {
readonly pfad: string; readonly pfad: string;
readonly beschriftung: string; readonly beschriftung: string;
/** Beschriftung im Modus Dienstleistung, falls sie abweicht. */
readonly beschriftungDienstleistung?: string;
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */ /** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
readonly nurModus?: Betriebsmodus; readonly nurModus?: Betriebsmodus;
readonly symbol: string; readonly symbol: string;
@@ -41,19 +39,10 @@ export const NAVIGATION: readonly NavEintrag[] = [
beschriftung: "Kunden", beschriftung: "Kunden",
nurModus: "DIENSTLEISTUNG", nurModus: "DIENSTLEISTUNG",
symbol: "Building2", symbol: "Building2",
verfuegbar: false,
ausIssue: 8,
},
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
{
// Zeigt vorerst direkt auf die Benutzerverwaltung; mit #008 kommen die
// Einstellungen dazu und daraus wird ein eigener Bereich.
pfad: "/admin/benutzer",
beschriftung: "Admin",
symbol: "Settings",
recht: "verwaltung",
verfuegbar: true, verfuegbar: true,
}, },
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", verfuegbar: true },
]; ];
/** /**
@@ -70,9 +59,5 @@ export function navigationFuer(
return NAVIGATION.filter( return NAVIGATION.filter(
(e) => (e) =>
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)), (e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
).map((e) =>
modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung
? { ...e, beschriftung: e.beschriftungDienstleistung }
: e,
); );
} }
+106
View File
@@ -0,0 +1,106 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
EinstellungsFehler,
NamensregelnSchema,
einstellungenSpeichern,
modusWechseln,
} from "@/server/services/einstellungen";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
/** Server Actions der Einstellungen (#008). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const EinstellungsSchema = z.object({
eigeneOrganisationName: z.string().trim().min(1, "Die eigene Organisation braucht einen Namen."),
gesperrteOu: z.string().trim().optional(),
namensregeln: NamensregelnSchema,
});
export async function einstellungenSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = EinstellungsSchema.safeParse({
eigeneOrganisationName: formular.get("eigeneOrganisationName"),
gesperrteOu: formular.get("gesperrteOu") ?? undefined,
namensregeln: {
upn: formular.get("regel_upn") ?? undefined,
sam: formular.get("regel_sam") ?? undefined,
mail: formular.get("regel_mail") ?? undefined,
hostname: formular.get("regel_hostname") ?? undefined,
},
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await einstellungenSpeichern(
{
eigeneOrganisationName: eingabe.data.eigeneOrganisationName,
namensregeln: eingabe.data.namensregeln,
gesperrteOu: eingabe.data.gesperrteOu ?? null,
},
benutzer.id,
);
revalidatePath("/admin");
return { erfolg: "Gespeichert." };
} catch (fehler) {
if (fehler instanceof EinstellungsFehler) return { fehler: fehler.message };
console.error("Einstellungen speichern fehlgeschlagen:", fehler);
return { fehler: "Die Einstellungen konnten nicht gespeichert werden." };
}
}
const WechselSchema = z.object({
ziel: z.enum(Betriebsmodus),
// Der Bestaetigungsdialog verlangt ein ausdrueckliches Haekchen: der Wechsel
// aendert, was die ganze Anwendung zeigt.
bestaetigt: z.literal("ja", { message: "Bitte den Wechsel ausdrücklich bestätigen." }),
name: z.string().trim().optional(),
kuerzel: z.string().trim().optional(),
});
export async function modusWechselnAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = WechselSchema.safeParse({
ziel: formular.get("ziel"),
bestaetigt: formular.get("bestaetigt"),
name: formular.get("name") ?? undefined,
kuerzel: formular.get("kuerzel") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await modusWechseln(
eingabe.data.ziel,
{ name: eingabe.data.name, kuerzel: eingabe.data.kuerzel },
benutzer.id,
);
// Der Modus aendert Navigation und Beschriftungen ueberall.
revalidatePath("/", "layout");
return {
erfolg:
eingabe.data.ziel === Betriebsmodus.DIENSTLEISTUNG
? "Der Betriebsmodus ist jetzt Dienstleistung. Kunden sind ab sofort eine sichtbare Ebene."
: "Der Betriebsmodus ist jetzt Intern. Die Kundenebene ist ausgeblendet.",
};
} catch (fehler) {
if (fehler instanceof EinstellungsFehler) return { fehler: fehler.message };
console.error("Moduswechsel fehlgeschlagen:", fehler);
return { fehler: "Der Moduswechsel ist fehlgeschlagen." };
}
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { KundenFehler, kundeAnlegen, kundeArchivieren, kundeBearbeiten } from "@/server/services/kunden";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
/** Server Actions der Kundenverwaltung (#008). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const NeuSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
kuerzel: z.string().trim().min(1, "Bitte ein Kürzel angeben."),
notizen: z.string().trim().optional(),
});
export async function kundeAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = NeuSchema.safeParse({
name: formular.get("name"),
kuerzel: formular.get("kuerzel"),
notizen: formular.get("notizen") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const kunde = await kundeAnlegen(eingabe.data, benutzer.id);
revalidatePath("/kunden");
return { erfolg: `${kunde.name} (${kunde.kuerzel}) wurde angelegt.` };
} catch (fehler) {
if (fehler instanceof KundenFehler) return { fehler: fehler.message };
console.error("Kunde anlegen fehlgeschlagen:", fehler);
return { fehler: "Der Kunde konnte nicht angelegt werden." };
}
}
const BearbeitenSchema = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
kuerzel: z.string().trim().min(1, "Bitte ein Kürzel angeben."),
notizen: z.string().trim().optional(),
});
export async function kundeBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = BearbeitenSchema.safeParse({
id: formular.get("id"),
name: formular.get("name"),
kuerzel: formular.get("kuerzel"),
notizen: formular.get("notizen") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await kundeBearbeiten(
eingabe.data.id,
{
name: eingabe.data.name,
kuerzel: eingabe.data.kuerzel,
notizen: eingabe.data.notizen ?? null,
},
benutzer.id,
);
revalidatePath("/kunden");
return { erfolg: "Gespeichert." };
} catch (fehler) {
if (fehler instanceof KundenFehler) return { fehler: fehler.message };
console.error("Kunde bearbeiten fehlgeschlagen:", fehler);
return { fehler: "Die Änderung konnte nicht gespeichert werden." };
}
}
export async function kundeArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = z
.object({ id: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ id: formular.get("id"), archiviert: formular.get("archiviert") });
if (!eingabe.success) {
return { fehler: "Die Eingabe ist unvollständig." };
}
try {
await kundeArchivieren(eingabe.data.id, eingabe.data.archiviert === "true", benutzer.id);
revalidatePath("/kunden");
return {};
} catch (fehler) {
// Der Versuch, die eigene Organisation zu archivieren, hat eine sorgfaeltig
// formulierte Begruendung – die soll der Admin sehen und keine Fehlerseite.
if (fehler instanceof KundenFehler) return { fehler: fehler.message };
console.error("Kunde archivieren fehlgeschlagen:", fehler);
return { fehler: "Die Änderung konnte nicht gespeichert werden." };
}
}
+2 -2
View File
@@ -174,7 +174,7 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
data: { data: {
entitaet: "Benutzer", entitaet: "Benutzer",
entitaetId: benutzer.id, entitaetId: benutzer.id,
aktion: "Rolle aus den OIDC-Gruppen geaendert", aktion: "Rolle aus den OIDC-Gruppen geändert",
benutzerId: benutzer.id, benutzerId: benutzer.id,
vorher: { rolle: vorhanden.rolle }, vorher: { rolle: vorhanden.rolle },
nachher: { rolle }, nachher: { rolle },
@@ -287,7 +287,7 @@ export async function rolleSetzen(benutzerId: string, rolle: Rolle, durchBenutze
data: { data: {
entitaet: "Benutzer", entitaet: "Benutzer",
entitaetId: benutzerId, entitaetId: benutzerId,
aktion: "Rolle geaendert", aktion: "Rolle geändert",
benutzerId: durchBenutzerId, benutzerId: durchBenutzerId,
vorher: { rolle: vorher.rolle }, vorher: { rolle: vorher.rolle },
nachher: { rolle }, nachher: { rolle },
+152
View File
@@ -0,0 +1,152 @@
import { cache } from "react";
import { Betriebsmodus, type Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Die Mandantengrenze (#008).
*
* Laufzettel laeuft in zwei Betriebsmodi, aber **nicht** in zwei Codepfaden.
* Jede kundenbezogene Zeile traegt immer eine `kundeId`; im Modus INTERN ist das
* die eine Organisation mit `istEigeneOrganisation = true`, die in der
* Oberflaeche nirgends erscheint. Dadurch ist der Wechsel in den Modus
* DIENSTLEISTUNG eine Datenaenderung und keine Migration.
*
* Die Trennung nach Kunden ist eine Sicherheitsgrenze. Sie steht deshalb an drei
* Stellen: hier, als zusammengesetzter Fremdschluessel in der Datenbank (siehe
* docs/architektur.md) und in den Lesemodellen, die ihre Sicht selbst einengen.
*/
export class MandantFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "MandantFehler";
}
}
/**
* Der Betriebsmodus. Liegt in der Datenbank und nicht in der Umgebung, damit der
* Admin ohne Neustart umschalten kann.
*
* `cache` teilt die Abfrage innerhalb einer Anfrage; ueber Anfragen hinweg wird
* bewusst nicht gecacht, sonst wuerde ein Wechsel erst nach einem Neustart
* wirken.
*/
export const betriebsmodus = cache(async (): Promise<Betriebsmodus> => {
const einstellung = await prisma.einstellung.findUnique({
where: { id: "global" },
select: { modus: true },
});
// Ohne Einstellung ist INTERN der sichere Standard: keine Kundenebene, nichts
// faellt versehentlich auseinander.
return einstellung?.modus ?? Betriebsmodus.INTERN;
});
export type EigeneOrganisation = {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
};
export const eigeneOrganisation = cache(async (): Promise<EigeneOrganisation> => {
const kunde = await prisma.kunde.findFirst({
where: { istEigeneOrganisation: true },
select: { id: true, name: true, kuerzel: true },
});
if (!kunde) {
throw new MandantFehler("Es gibt keine eigene Organisation. Der Seed legt sie an: pnpm db:seed.");
}
return kunde;
});
export type HandelnderBenutzer = {
readonly id: string;
readonly rolle: Rolle;
};
/**
* Welcher Kunde gilt fuer diesen Vorgang?
*
* Im Modus INTERN **immer** die eigene Organisation. Eine vom Client
* mitgeschickte `kundeId` wird dann nicht etwa abgelehnt, sondern schlicht
* verworfen — es gibt in diesem Modus keine Kundenwahl, also auch nichts zu
* entscheiden.
*
* Im Modus DIENSTLEISTUNG ist die Angabe Pflicht, muss auf einen nicht
* archivierten Kunden zeigen **und** fuer diesen Benutzer sichtbar sein. Die
* Sichtbarkeit steht bewusst hier und nicht in einer zweiten Funktion, an die
* jeder Aufrufer selbst denken muesste: das ist die eine Stelle, an der die
* Mandantenregel steht, und jeder Service, der eine kundenbezogene Zeile
* schreibt, ruft sie.
*/
export async function kundeErmitteln(
kundeIdAusEingabe: string | null | undefined,
benutzer: HandelnderBenutzer,
): Promise<string> {
if ((await betriebsmodus()) === Betriebsmodus.INTERN) {
return (await eigeneOrganisation()).id;
}
const gewuenscht = kundeIdAusEingabe?.trim();
if (!gewuenscht) {
throw new MandantFehler("Im Modus Dienstleistung muss ein Kunde gewählt werden.");
}
const kunde = await prisma.kunde.findUnique({
where: { id: gewuenscht },
select: { id: true, archiviert: true, name: true },
});
if (!kunde) {
throw new MandantFehler("Diesen Kunden gibt es nicht.");
}
if (kunde.archiviert) {
throw new MandantFehler(`${kunde.name} ist archiviert und kann nichts Neues aufnehmen.`);
}
await kundeZugriffPruefen(kunde.id, benutzer);
return kunde.id;
}
/**
* Die Kunden, die dieser Benutzer sehen darf – oder `undefined` fuer "alle".
*
* `undefined` und eine leere Liste bedeuten Verschiedenes: `undefined` heisst
* "keine Einschraenkung", eine leere Liste heisst "sieht nichts". Ein Aufrufer,
* der beides verwechselt, zeigt entweder zu viel oder zu wenig.
*
* Eingeschraenkt wird nur im Modus DIENSTLEISTUNG, nur fuer TECHNIKER und LESER
* und nur, wenn ueberhaupt eine Bindung gepflegt ist (OIDC_CUSTOMER_GROUP_PREFIX).
*/
export async function sichtbareKundenIds(
benutzer: HandelnderBenutzer,
): Promise<readonly string[] | undefined> {
if ((await betriebsmodus()) === Betriebsmodus.INTERN) return undefined;
if (benutzer.rolle === "ADMIN" || benutzer.rolle === "REDAKTEUR") return undefined;
if (!process.env["OIDC_CUSTOMER_GROUP_PREFIX"]?.trim()) return undefined;
const bindungen = await prisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id },
select: { kundeId: true },
});
return bindungen.map((bindung) => bindung.kundeId);
}
/**
* Darf dieser Benutzer auf diesen Kunden? Wirft, wenn nicht.
*
* `kundeErmitteln` ruft das selbst. Einzeln gebraucht wird es von Services, die
* eine vorhandene Zeile aendern: dort steht die `kundeId` schon fest.
*/
export async function kundeZugriffPruefen(kundeId: string, benutzer: HandelnderBenutzer): Promise<void> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return;
if (!sichtbar.includes(kundeId)) {
throw new MandantFehler("Für diesen Kunden fehlt dir die Berechtigung.");
}
}
/**
* Im Modus INTERN wird die Kundenebene nirgends gezeigt – keine Liste, kein
* Filter, kein Formular, kein Etikett.
*/
export async function kundenEbeneSichtbar(): Promise<boolean> {
return (await betriebsmodus()) === Betriebsmodus.DIENSTLEISTUNG;
}
+117
View File
@@ -0,0 +1,117 @@
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Kundenverwaltung (#008).
*
* Sichtbar sind diese Listen nur im Modus `DIENSTLEISTUNG`; im Modus `INTERN`
* gibt es genau die eigene Organisation, und die erscheint nirgends. Welcher
* Modus gilt, entscheidet der Aufrufer.
*
* Die Mandantensicht wenden diese Funktionen aber **selbst** an, statt sie dem
* Aufrufer zu ueberlassen: ein vergessener Filter waere hier kein Schoenheits-,
* sondern ein Datenschutzfehler. Deshalb verlangt jede von ihnen den Benutzer.
*/
/**
* `undefined` heisst "keine Einschraenkung", eine leere Liste heisst "sieht
* nichts". Wer das verwechselt, zeigt entweder alles oder gar nichts — deshalb
* steht die Umsetzung an einer Stelle.
*/
async function sichtbarkeitsFilter(benutzer: HandelnderBenutzer) {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? {} : { id: { in: [...sichtbar] } };
}
export type KundenZeile = {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
readonly istEigeneOrganisation: boolean;
readonly archiviert: boolean;
readonly notizen: string | null;
/** Fuer die Kundenliste: woran haengt der Kunde? */
readonly anzahlGeraete: number;
readonly anzahlMitarbeiter: number;
readonly anzahlLaeufe: number;
};
/**
* Die Zaehlungen kommen als `_count` aus derselben Abfrage.
*
* In einer Schleife nachzuzaehlen waere eine Abfrage je Kunde – bei einem
* Dienstleister mit dreissig Kunden also einundneunzig statt einer.
*/
const AUSWAHL = {
id: true,
name: true,
kuerzel: true,
istEigeneOrganisation: true,
archiviert: true,
notizen: true,
_count: { select: { geraete: true, mitarbeiter: true, laeufe: true } },
} as const;
type RohZeile = {
id: string;
name: string;
kuerzel: string;
istEigeneOrganisation: boolean;
archiviert: boolean;
notizen: string | null;
_count: { geraete: number; mitarbeiter: number; laeufe: number };
};
function zeileBauen(roh: RohZeile): KundenZeile {
return {
id: roh.id,
name: roh.name,
kuerzel: roh.kuerzel,
istEigeneOrganisation: roh.istEigeneOrganisation,
archiviert: roh.archiviert,
notizen: roh.notizen,
anzahlGeraete: roh._count.geraete,
anzahlMitarbeiter: roh._count.mitarbeiter,
anzahlLaeufe: roh._count.laeufe,
};
}
/**
* Alle Kunden, nicht archivierte zuerst, dann nach Namen.
*
* Archivierte bleiben standardmaessig aussen vor: die Liste ist der
* Alltagsbestand, das Archiv ist ein ausdruecklicher Griff.
*/
export async function kundenListe(
benutzer: HandelnderBenutzer,
auchArchivierte = false,
): Promise<readonly KundenZeile[]> {
const zeilen = await prisma.kunde.findMany({
where: {
...(await sichtbarkeitsFilter(benutzer)),
...(auchArchivierte ? {} : { archiviert: false }),
},
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
return zeilen.map(zeileBauen);
}
export async function kundeHolen(id: string, benutzer: HandelnderBenutzer): Promise<KundenZeile | undefined> {
const zeile = await prisma.kunde.findFirst({
where: { id, ...(await sichtbarkeitsFilter(benutzer)) },
select: AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
/** Fuer Auswahlfelder: id, name, kuerzel nicht archivierter Kunden, nach Namen. */
export async function kundenZurAuswahl(
benutzer: HandelnderBenutzer,
): Promise<readonly { id: string; name: string; kuerzel: string }[]> {
return prisma.kunde.findMany({
where: { archiviert: false, ...(await sichtbarkeitsFilter(benutzer)) },
orderBy: { name: "asc" },
select: { id: true, name: true, kuerzel: true },
});
}
+312
View File
@@ -0,0 +1,312 @@
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { betriebsmodus, eigeneOrganisation } from "@/server/mandant";
import { kuerzelPruefen } from "@/server/services/kunden";
/**
* Einstellungen der Anwendung und der Wechsel des Betriebsmodus (#008).
*
* Der Modus liegt in der Datenbank, nicht in der Umgebung – der Admin schaltet
* ohne Neustart um. Der Wechsel ist keine Migration, sondern eine
* Datenaenderung: jede kundenbezogene Zeile traegt ohnehin schon eine `kundeId`.
*/
export class EinstellungsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "EinstellungsFehler";
}
}
/**
* Vorlagen fuer abgeleitete Werte. Die Platzhalter darin (`{vorname}`,
* `{nachname}`, `{v}`, `{nnn}`, `{kundenkuerzel}`) loest #012 auf; hier werden
* sie nur gespeichert und bearbeitet.
*/
export const NamensregelnSchema = z.object({
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export type Einstellungen = {
readonly modus: Betriebsmodus;
readonly namensregeln: Namensregeln;
/** Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding. */
readonly gesperrteOu: string | null;
readonly eigeneOrganisation: { readonly id: string; readonly name: string; readonly kuerzel: string };
/** Nur zur Anzeige: sie steckt in den QR-Codes und kommt aus der Umgebung. */
readonly appUrl: string | null;
};
/** Ein unbrauchbarer Json-Inhalt darf die Einstellungsseite nicht sprengen. */
function namensregelnLesen(roh: unknown): Namensregeln {
const gelesen = NamensregelnSchema.safeParse(roh);
return gelesen.success ? gelesen.data : {};
}
export async function einstellungenHolen(): Promise<Einstellungen> {
const [einstellung, organisation] = await Promise.all([
prisma.einstellung.findUnique({ where: { id: "global" } }),
eigeneOrganisation(),
]);
return {
modus: einstellung?.modus ?? Betriebsmodus.INTERN,
namensregeln: namensregelnLesen(einstellung?.namensregeln),
gesperrteOu: einstellung?.gesperrteOu ?? null,
eigeneOrganisation: organisation,
appUrl: process.env["APP_URL"]?.trim() || null,
};
}
export type EinstellungsAenderung = {
readonly eigeneOrganisationName?: string;
readonly namensregeln?: Namensregeln;
readonly gesperrteOu?: string | null;
};
export async function einstellungenSpeichern(
aenderung: EinstellungsAenderung,
durchBenutzerId: string,
): Promise<void> {
const vorher = await einstellungenHolen();
if (aenderung.eigeneOrganisationName !== undefined) {
const name = aenderung.eigeneOrganisationName.trim();
if (!name) {
throw new EinstellungsFehler("Die eigene Organisation braucht einen Namen.");
}
if (name !== vorher.eigeneOrganisation.name) {
await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } });
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: vorher.eigeneOrganisation.id,
aktion: "Name der eigenen Organisation geändert",
benutzerId: durchBenutzerId,
vorher: { name: vorher.eigeneOrganisation.name },
nachher: { name },
},
});
}
}
const neueRegeln = aenderung.namensregeln ?? vorher.namensregeln;
const neueOu =
aenderung.gesperrteOu === undefined
? vorher.gesperrteOu
: (aenderung.gesperrteOu?.trim() ?? null) || null;
const regelnGeaendert = JSON.stringify(neueRegeln) !== JSON.stringify(vorher.namensregeln);
const ouGeaendert = neueOu !== vorher.gesperrteOu;
if (!regelnGeaendert && !ouGeaendert) return;
await prisma.einstellung.upsert({
where: { id: "global" },
create: { id: "global", modus: vorher.modus, namensregeln: neueRegeln, gesperrteOu: neueOu },
update: { namensregeln: neueRegeln, gesperrteOu: neueOu },
});
await prisma.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Einstellungen geändert",
benutzerId: durchBenutzerId,
vorher: { namensregeln: vorher.namensregeln, gesperrteOu: vorher.gesperrteOu },
nachher: { namensregeln: neueRegeln, gesperrteOu: neueOu },
},
});
}
/**
* Feste Kennung fuer das Vorhaengeschloss des Moduswechsels.
*
* Ohne Schloss lassen sich zwei Wechsel gleichzeitig vollziehen, oder ein Kunde
* entsteht zwischen Pruefung und Vollzug — dann stuende der Modus auf INTERN,
* obwohl es zwei nicht archivierte Kunden gibt. Ein Advisory Lock auf
* Transaktionsebene wird am Ende automatisch freigegeben, auch bei einem Abbruch.
*/
const WECHSEL_SCHLOSS = 815_423_100;
export type WechselPruefung = {
readonly moeglich: boolean;
/** Warum nicht – nur gesetzt, wenn `moeglich` falsch ist. */
readonly grund?: string;
/** Braucht der Wechsel Name und Kuerzel vom Admin? */
readonly brauchtAngaben: boolean;
/** Was der Admin im Bestaetigungsdialog wissen muss. */
readonly folgen: readonly string[];
};
/**
* Was passiert beim Wechsel, und geht er ueberhaupt?
*
* Getrennt vom Vollzug, damit der Bestaetigungsdialog dieselbe Auskunft zeigt,
* die der Wechsel danach durchsetzt – und nicht eine nachgebaute zweite Wahrheit.
*/
export async function modusWechselPruefen(ziel: Betriebsmodus): Promise<WechselPruefung> {
return pruefungMit(prisma, ziel, await betriebsmodus());
}
/**
* Dieselbe Pruefung, aber gegen einen beliebigen Client – damit der Vollzug sie
* **innerhalb** seiner Transaktion wiederholen kann. Der Dialog und der Wechsel
* benutzen denselben Code; eine nachgebaute zweite Wahrheit waere genau die
* Sorte Fehler, die man erst im Betrieb bemerkt.
*/
async function pruefungMit(
client: Prisma.TransactionClient | typeof prisma,
ziel: Betriebsmodus,
aktuell: Betriebsmodus,
): Promise<WechselPruefung> {
if (aktuell === ziel) {
return {
moeglich: false,
grund: "Dieser Modus ist bereits eingestellt.",
brauchtAngaben: false,
folgen: [],
};
}
if (ziel === Betriebsmodus.DIENSTLEISTUNG) {
const organisation = await eigeneOrganisation();
return {
moeglich: true,
brauchtAngaben: true,
folgen: [
`Die bisherige eigene Organisation „${organisation.name}" wird zum ersten Kunden.`,
"Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.",
"Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, Kundenkürzel auf den Etiketten.",
],
};
}
const offene = await client.kunde.count({ where: { archiviert: false } });
if (offene !== 1) {
return {
moeglich: false,
grund:
`Zurück geht es nur mit genau einem nicht archivierten Kunden, vorhanden sind ${offene}. ` +
"Archiviere die übrigen Kunden, dann ist der Wechsel möglich.",
brauchtAngaben: false,
folgen: [],
};
}
const einziger = await client.kunde.findFirstOrThrow({ where: { archiviert: false } });
return {
moeglich: true,
brauchtAngaben: false,
folgen: [
`„${einziger.name}" wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
"Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.",
"Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.",
],
};
}
export type WechselAngaben = {
/** Name der eigenen Organisation als erster Kunde – nur Richtung DIENSTLEISTUNG. */
readonly name?: string;
readonly kuerzel?: string;
};
/**
* Vollzieht den Wechsel.
*
* Alles in einer Transaktion: ein halb gewechselter Zustand waere schlimmer als
* gar keiner.
*/
export async function modusWechseln(
ziel: Betriebsmodus,
angaben: WechselAngaben,
durchBenutzerId: string,
): Promise<void> {
const organisation = await eigeneOrganisation();
// Alles in einer Transaktion: Schloss, Pruefung, Aenderung und Protokoll.
// Frueher stand das Protokoll dahinter – schlug es fehl, war der Modus schon
// gewechselt und der Vorgang stand nirgends.
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${WECHSEL_SCHLOSS})`;
const einstellung = await tx.einstellung.findUnique({
where: { id: "global" },
select: { modus: true },
});
const vorher = einstellung?.modus ?? Betriebsmodus.INTERN;
// Innerhalb des Schlosses erneut pruefen: zwischen Dialog und Klick kann ein
// Kunde entstanden sein oder ein zweiter Admin schon gewechselt haben.
const pruefung = await pruefungMit(tx, ziel, vorher);
if (!pruefung.moeglich) {
throw new EinstellungsFehler(pruefung.grund ?? "Der Wechsel ist nicht möglich.");
}
let nachherOrganisation: { name: string; kuerzel: string };
if (ziel === Betriebsmodus.DIENSTLEISTUNG) {
const name = angaben.name?.trim() || organisation.name;
// Dieselbe Regel und dieselbe Meldung wie in der Kundenverwaltung.
const kuerzel = kuerzelPruefen(angaben.kuerzel?.trim() || organisation.kuerzel);
const belegt = await tx.kunde.findFirst({
where: { kuerzel, id: { not: organisation.id } },
select: { name: true, archiviert: true },
});
if (belegt) {
throw new EinstellungsFehler(
`Das Kürzel ${kuerzel} gehört bereits zu ${belegt.name}${belegt.archiviert ? " (archiviert)" : ""}.`,
);
}
// Die Zeile bleibt dieselbe und behaelt istEigeneOrganisation: sie ist
// weiterhin die eigene Firma, nur jetzt als sichtbarer Kunde. Deshalb
// haengt auch nichts um.
await tx.kunde.update({ where: { id: organisation.id }, data: { name, kuerzel } });
nachherOrganisation = { name, kuerzel };
} else {
const einziger = await tx.kunde.findFirstOrThrow({ where: { archiviert: false } });
if (!einziger.istEigeneOrganisation) {
// Kann heute nicht vorkommen, weil sich die eigene Organisation nicht
// archivieren laesst. Steht hier, damit die Regel "der einzige
// verbleibende Kunde wird zur eigenen Organisation" auch dann traegt,
// wenn sich daran je etwas aendert. Erst loesen, dann setzen – der
// partielle Index laesst nur eine true-Zeile zu.
await tx.kunde.updateMany({
where: { istEigeneOrganisation: true },
data: { istEigeneOrganisation: false },
});
await tx.kunde.update({ where: { id: einziger.id }, data: { istEigeneOrganisation: true } });
}
nachherOrganisation = { name: einziger.name, kuerzel: einziger.kuerzel };
}
await tx.einstellung.upsert({
where: { id: "global" },
create: { id: "global", modus: ziel },
update: { modus: ziel },
});
// Der Eintrag haelt den Zustand fest, nicht die rohe Eingabe: sonst stuende
// dort ein kleingeschriebenes Kuerzel, das so nie gespeichert wurde.
await tx.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Betriebsmodus gewechselt",
benutzerId: durchBenutzerId,
vorher: {
modus: vorher,
organisation: { name: organisation.name, kuerzel: organisation.kuerzel },
},
nachher: { modus: ziel, organisation: nachherOrganisation },
},
});
});
}
+235
View File
@@ -0,0 +1,235 @@
import type { Kunde } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
* Regeln der Kundenverwaltung (#008).
*
* Ein Kunde ist die Mandantengrenze: jede kundenbezogene Zeile traegt eine
* `kundeId`, auch im Modus `INTERN`. Dort gibt es genau einen Kunden mit
* `istEigeneOrganisation = true`, der in der Oberflaeche nirgends erscheint.
* Deshalb sind hier zwei Dinge tabu: die eigene Organisation anlegen und sie
* archivieren – beides wuerde den Modus `INTERN` zerlegen.
*
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
*/
export class KundenFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "KundenFehler";
}
}
export type NeuerKunde = {
readonly name: string;
readonly kuerzel: string;
readonly notizen?: string;
};
export type KundenAenderung = {
readonly name?: string;
readonly kuerzel?: string;
/** `null` loescht die Notizen; ein fehlendes Feld laesst sie unangetastet. */
readonly notizen?: string | null;
};
/** 2 bis 6 Zeichen, nur A-Z und 0-9 – dieselbe Form erzwingt `Kunde_kuerzel_form`. */
const KUERZEL_FORM = /^[A-Z0-9]{2,6}$/;
const KUERZEL_ERWARTUNG = "Das Kürzel besteht aus 2 bis 6 Zeichen, nur Großbuchstaben A-Z und Ziffern 0-9.";
/**
* Normalisiert und prueft ein Kuerzel; wirft KundenFehler mit verstaendlicher Meldung.
*
* Getrimmt und in Grossbuchstaben: `mus ` wird zu `MUS`. Die Datenbank haelt
* dieselbe Form ueber die Bedingung `Kunde_kuerzel_form` fest – diese Pruefung
* existiert fuer die bessere Meldung, nicht als einzige Schranke.
*/
export function kuerzelPruefen(roh: string): string {
const kuerzel = roh.trim().toUpperCase();
if (!KUERZEL_FORM.test(kuerzel)) {
throw new KundenFehler(
kuerzel.length === 0
? `Ein Kürzel fehlt. ${KUERZEL_ERWARTUNG}`
: `Kürzel "${kuerzel}" passt nicht. ${KUERZEL_ERWARTUNG}`,
);
}
return kuerzel;
}
function namePruefen(roh: string): string {
const name = roh.trim();
if (name.length === 0) {
throw new KundenFehler("Der Name des Kunden fehlt.");
}
return name;
}
/** Leere Notizen werden zu null, damit "" und null nicht beides herumliegen. */
function notizenNormalisieren(roh: string | null | undefined): string | null {
const notizen = roh?.trim() ?? "";
return notizen.length === 0 ? null : notizen;
}
/**
* Stellt sicher, dass das Kuerzel frei ist – auch gegen archivierte Kunden.
*
* Der Prisma-Fehler des eindeutigen Index wird absichtlich nicht abgefangen: er
* wuesste nicht, wem das Kuerzel gehoert, und genau das braucht die Meldung. Ein
* archivierter Kunde wird mitgenannt, sonst sucht man den Konflikt in einer
* Liste, die ihn gar nicht zeigt.
*/
async function kuerzelFreiPruefen(kuerzel: string, ausserId?: string): Promise<void> {
const vorhanden = await prisma.kunde.findUnique({
where: { kuerzel },
select: { id: true, name: true, archiviert: true },
});
if (!vorhanden || vorhanden.id === ausserId) return;
throw new KundenFehler(
`Das Kürzel ${kuerzel} gehört schon zu "${vorhanden.name}"` +
(vorhanden.archiviert ? " (archiviert)." : "."),
);
}
/**
* Legt einen Kunden an.
*
* `istEigeneOrganisation` wird hier nie gesetzt, auch nicht, wenn es in der
* Eingabe steht: die eigene Organisation entsteht nur im Seed und beim
* Moduswechsel, und ein partieller Index laesst ohnehin nur eine zu.
*/
export async function kundeAnlegen(eingabe: NeuerKunde, durchBenutzerId: string): Promise<Kunde> {
const name = namePruefen(eingabe.name);
const kuerzel = kuerzelPruefen(eingabe.kuerzel);
await kuerzelFreiPruefen(kuerzel);
const kunde = await prisma.kunde.create({
data: {
name,
kuerzel,
notizen: notizenNormalisieren(eingabe.notizen),
istEigeneOrganisation: false,
},
});
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: kunde.id,
aktion: "Kunde angelegt",
benutzerId: durchBenutzerId,
nachher: { name: kunde.name, kuerzel: kunde.kuerzel },
},
});
return kunde;
}
/** Deutsche Beschriftung der aenderbaren Felder, fuer die Aktion im Protokoll. */
const FELD_TEXT = { name: "Name", kuerzel: "Kürzel", notizen: "Notizen" } as const;
/**
* Aendert Name, Kuerzel und Notizen.
*
* `istEigeneOrganisation` ist nicht aenderbar – weder hinein noch heraus. Wer
* die eigene Organisation zum Kunden macht, tut das ueber den Moduswechsel, der
* die ganze Umstellung kennt.
*/
export async function kundeBearbeiten(
id: string,
aenderung: KundenAenderung,
durchBenutzerId: string,
): Promise<Kunde> {
const vorher = await kundeLaden(id);
const name = aenderung.name === undefined ? vorher.name : namePruefen(aenderung.name);
const kuerzel = aenderung.kuerzel === undefined ? vorher.kuerzel : kuerzelPruefen(aenderung.kuerzel);
const notizen = aenderung.notizen === undefined ? vorher.notizen : notizenNormalisieren(aenderung.notizen);
// Nur die wirklich geaenderten Felder landen im Protokoll – so steht dort,
// was jemand getan hat, und nicht, was das Formular alles mitgeschickt hat.
const felder: (keyof typeof FELD_TEXT)[] = [];
const vorherWerte: Record<string, string | null> = {};
const nachherWerte: Record<string, string | null> = {};
if (name !== vorher.name) {
felder.push("name");
vorherWerte["name"] = vorher.name;
nachherWerte["name"] = name;
}
if (kuerzel !== vorher.kuerzel) {
felder.push("kuerzel");
vorherWerte["kuerzel"] = vorher.kuerzel;
nachherWerte["kuerzel"] = kuerzel;
}
if (notizen !== vorher.notizen) {
felder.push("notizen");
vorherWerte["notizen"] = vorher.notizen;
nachherWerte["notizen"] = notizen;
}
// Ohne Unterschied wird nichts geschrieben, sonst fuellt jedes geoeffnete
// Formular das Protokoll mit leeren Aenderungen.
if (felder.length === 0) return vorher;
if (kuerzel !== vorher.kuerzel) {
await kuerzelFreiPruefen(kuerzel, id);
}
const kunde = await prisma.kunde.update({
where: { id },
data: { name, kuerzel, notizen },
});
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: id,
aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`,
benutzerId: durchBenutzerId,
vorher: vorherWerte,
nachher: nachherWerte,
},
});
return kunde;
}
/**
* Archiviert einen Kunden oder gibt ihn wieder frei.
*
* Geloescht wird nichts: an einem Kunden haengen Laeufe, und die sind Archiv.
*/
export async function kundeArchivieren(
id: string,
archiviert: boolean,
durchBenutzerId: string,
): Promise<void> {
const vorher = await kundeLaden(id);
if (archiviert && vorher.istEigeneOrganisation) {
throw new KundenFehler(
"Die eigene Organisation ist im Modus INTERN der einzige Kunde; sie zu archivieren würde " +
"die Anwendung unbenutzbar machen.",
);
}
if (vorher.archiviert === archiviert) return;
await prisma.kunde.update({ where: { id }, data: { archiviert } });
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: id,
aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben",
benutzerId: durchBenutzerId,
vorher: { archiviert: vorher.archiviert },
nachher: { archiviert },
},
});
}
/** Eigene Meldung statt eines Prisma-Fehlers, falls die Kennung ins Leere zeigt. */
async function kundeLaden(id: string): Promise<Kunde> {
const kunde = await prisma.kunde.findUnique({ where: { id } });
if (!kunde) {
throw new KundenFehler("Diesen Kunden gibt es nicht (mehr).");
}
return kunde;
}
+1 -1
View File
@@ -363,7 +363,7 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } }); const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } });
expect(frisch.rolle).toBe("REDAKTEUR"); expect(frisch.rolle).toBe("REDAKTEUR");
const protokoll = await testPrisma.protokoll.findFirst({ const protokoll = await testPrisma.protokoll.findFirst({
where: { entitaetId: ziel.id, aktion: "Rolle geaendert" }, where: { entitaetId: ziel.id, aktion: "Rolle geändert" },
}); });
expect(protokoll?.vorher).toEqual({ rolle: "LESER" }); expect(protokoll?.vorher).toEqual({ rolle: "LESER" });
expect(protokoll?.nachher).toEqual({ rolle: "REDAKTEUR" }); expect(protokoll?.nachher).toEqual({ rolle: "REDAKTEUR" });
+359
View File
@@ -0,0 +1,359 @@
import { beforeEach, describe, expect, it } from "vitest";
import { kundeHolen, kundenListe, kundenZurAuswahl } from "@/server/queries/kunden";
import {
KundenFehler,
kuerzelPruefen,
kundeAnlegen,
kundeArchivieren,
kundeBearbeiten,
type KundenAenderung,
type NeuerKunde,
} from "@/server/services/kunden";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Die Lesemodelle verlangen einen Benutzer; ein Administrator sieht alles. */
const ADMIN = { id: "admin", rolle: "ADMIN" } as const;
/**
* Kundenverwaltung gegen die echte Datenbank (#008).
*
* Zwei Regeln haengen unmittelbar am Betriebsmodus und stehen deshalb hier fest:
* die eigene Organisation entsteht nicht ueber `kundeAnlegen`, und sie laesst
* sich nicht archivieren – sonst waere der Modus `INTERN` ohne Kunden.
*/
/** Urheber fuer die Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
async function adminAnlegen() {
return testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
}
async function eigeneOrganisationAnlegen() {
return testPrisma.kunde.create({
data: { name: "Eigene Firma", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
}
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
async function veroeffentlichteVersionAnlegen() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
return testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
}
describe.runIf(datenbankVerfuegbar)("Kundenverwaltung", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Kuerzel", () => {
it("trimmt und schreibt gross", () => {
expect(kuerzelPruefen("mus")).toBe("MUS");
expect(kuerzelPruefen(" REI ")).toBe("REI");
expect(kuerzelPruefen("mus ")).toBe("MUS");
});
it("weist alles ab, was nicht 2 bis 6 Zeichen A-Z und 0-9 ist", () => {
// "MUeS" waere in Ordnung, ein Umlaut nicht: das Kuerzel steht auf
// Etiketten und in Hostnamen.
for (const falsch of ["X", "ZULANG7", "MU-S", "MU_S", "MÜS", "", " "]) {
expect(() => kuerzelPruefen(falsch)).toThrow(KundenFehler);
}
});
it("sagt in der Meldung, was erwartet wird", () => {
expect(() => kuerzelPruefen("X")).toThrow(/2 bis 6 Zeichen/);
});
it("nimmt das getrimmte Kuerzel beim Anlegen", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: " Musterfirma ", kuerzel: " mus " }, admin.id);
expect(kunde.kuerzel).toBe("MUS");
expect(kunde.name).toBe("Musterfirma");
});
it("weist ein doppeltes Kuerzel ab und benennt den vorhandenen Kunden", async () => {
const admin = await adminAnlegen();
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await expect(kundeAnlegen({ name: "Muster GmbH", kuerzel: "mus" }, admin.id)).rejects.toThrow(
/Musterfirma/,
);
expect(await testPrisma.kunde.count()).toBe(1);
});
it("weist ein doppeltes Kuerzel auch gegen einen archivierten Kunden ab", async () => {
// Sonst waere das Kuerzel zweimal im Umlauf, sobald jemand den alten
// Kunden zurueckholt – und der eindeutige Index kennt kein Archiv.
const admin = await adminAnlegen();
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
await kundeArchivieren(alt.id, true, admin.id);
await expect(kundeAnlegen({ name: "Neue Firma", kuerzel: "ALT" }, admin.id)).rejects.toBeInstanceOf(
KundenFehler,
);
});
it("weist ein doppeltes Kuerzel auch beim Bearbeiten ab", async () => {
const admin = await adminAnlegen();
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const rei = await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
await expect(kundeBearbeiten(rei.id, { kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(
KundenFehler,
);
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: rei.id } });
expect(unveraendert.kuerzel).toBe("REI");
});
});
describe("Name", () => {
it("weist einen leeren Namen ab", async () => {
const admin = await adminAnlegen();
for (const name of ["", " "]) {
await expect(kundeAnlegen({ name, kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
}
expect(await testPrisma.kunde.count()).toBe(0);
});
it("weist einen leeren Namen auch beim Bearbeiten ab", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await expect(kundeBearbeiten(kunde.id, { name: " " }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
});
});
describe("Eigene Organisation", () => {
it("entsteht nie ueber kundeAnlegen, egal was in der Eingabe steht", async () => {
// Sie entsteht nur im Seed und beim Moduswechsel. Die Eingabe wird hier
// absichtlich ueber die Typgrenze hinaus befuellt, wie es ein Aufrufer aus
// einem Formular koennte.
const admin = await adminAnlegen();
const eingabe = { name: "Untergeschoben", kuerzel: "UGS", istEigeneOrganisation: true } as NeuerKunde;
const kunde = await kundeAnlegen(eingabe, admin.id);
expect(kunde.istEigeneOrganisation).toBe(false);
expect(await testPrisma.kunde.count({ where: { istEigeneOrganisation: true } })).toBe(0);
});
it("laesst sich nicht archivieren", async () => {
const admin = await adminAnlegen();
const eigene = await eigeneOrganisationAnlegen();
await expect(kundeArchivieren(eigene.id, true, admin.id)).rejects.toThrow(/einzige Kunde/);
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: eigene.id } });
expect(unveraendert.archiviert).toBe(false);
expect(await testPrisma.protokoll.count({ where: { entitaetId: eigene.id } })).toBe(0);
});
it("laesst sich umbenennen, ohne ihre Rolle zu verlieren", async () => {
const admin = await adminAnlegen();
const eigene = await eigeneOrganisationAnlegen();
const geaendert = await kundeBearbeiten(eigene.id, { name: "Neuer Firmenname" }, admin.id);
expect(geaendert.name).toBe("Neuer Firmenname");
expect(geaendert.istEigeneOrganisation).toBe(true);
});
});
describe("Bearbeiten", () => {
it("aendert istEigeneOrganisation nicht", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const aenderung = { name: "Musterfirma AG", istEigeneOrganisation: true } as KundenAenderung;
const geaendert = await kundeBearbeiten(kunde.id, aenderung, admin.id);
expect(geaendert.name).toBe("Musterfirma AG");
expect(geaendert.istEigeneOrganisation).toBe(false);
});
it("protokolliert das Umbenennen mit Vorher und Nachher", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeBearbeiten(kunde.id, { name: "Musterfirma AG" }, admin.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "geändert" } },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.vorher).toEqual({ name: "Musterfirma" });
expect(eintraege[0]?.nachher).toEqual({ name: "Musterfirma AG" });
expect(eintraege[0]?.benutzerId).toBe(admin.id);
});
it("protokolliert eine Kuerzelaenderung mit Vorher und Nachher", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeBearbeiten(kunde.id, { kuerzel: "mu2" }, admin.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaetId: kunde.id, aktion: { contains: "Kürzel" } },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.vorher).toEqual({ kuerzel: "MUS" });
expect(eintraege[0]?.nachher).toEqual({ kuerzel: "MU2" });
});
it("schreibt nichts, wenn sich nichts aendert", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen(
{ name: "Musterfirma", kuerzel: "MUS", notizen: "Zwei Standorte" },
admin.id,
);
await kundeBearbeiten(
kunde.id,
// Auch die Schreibweise des Kuerzels macht keinen Unterschied, weil es
// vor dem Vergleich normalisiert wird.
{ name: " Musterfirma ", kuerzel: "mus", notizen: "Zwei Standorte" },
admin.id,
);
expect(
await testPrisma.protokoll.count({
where: { entitaetId: kunde.id, aktion: { contains: "geändert" } },
}),
).toBe(0);
});
it("loescht Notizen mit null", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS", notizen: "Alt" }, admin.id);
const geaendert = await kundeBearbeiten(kunde.id, { notizen: null }, admin.id);
expect(geaendert.notizen).toBeNull();
});
it("weist eine unbekannte Kennung mit einer eigenen Meldung ab", async () => {
const admin = await adminAnlegen();
await expect(kundeBearbeiten("gibtsnicht", { name: "Egal" }, admin.id)).rejects.toBeInstanceOf(
KundenFehler,
);
});
});
describe("Archivieren", () => {
it("archiviert und gibt wieder frei, je mit Protokolleintrag", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeArchivieren(kunde.id, true, admin.id);
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(true);
await kundeArchivieren(kunde.id, false, admin.id);
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(false);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "Kunde " } },
orderBy: { erstelltAm: "asc" },
});
// Angelegt, archiviert, wieder freigegeben.
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual([
"Kunde angelegt",
"Kunde archiviert",
"Kunde wieder freigegeben",
]);
expect(eintraege[1]?.vorher).toEqual({ archiviert: false });
expect(eintraege[1]?.nachher).toEqual({ archiviert: true });
expect(eintraege[2]?.nachher).toEqual({ archiviert: false });
});
it("schreibt nichts, wenn der Zustand schon stimmt", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeArchivieren(kunde.id, false, admin.id);
expect(await testPrisma.protokoll.count({ where: { entitaetId: kunde.id } })).toBe(1);
});
});
describe("Lesemodelle", () => {
/** Ein Kunde mit zwei Geraeten, einem Mitarbeiter und einem Lauf. */
async function kundeMitBestandAnlegen(adminId: string) {
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, adminId);
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" } });
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-M2XH4T" } });
await testPrisma.mitarbeiter.create({
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
const version = await veroeffentlichteVersionAnlegen();
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "PC Neuinstallation",
kundeId: kunde.id,
erstelltVonId: adminId,
},
});
return kunde;
}
it("zaehlt Geraete, Mitarbeiter und Laeufe je Kunde", async () => {
const admin = await adminAnlegen();
const mus = await kundeMitBestandAnlegen(admin.id);
await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
const liste = await kundenListe(ADMIN);
const musZeile = liste.find((zeile) => zeile.id === mus.id);
expect(musZeile?.anzahlGeraete).toBe(2);
expect(musZeile?.anzahlMitarbeiter).toBe(1);
expect(musZeile?.anzahlLaeufe).toBe(1);
const reiZeile = liste.find((zeile) => zeile.kuerzel === "REI");
expect(reiZeile?.anzahlGeraete).toBe(0);
expect(reiZeile?.anzahlLaeufe).toBe(0);
});
it("sortiert nicht archivierte zuerst, dann nach Namen", async () => {
const admin = await adminAnlegen();
const zeta = await kundeAnlegen({ name: "Zeta Werke", kuerzel: "ZET" }, admin.id);
const alpha = await kundeAnlegen({ name: "Alpha AG", kuerzel: "ALP" }, admin.id);
const beta = await kundeAnlegen({ name: "Beta GmbH", kuerzel: "BET" }, admin.id);
await kundeArchivieren(alpha.id, true, admin.id);
const liste = await kundenListe(ADMIN, true);
expect(liste.map((zeile) => zeile.id)).toEqual([beta.id, zeta.id, alpha.id]);
});
it("blendet archivierte aus, solange nicht danach gefragt wird", async () => {
const admin = await adminAnlegen();
const mus = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
await kundeArchivieren(alt.id, true, admin.id);
expect((await kundenListe(ADMIN, false)).map((zeile) => zeile.id)).toEqual([mus.id]);
expect((await kundenListe(ADMIN)).map((zeile) => zeile.id)).toEqual([mus.id]);
expect((await kundenListe(ADMIN, true)).map((zeile) => zeile.id)).toContain(alt.id);
});
it("liefert einen einzelnen Kunden mit Zaehlungen, sonst undefined", async () => {
const admin = await adminAnlegen();
const mus = await kundeMitBestandAnlegen(admin.id);
const zeile = await kundeHolen(mus.id, ADMIN);
expect(zeile?.kuerzel).toBe("MUS");
expect(zeile?.anzahlGeraete).toBe(2);
expect(zeile?.istEigeneOrganisation).toBe(false);
expect(await kundeHolen("gibtsnicht", ADMIN)).toBeUndefined();
});
it("nennt im Auswahlfeld keine archivierten Kunden", async () => {
const admin = await adminAnlegen();
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
await kundeArchivieren(alt.id, true, admin.id);
const auswahl = await kundenZurAuswahl(ADMIN);
expect(auswahl.map((eintrag) => eintrag.kuerzel)).toEqual(["MUS"]);
});
});
});
+504
View File
@@ -0,0 +1,504 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
MandantFehler,
betriebsmodus,
eigeneOrganisation,
kundeErmitteln,
kundeZugriffPruefen,
kundenEbeneSichtbar,
sichtbareKundenIds,
} from "@/server/mandant";
import {
EinstellungsFehler,
einstellungenHolen,
einstellungenSpeichern,
modusWechseln,
modusWechselPruefen,
} from "@/server/services/einstellungen";
import { kundenListe, kundenZurAuswahl } from "@/server/queries/kunden";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Betriebsmodus und Mandantengrenze (#008).
*
* Hier haengen die Akzeptanzkriterien, die den ganzen Zwei-Modi-Entwurf tragen:
* dass im Modus INTERN eine mitgeschickte kundeId verworfen wird, dass der
* Wechsel alle Daten mitnimmt und dass der Rueckweg bei zwei Kunden abgelehnt
* wird.
*
* `betriebsmodus()` benutzt Reacts `cache`. Ausserhalb einer Anfrage merkt das
* nichts – nachgeprueft –, die Tests beeinflussen sich also nicht gegenseitig.
*/
const UMGEBUNG = { ...process.env };
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const organisation = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
return { organisation, admin };
}
describe.runIf(datenbankVerfuegbar)("Betriebsmodus und Mandantengrenze", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Modus lesen", () => {
it("ist INTERN, solange nichts eingestellt ist", async () => {
// Der sichere Standard: keine Kundenebene, nichts faellt auseinander.
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
});
it("liest den eingestellten Modus aus der Datenbank", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
expect(await betriebsmodus()).toBe(Betriebsmodus.DIENSTLEISTUNG);
});
it("zeigt die Kundenebene nur im Modus Dienstleistung", async () => {
await grundbestand(Betriebsmodus.INTERN);
expect(await kundenEbeneSichtbar()).toBe(false);
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
expect(await kundenEbeneSichtbar()).toBe(true);
});
it("sagt deutlich Bescheid, wenn die eigene Organisation fehlt", async () => {
await expect(eigeneOrganisation()).rejects.toThrow(/pnpm db:seed/);
});
});
describe("kundeErmitteln", () => {
/** Ein Administrator – die Sichtbarkeit schraenkt ihn nie ein. */
const ADMIN = { id: "admin", rolle: "ADMIN" } as const;
it("verwirft im Modus Intern eine mitgeschickte fremde kundeId", async () => {
// Das ist die Regel, die den Zwei-Modi-Entwurf sicher macht: im Modus
// INTERN gibt es keine Kundenwahl, also ist auch nichts zu entscheiden.
const { organisation } = await grundbestand(Betriebsmodus.INTERN);
const fremder = await testPrisma.kunde.create({
data: { name: "Fremdfirma", kuerzel: "FREMD" },
});
expect(await kundeErmitteln(fremder.id, ADMIN)).toBe(organisation.id);
expect(await kundeErmitteln("gibt-es-gar-nicht", ADMIN)).toBe(organisation.id);
expect(await kundeErmitteln(null, ADMIN)).toBe(organisation.id);
expect(await kundeErmitteln(undefined, ADMIN)).toBe(organisation.id);
});
it("verlangt im Modus Dienstleistung eine Wahl", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(kundeErmitteln(undefined, ADMIN)).rejects.toBeInstanceOf(MandantFehler);
await expect(kundeErmitteln(" ", ADMIN)).rejects.toThrow(/Kunde gewählt/);
});
it("weist im Modus Dienstleistung einen unbekannten Kunden ab", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(kundeErmitteln("gibt-es-nicht", ADMIN)).rejects.toThrow(/gibt es nicht/);
});
it("weist einen archivierten Kunden ab", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const archiviert = await testPrisma.kunde.create({
data: { name: "Alte Firma", kuerzel: "ALT", archiviert: true },
});
await expect(kundeErmitteln(archiviert.id, ADMIN)).rejects.toThrow(/archiviert/);
});
it("nimmt im Modus Dienstleistung einen gueltigen Kunden an", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
expect(await kundeErmitteln(mus.id, ADMIN)).toBe(mus.id);
});
it("weist einen Kunden ab, an den der Benutzer nicht gebunden ist", async () => {
// Die Sichtbarkeit steht in kundeErmitteln selbst, damit kein Aufrufer sie
// vergessen kann.
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
expect(await kundeErmitteln(meiner.id, techniker)).toBe(meiner.id);
await expect(kundeErmitteln(fremder.id, techniker)).rejects.toThrow(/Berechtigung/);
});
});
describe("Sichtbare Kunden", () => {
async function technikerMitBindung() {
const { organisation } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
await testPrisma.kunde.create({ data: { name: "Baeckerei", kuerzel: "REI" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: mus.id },
});
return { organisation, mus, techniker };
}
it("schraenkt ohne gesetztes Praefix niemanden ein", async () => {
// Eine leere Bindung darf nicht als "sieht nichts" gelesen werden.
const { techniker } = await technikerMitBindung();
expect(await sichtbareKundenIds(techniker)).toBeUndefined();
});
it("schraenkt Techniker und Leser auf ihre Kunden ein", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const { mus, techniker } = await technikerMitBindung();
expect(await sichtbareKundenIds(techniker)).toEqual([mus.id]);
});
it("schraenkt Administrator und Redakteur nie ein", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await technikerMitBindung();
for (const rolle of ["ADMIN", "REDAKTEUR"] as const) {
expect(await sichtbareKundenIds({ id: "egal", rolle })).toBeUndefined();
}
});
it("schraenkt im Modus Intern nie ein", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.INTERN);
const leser = await testPrisma.benutzer.create({
data: { email: "l@firma.example", name: "Leser", rolle: "LESER" },
});
expect(await sichtbareKundenIds(leser)).toBeUndefined();
});
it("weist den Zugriff auf einen fremden Kunden ab", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const { organisation, mus, techniker } = await technikerMitBindung();
await expect(kundeZugriffPruefen(mus.id, techniker)).resolves.toBeUndefined();
await expect(kundeZugriffPruefen(organisation.id, techniker)).rejects.toBeInstanceOf(MandantFehler);
});
});
describe("Wechsel nach Dienstleistung", () => {
it("nimmt alle Daten auf den ersten Kunden mit", async () => {
const { organisation, admin } = await grundbestand(Betriebsmodus.INTERN);
await testPrisma.geraet.create({
data: {
kundeId: organisation.id,
tagCode: "LZ-7K3F9Q",
hostname: "PC-001",
registriertAm: new Date(),
},
});
await testPrisma.mitarbeiter.create({
data: {
kundeId: organisation.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
},
});
await testPrisma.abteilung.create({
data: { kundeId: organisation.id, name: "Buchhaltung" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "PC-001 neu aufsetzen",
kundeId: organisation.id,
erstelltVonId: admin.id,
},
});
await modusWechseln(
Betriebsmodus.DIENSTLEISTUNG,
{ name: "Musterfirma GmbH", kuerzel: "mus" },
admin.id,
);
expect(await betriebsmodus()).toBe(Betriebsmodus.DIENSTLEISTUNG);
const danach = await testPrisma.kunde.findUniqueOrThrow({ where: { id: organisation.id } });
expect(danach.name).toBe("Musterfirma GmbH");
// Das Kuerzel wird in Grossbuchstaben normalisiert.
expect(danach.kuerzel).toBe("MUS");
// Es bleibt dieselbe Zeile, deshalb haengt nichts um.
expect(await testPrisma.geraet.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.abteilung.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.lauf.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.kunde.count()).toBe(1);
});
it("weist ein ungueltiges Kuerzel mit einer verstaendlichen Meldung ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
for (const kuerzel of ["X", "ZULANG7", "MU-S"]) {
await expect(
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Firma", kuerzel }, admin.id),
).rejects.toThrow(/2 bis 6/);
}
// Nach einem abgelehnten Wechsel steht der Modus unveraendert.
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
});
it("weist ein bereits vergebenes Kuerzel ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
await testPrisma.kunde.create({ data: { name: "Andere", kuerzel: "MUS" } });
await expect(
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Firma", kuerzel: "MUS" }, admin.id),
).rejects.toThrow(/gehört bereits/);
});
it("behaelt Name und Kuerzel, wenn nichts angegeben wird", async () => {
const { organisation, admin } = await grundbestand(Betriebsmodus.INTERN);
await modusWechseln(Betriebsmodus.DIENSTLEISTUNG, {}, admin.id);
const danach = await testPrisma.kunde.findUniqueOrThrow({ where: { id: organisation.id } });
expect(danach.name).toBe(organisation.name);
expect(danach.kuerzel).toBe(organisation.kuerzel);
});
it("haelt den Wechsel im Protokoll fest", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
await modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Firma", kuerzel: "FIR" }, admin.id);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Betriebsmodus gewechselt" },
});
expect(eintrag.benutzerId).toBe(admin.id);
expect(eintrag.vorher).toMatchObject({ modus: "INTERN" });
expect(eintrag.nachher).toMatchObject({ modus: "DIENSTLEISTUNG" });
});
});
describe("Wechsel zurueck nach Intern", () => {
it("wird bei zwei nicht archivierten Kunden abgelehnt", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await testPrisma.kunde.create({ data: { name: "Zweiter Kunde", kuerzel: "ZWEI" } });
const pruefung = await modusWechselPruefen(Betriebsmodus.INTERN);
expect(pruefung.moeglich).toBe(false);
expect(pruefung.grund).toMatch(/genau einem nicht archivierten Kunden/);
await expect(modusWechseln(Betriebsmodus.INTERN, {}, admin.id)).rejects.toBeInstanceOf(
EinstellungsFehler,
);
expect(await betriebsmodus()).toBe(Betriebsmodus.DIENSTLEISTUNG);
});
it("gelingt, sobald die uebrigen Kunden archiviert sind", async () => {
const { organisation, admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const zweiter = await testPrisma.kunde.create({
data: { name: "Zweiter Kunde", kuerzel: "ZWEI" },
});
await testPrisma.kunde.update({ where: { id: zweiter.id }, data: { archiviert: true } });
await modusWechseln(Betriebsmodus.INTERN, {}, admin.id);
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
expect((await eigeneOrganisation()).id).toBe(organisation.id);
// Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.
const archivierter = await testPrisma.kunde.findUniqueOrThrow({ where: { id: zweiter.id } });
expect(archivierter.archiviert).toBe(true);
});
it("haelt auch den Rueckweg im Protokoll fest", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await modusWechseln(Betriebsmodus.INTERN, {}, admin.id);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Betriebsmodus gewechselt" },
});
expect(eintrag.vorher).toMatchObject({ modus: "DIENSTLEISTUNG" });
expect(eintrag.nachher).toMatchObject({ modus: "INTERN" });
});
it("lehnt den Wechsel in den bereits eingestellten Modus ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
const pruefung = await modusWechselPruefen(Betriebsmodus.INTERN);
expect(pruefung.moeglich).toBe(false);
await expect(modusWechseln(Betriebsmodus.INTERN, {}, admin.id)).rejects.toThrow(/bereits/);
});
});
describe("Der Wechsel ist unteilbar", () => {
it("nimmt den Moduswechsel zurueck, wenn das Protokoll nicht geschrieben werden kann", async () => {
// Frueher stand der Protokoll-Eintrag hinter der Transaktion: schlug er
// fehl, war der Modus schon gewechselt und der Vorgang stand nirgends.
await grundbestand(Betriebsmodus.INTERN);
await expect(
modusWechseln(
Betriebsmodus.DIENSTLEISTUNG,
{ name: "Firma", kuerzel: "FIR" },
"diesen-benutzer-gibt-es-nicht",
),
).rejects.toThrow();
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
expect(await testPrisma.protokoll.count()).toBe(0);
// Auch Name und Kuerzel duerfen nicht halb geaendert sein.
const organisation = await testPrisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
});
expect(organisation.name).toBe("Eigene Organisation");
expect(organisation.kuerzel).toBe("EIGEN");
});
it("laesst von zwei gleichzeitigen Wechseln nur einen durch", async () => {
// Ohne Schloss liefen beide durch, mit zwei Protokolleintraegen, die beide
// INTERN als Vorzustand behaupteten.
const { admin } = await grundbestand(Betriebsmodus.INTERN);
const ergebnisse = await Promise.allSettled([
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Erster", kuerzel: "AAA" }, admin.id),
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Zweiter", kuerzel: "BBB" }, admin.id),
]);
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
expect(await testPrisma.protokoll.count({ where: { aktion: "Betriebsmodus gewechselt" } })).toBe(1);
});
});
describe("Lesemodelle wenden die Mandantensicht selbst an", () => {
it("zeigt einem gebundenen Techniker nur seine Kunden", async () => {
// Ein vergessener Filter waere hier kein Schoenheitsfehler, deshalb steht
// die Einschraenkung im Lesemodell und nicht beim Aufrufer.
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
const fuerTechniker = await kundenListe(techniker, true);
expect(fuerTechniker.map((z) => z.kuerzel)).toEqual(["MEIN"]);
expect((await kundenZurAuswahl(techniker)).map((z) => z.kuerzel)).toEqual(["MEIN"]);
// Der Administrator sieht weiterhin alles.
const fuerAdmin = await kundenListe({ id: "admin", rolle: "ADMIN" }, true);
expect(fuerAdmin).toHaveLength(3);
});
it("zeigt einem ungebundenen Techniker nichts, statt versehentlich alles", async () => {
// Der Unterschied zwischen "keine Einschraenkung" und "sieht nichts" ist
// der heikelste Teil dieser Regel.
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
expect(await kundenListe(techniker, true)).toEqual([]);
});
it("schraenkt ohne Praefix niemanden ein", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
expect(await kundenListe(techniker, true)).toHaveLength(2);
});
});
describe("Wechsel-Auskunft fuer den Bestaetigungsdialog", () => {
it("nennt die Folgen und fragt nach Name und Kuerzel", async () => {
await grundbestand(Betriebsmodus.INTERN);
const pruefung = await modusWechselPruefen(Betriebsmodus.DIENSTLEISTUNG);
expect(pruefung.moeglich).toBe(true);
expect(pruefung.brauchtAngaben).toBe(true);
expect(pruefung.folgen.length).toBeGreaterThan(2);
expect(pruefung.folgen.join(" ")).toMatch(/erste[nr] Kunden/);
});
it("braucht fuer den Rueckweg keine Angaben", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const pruefung = await modusWechselPruefen(Betriebsmodus.INTERN);
expect(pruefung.moeglich).toBe(true);
expect(pruefung.brauchtAngaben).toBe(false);
});
});
describe("Einstellungen", () => {
it("liefert Modus, Regeln und die eigene Organisation", async () => {
await grundbestand(Betriebsmodus.INTERN);
const einstellungen = await einstellungenHolen();
expect(einstellungen.modus).toBe(Betriebsmodus.INTERN);
expect(einstellungen.eigeneOrganisation.kuerzel).toBe("EIGEN");
expect(einstellungen.namensregeln).toEqual({});
});
it("speichert Namensregeln und Sperr-OU und protokolliert es", async () => {
const { admin } = await grundbestand();
await einstellungenSpeichern(
{
namensregeln: { upn: "{vorname}.{nachname}@firma.de", sam: "{v}{nachname}" },
gesperrteOu: "OU=Gesperrt,DC=firma,DC=local",
},
admin.id,
);
const danach = await einstellungenHolen();
expect(danach.namensregeln.upn).toBe("{vorname}.{nachname}@firma.de");
expect(danach.gesperrteOu).toBe("OU=Gesperrt,DC=firma,DC=local");
expect(await testPrisma.protokoll.count({ where: { aktion: "Einstellungen geändert" } })).toBe(1);
});
it("benennt die eigene Organisation um", async () => {
const { organisation, admin } = await grundbestand();
await einstellungenSpeichern({ eigeneOrganisationName: " Wilde IT " }, admin.id);
const danach = await testPrisma.kunde.findUniqueOrThrow({ where: { id: organisation.id } });
expect(danach.name).toBe("Wilde IT");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(
einstellungenSpeichern({ eigeneOrganisationName: " " }, admin.id),
).rejects.toBeInstanceOf(EinstellungsFehler);
});
it("schreibt nichts, wenn sich nichts aendert", async () => {
const { admin } = await grundbestand();
await einstellungenSpeichern({ namensregeln: {}, gesperrteOu: null }, admin.id);
expect(await testPrisma.protokoll.count()).toBe(0);
});
it("uebersteht einen unbrauchbaren Json-Inhalt in den Namensregeln", async () => {
// Ein von Hand verbogener Wert darf die Einstellungsseite nicht sprengen.
await grundbestand();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: { upn: 42, unsinn: true } },
});
const einstellungen = await einstellungenHolen();
expect(einstellungen.namensregeln).toEqual({});
});
});
});
+4 -4
View File
@@ -25,9 +25,9 @@ describe("Seitenleiste nach Betriebsmodus", () => {
describe("Seitenleiste nach Rolle", () => { describe("Seitenleiste nach Rolle", () => {
it("zeigt Admin nur dem Administrator", () => { it("zeigt Admin nur dem Administrator", () => {
expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin/benutzer"); expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin");
for (const rolle of ["REDAKTEUR", "TECHNIKER", "LESER"] as const) { for (const rolle of ["REDAKTEUR", "TECHNIKER", "LESER"] as const) {
expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin/benutzer"); expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin");
} }
}); });
@@ -36,13 +36,13 @@ describe("Seitenleiste nach Rolle", () => {
expect(pfade).toContain("/"); expect(pfade).toContain("/");
expect(pfade).toContain("/laeufe"); expect(pfade).toContain("/laeufe");
expect(pfade).toContain("/archiv"); expect(pfade).toContain("/archiv");
expect(pfade).not.toContain("/admin/benutzer"); expect(pfade).not.toContain("/admin");
}); });
it("blendet ohne bekannte Rolle alles Rechtegebundene aus", () => { it("blendet ohne bekannte Rolle alles Rechtegebundene aus", () => {
// So sieht die Huelle aus, solange niemand angemeldet ist. // So sieht die Huelle aus, solange niemand angemeldet ist.
const pfade = navigationFuer("INTERN", () => false).map((e) => e.pfad); const pfade = navigationFuer("INTERN", () => false).map((e) => e.pfad);
expect(pfade).not.toContain("/admin/benutzer"); expect(pfade).not.toContain("/admin");
}); });
}); });