Betriebsmodus, Kundenverwaltung und Einstellungen #22

Merged
florian.wilde merged 1 commits from feat/8-betriebsmodus into main 2026-09-25 05:03:12 +00:00
43 changed files with 2729 additions and 53 deletions
Showing only changes of commit 8e67591912 - Show all commits
+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");
}); });
}); });