diff --git a/.env.example b/.env.example index 55241b2..abc53a4 100644 --- a/.env.example +++ b/.env.example @@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true # Nach dem ersten Login das Passwort in der Oberfläche ändern. SEED_ADMIN_EMAIL=admin@beispielwehr.de SEED_ADMIN_PASSWORT=Feuerwehr2026 +# Das Konto der Kreisebene aus dem Beispielbestand (Phase 10): Mitglied in der +# eigenen Wehr, Aufsicht in der Nachbarwehr. Vorgabe leitet sich aus der +# Wehrkennung ab, also kbi@ff-musterhausen.de. +# SEED_KBI_EMAIL= +# SEED_KBI_PASSWORT=Feuerwehr2026 # bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo. AUTH_BCRYPT_ROUNDS=12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 11321de..fd467db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,130 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). +## [0.11.0] — 2026-09-05 + +Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die +zusieht — und eine Verwaltung für den, der die Installation betreibt. + +**Das Anmeldekonto ist von der Personenakte gelöst.** Bis hierher war die Akte +zugleich das Konto: Passwort-Hash und OIDC-Subject hingen an `person`, und +`person` gehört genau einer Wehr. Ein Mensch konnte damit nicht zu zwei Wehren +gehören, ohne zwei Passwörter zu haben — und die Anmeldung per E-Mail war +mehrdeutig, sobald dieselbe Adresse in zwei Wehren stand, weil die +Eindeutigkeit nur je Organisation galt. + +**Das Konto trägt keine Mandantenkennung — und kann keine tragen.** Es ist der +Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. Geschützt wird es +deshalb wie `Berechtigung` aus Phase 0: über Rechte statt über Row Level +Security. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst, +sondern seine `Mitgliedschaft` — und die trägt `organisationId` wie jede +andere Tabelle. + +**Die Kreisebene sieht und ändert nichts.** Ihre Rechte werden nicht +aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und sonst nichts. +Eine Liste von Hand wäre bei der nächsten Phase veraltet, und zwar unbemerkt. + +### Hinzugefügt + +**Datenmodell** + +- `Benutzer` — das Anmeldekonto. Global eindeutige E-Mail, ein Passwort, ein + OIDC-Subject. Ohne `organisationId`, mit Begründung im Schema. +- `Mitgliedschaft` — die Zugehörigkeit eines Kontos zu einer Wehr, als + `MITGLIED` (mit Personenakte) oder `AUFSICHT` (ohne). +- `Person` verliert die Anmeldefelder. Der Passwort-Hash kann damit aus einer + Personenabfrage gar nicht mehr herausfallen — die drei Stellen, die ihn + bisher vorsichtshalber abgeschnitten haben, sind entfallen. + +**Was die Datenbank durchsetzt** (Migrationen `20260905090000` und +`20260905091500`) + +- RLS für `mitgliedschaft` wie für jede andere Tabelle. Eine Wehr sieht damit + auch nicht, in welchen anderen ein Konto noch geführt wird. +- Trigger `mitgliedschaft_akte_gleiche_wehr` — eine Zuordnung, deren + Personenakte in einer anderen Wehr liegt, wird abgelehnt. Ein Fremdschlüssel + prüft nur, *dass* es die Akte gibt, nicht *wo* sie liegt; ohne den Trigger + ließe sich ein Konto über eine fremde Akte in eine Wehr hängen. +- Trigger `benutzer_kennung_unveraenderlich` — E-Mail, OIDC-Subject und das + Verwaltermerkmal lassen sich nicht aus einer Wehr heraus ändern. +- `REVOKE INSERT, DELETE ON benutzer` für die Anwendungsrolle. +- CHECKs: `MITGLIED` braucht eine Akte, `AUFSICHT` darf keine haben; ein Konto + braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine. + +**Die Umstellung ist datenerhaltend.** Jede Person mit Anmeldedaten bekommt +ein Konto und eine Mitgliedschaft, bevor die alten Spalten fallen. Wer schon +mit derselben Adresse in zwei Wehren stand, bekommt **ein** Konto mit **zwei** +Mitgliedschaften — genau das, was vorher nicht möglich war. Eine Prüfung am +Ende bricht die Migration ab, statt einen halben Zustand zu hinterlassen. + +**Oberfläche** + +- **Wehrwähler** in der Seitenleiste. Er erscheint nur bei mehr als einer Wehr + — ein Auswahlfeld mit genau einem Eintrag ist eine Frage ohne + Antwortmöglichkeit. +- Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute. +- Ein dauerhafter Hinweis, wenn man eine Wehr als Kreisaufsicht sieht — nicht + als kurz aufblitzende Meldung. +- **`/wehren`** für den Verwalter: Wehren anlegen (samt Systemrollen), Konten + führen, Zuordnungen vergeben und beenden. Jede Zuordnung wird in der + betroffenen Wehr protokolliert, nicht beim Verwalter — sonst hätte der + Betreiber einen Weg, sich still in fremde Daten zu setzen. + +**Rechte** + +- Neue Rolle `AUFSICHT` — bewusst **nicht** im Prisma-Enum: sie wird nicht + vergeben, sondern ergibt sich aus der Mitgliedschaftsart. +- Ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden: `kasse.lesen` (die + Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht), + `person.export`, `system.auditlog`, `ki.protokoll`. +- `istVerwalter` als Merkmal des Kontos, nicht als Recht. + +**Tests** + +- 19 neue Unit-Tests, davon 11 gegen eine echte Datenbank: Doppelmitgliedschaft, + der Trigger gegen wehrfremde Akten, die CHECKs und die Trennung der + Zuordnungen über RLS. +- 13 E2E-Tests: kein Wähler bei einer Wehr, Wechsel in die betreute und + zurück, die Mitglieder der anderen Wehr, die verschlossene Kasse, und dass + der Administrator die Umstellung überlebt hat. + +**Seed** + +- Eine zweite, kleine Wehr je Mandant und ein Konto, das in der einen Mitglied + und in der anderen Kreisaufsicht ist. Ohne sie ließe sich die Phase nicht + ausprobieren — und eine Mandantentrennung, die nie zwei Mandanten hatte, ist + keine geprüfte. + +### Geändert + +- Eine Sitzung ohne Personenakte ist gültig. Bis hierher galt genau das als + „nicht angemeldet"; für die Kreisaufsicht, die in der betreuten Wehr keine + Akte hat, hätte jeder Aufruf auf der Anmeldeseite geendet. +- `Sitzung.personId` ist nullbar. Der Typechecker hat damit die fünf Stellen + gezeigt, an denen eine Aufsichtssitzung nichts verloren hat — Anmeldemarke, + Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz. +- Die Mitgliederansicht zeigt statt eines ungenutzten Zeitstempels, ob jemand + überhaupt ein Konto hat. Das ist die nützlichere Auskunft: ohne Konto kommt + er nicht in FLORIAN hinein. + +### Behoben + +- **Ein Testaufbau war nicht wiederholbar.** `ki-datenbank.test.ts` legte Wehr + und Person idempotent an, die Wiki-Seite aber nicht — blieb aus einem + abgebrochenen Lauf etwas stehen, scheiterte der ganze Datei-Lauf an einer + Eindeutigkeit, die mit dem Geprüften nichts zu tun hat. +- **`db:test-reset` ließ die Nachbarwehr stehen** und der Seed hielt daraufhin + alles für erledigt — die Mitgliedschaft in der neu angelegten Testwehr + entstand nie wieder. Der Reset nimmt sie jetzt mit, und der Seed prüft + Nachbarwehr und Kreiskonto getrennt statt mit einem gemeinsamen Ausstieg. +- **Der Fehlversuch-Test sperrte das gemeinsame Testkonto.** Fünf Fehlversuche + sperren ein Konto für fünf Minuten; bei zwei Projekten und Wiederholungen + kam das im vollen Lauf zusammen, und fremde Testdateien fielen mit + „Anmeldung fehlgeschlagen“ aus. Er nimmt jetzt eine Adresse, die es nicht + gibt — die Meldung muss ohnehin dieselbe sein. + +--- + ## [0.10.0] — 2026-09-04 Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an diff --git a/README.md b/README.md index 74fbd1e..b8a8d20 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 7 — Benachrichtigungen und Termine | fertig | | Phase 8 — Lokale KI | fertig | | Phase 9 — Kassenverwaltung | fertig | +| Phase 10 — Mandantenfähigkeit | fertig | Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md). @@ -1305,6 +1306,147 @@ läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs. --- +## Phase 10 — Mandantenfähigkeit + +Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine +Kreisebene, die zusieht. + +Datenmodell und Row Level Security tragen seit Phase 0 mehrere Wehren — jede +Tabelle hat `organisationId`, jede Abfrage läuft im Mandantenkontext. Was +fehlte, war alles darüber: eine Anmeldung, die zu mehr als einer Wehr gehören +kann, ein Weg zum Umschalten, und eine Verwaltung für den, der den Server +betreibt. + +### Konto und Akte sind nicht dasselbe + +Bis Phase 9 war die Personenakte zugleich das Anmeldekonto: Passwort-Hash und +OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. Daraus +folgten zwei Dinge, die niemand wollte: + +- Wer in zwei Wehren geführt wurde, brauchte **zwei Passwörter**, die + auseinanderlaufen können. +- Die Anmeldung per E-Mail war **mehrdeutig**, sobald dieselbe Adresse in zwei + Wehren stand — die Eindeutigkeit galt nur je Organisation, und gefunden + wurde, was die Datenbank zuerst hergab. + +Seit Phase 10: + +| Tabelle | Was sie ist | Mandant | +|---|---|---| +| `Benutzer` | das Anmeldekonto — E-Mail, Passwort, OIDC | **keiner** | +| `Mitgliedschaft` | Zugehörigkeit eines Kontos zu einer Wehr | `organisationId` | +| `Person` | die Akte in einer Wehr — Dienstgrad, Qualifikationen, Einsätze | `organisationId` | + +**`Benutzer` trägt bewusst keine Mandantenkennung** und kann keine tragen: sie +ist ja der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift +dort deshalb nicht. Geschützt wird sie stattdessen über Rechte — dieselbe +Lösung wie bei `Berechtigung` aus Phase 0 und aus demselben Grund: eine +Tabelle, die über allen Mandanten steht, gehört nicht in die Hand der +Anwendungsrolle. Was ein Konto in einer Wehr darf, entscheidet nicht es +selbst, sondern seine `Mitgliedschaft`. + +Ein angenehmer Nebeneffekt: der Passwort-Hash kann aus einer Personenabfrage +gar nicht mehr herausfallen. Die drei Stellen, die ihn bisher vorsichtshalber +abgeschnitten haben, sind entfallen — eine Sorgfaltsmaßnahme ist durch eine +Struktur ersetzt. + +### Die Wehr wechseln + +Der Wähler steht in der Seitenleiste unter dem Namen der Wehr — **aber nur, +wenn es etwas zu wählen gibt.** Für die allermeisten gibt es das nie, und ein +Auswahlfeld mit genau einem Eintrag ist keine Hilfe, sondern eine Frage ohne +Antwortmöglichkeit. + +Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute: wer eine +Wehr führt und acht andere beaufsichtigt, will in seiner landen. + +Technisch: die Wehr steht im Sitzungstoken, also wird beim Wechsel ein neues +ausgestellt. Was der Browser dabei schickt, ist ein **Wunsch** — geprüft wird +serverseitig gegen die Mitgliedschaften des Kontos. Eine fremde Kennung zu +nennen bringt nichts, weil es dazu keine Mitgliedschaft gibt. + +### Die Kreisebene + +Kreisbrandrat und Kreisbrandinspektion gehören den betreuten Wehren nicht an. +Sie bekommen deshalb dort **keine Personenakte**, tauchen in keiner +Mitgliederliste auf und können nichts ändern. + +Ihre Rechte werden nicht aufgezählt, sondern **gefiltert**: alles, was +`.lesen` heißt, und sonst nichts. Eine von Hand gepflegte Liste wäre bei der +nächsten Phase veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte +mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte +automatisch und neue Schreibrechte nie. + +Vier Rechte sind ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden: + +| Recht | Warum nicht | +|---|---| +| `kasse.lesen` | Die Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. | +| `person.export` | Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt. | +| `system.auditlog` | Wer wann was getan hat, bleibt innerhalb der Wehr. | +| `ki.protokoll` | Dasselbe. | + +Der **Atemschutz** steht dagegen bewusst drin: ob ein Träger tauglich ist, ist +genau die Frage, für die es eine Kreisebene gibt. + +Wer als Aufsicht unterwegs ist, sieht dauerhaft einen Hinweis darauf — nicht +als kurz aufblitzende Meldung, sondern als Zeile über der Ansicht. Man soll +nicht erst merken, in welcher Rolle man ist, wenn ein Knopf fehlt. + +### Doppelmitgliedschaft + +Ein Konto, mehrere Zugehörigkeiten. In Bayern der Regelfall bei allem, was +über die eigene Wehr hinausgeht: aktiv in A, Ehrenmitglied in B, oder +Kommandant der einen und Kreisbrandinspektor für sechs weitere. + +Zwei Zusicherungen setzt die Datenbank durch: + +- **Ein Konto gehört einer Wehr höchstens einmal an.** +- **Eine Personenakte hängt an höchstens einem Konto.** Sonst wäre nicht + entscheidbar, wer unter ihrem Namen gehandelt hat. + +Und eine dritte, die weniger offensichtlich ist: die Akte muss zu **derselben +Wehr** gehören wie die Zuordnung. Ein Fremdschlüssel prüft nur, *dass* es die +Akte gibt, nicht *wo* sie liegt — ohne diese Prüfung ließe sich ein Konto über +eine Akte aus Wehr A in Wehr B hängen, und die Sitzung stünde dort mit einer +Personenkennung da, die niemandem gehört. Ein Trigger lehnt das ab. + +### Wehren und Konten verwalten + +Unter **`/wehren`**, und nur für den, der die Installation betreibt. + +Der Zugang hängt an einem Merkmal des Kontos (`istVerwalter`), **nicht an +einem Recht**. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein +Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre genau die +Hintertür, die diese Phase vermeiden soll. + +Was dort geht: + +- Eine Wehr anlegen — sie bekommt sofort ihre Systemrollen samt Rechten. Ein + Konto bekommt sie nicht: eine Wehr anzulegen und jemanden hineinzusetzen + sind zwei Entscheidungen, und die zweite trifft meist jemand anders. +- Ein Konto anlegen — ohne Wehr. Die kommt über die Zuordnung. +- Zuordnen: als Mitglied mit Personenakte oder als Kreisaufsicht ohne. + +**Jede Zuordnung wird in der betroffenen Wehr protokolliert**, nicht beim +Verwalter. Sonst hätte der Betreiber einen Weg, sich still in fremde Daten zu +setzen. + +**Keine zusätzlichen Umgebungsvariablen.** + +### Was bewusst fehlt + +**Wehrübergreifende Auswertungen.** Eine Zahl über mehrere Wehren hinweg +braucht eine abgestimmte Definition — was zählt als Einsatz, was als Übung, +wie wird eine Doppelmitgliedschaft gezählt. Ohne die ist eine solche Zahl +schlimmer als keine, weil sie benutzt wird. Die Aufsicht sieht jede Wehr +einzeln, und das reicht für die Fragen, die tatsächlich gestellt werden. + +**Ein Einladungsverfahren.** Passwörter werden vom Verwalter vergeben und +weitergegeben. FLORIAN verschickt keine. + +--- + ## Befehle ```bash diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 922c045..a2b1796 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,6 +1,6 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 9 (Version 0.10.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. --- @@ -1986,7 +1986,138 @@ Dezimaltrennzeichen, UTF-8 mit Kennung. --- -## 54. Rollen und was sie dürfen +## 54. Mehrere Wehren auf einem Server + +Ein FLORIAN kann mehrere Wehren führen — eine Gemeinde mit drei Ortswehren, +ein Landkreis mit zwanzig. Die Wehren sehen einander dabei **nicht**. Jede +Abfrage ist an eine Wehr gebunden, und zwar in der Datenbank selbst, nicht im +Programm. + +### Konto und Personenakte + +Zwei verschiedene Dinge, und der Unterschied ist der Kern dieses Kapitels: + +| | Was es ist | +|---|---| +| **Konto** | Womit Sie sich anmelden. E-Mail und Passwort. Gehört keiner Wehr. | +| **Personenakte** | Ihr Eintrag in einer Wehr: Dienstgrad, Qualifikationen, Einsätze. | +| **Zuordnung** | Verbindet beides — je Wehr eine. | + +Wer nur zu einer Wehr gehört, merkt davon nichts: ein Konto, eine Akte, eine +Zuordnung, und alles sieht aus wie vorher. + +Wer zu **zwei** Wehren gehört — aktiv in der einen, Ehrenmitglied in der +anderen —, hat trotzdem nur **ein** Passwort. Zwei Akten, ein Konto. + +### Die Wehr wechseln + +Steht Ihr Konto bei mehreren Wehren, wird der Name der Wehr in der +Seitenleiste zu einem Auswahlfeld. Ein Klick darauf, und alle Wehren stehen +untereinander — mit dem Hinweis, ob Sie dort Mitglied oder Kreisaufsicht sind. + +Nach der Anmeldung sind Sie immer in **Ihrer eigenen** Wehr, nicht in einer +betreuten. + +Nach dem Wechsel wird alles neu geladen. Was Sie danach sehen, gehört zur +neuen Wehr — es gibt keinen Zustand, in dem Daten der einen unter dem Namen +der anderen stehen. + +--- + +## 55. Kreisbrandrat und Kreisbrandinspektion + +Die Kreisebene bekommt Zugang zu den betreuten Wehren, ohne ihnen anzugehören. + +**Sie sieht und ändert nichts.** Das ist keine Einstellung, die jemand +versehentlich anders setzen kann: eine Aufsichtszuordnung bekommt genau die +Leserechte und keines der Schreibrechte, und zwar automatisch auch für Module, +die es noch gar nicht gibt. + +Wer als Aufsicht in einer Wehr unterwegs ist, sieht das durchgehend als Zeile +über der Ansicht. + +### Was die Aufsicht sieht + +Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze, Übungen, +Qualifikationen, Termine, Wiki, Karte. + +Der **Atemschutz** ist dabei der wichtigste Punkt: ob ein Träger tauglich ist, +ist genau die Frage, für die es eine Kreisebene gibt. + +### Was sie nicht sieht + +| | Warum | +|---|---| +| **Kasse** | Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. | +| **Protokoll** | Wer wann was getan hat, bleibt innerhalb der Wehr. | +| **KI-Protokoll** | Dasselbe. | +| **Personenexport nach DSGVO** | Das Auskunftsrecht erfüllt die Wehr, die die Daten führt. | + +### Sie taucht in keiner Mitgliederliste auf + +Die Aufsicht hat in der betreuten Wehr **keine Personenakte**. Sie ist also +kein Mitglied, erscheint in keiner Liste, in keiner Stärkemeldung und in +keiner Anwesenheit — und kann als Urheber einer Änderung auch gar nicht +eingetragen werden, weil sie keine macht. + +Ist der Kreisbrandinspektor zugleich Kommandant seiner Heimatwehr, hat er dort +ganz normal seine Akte und seine Rollen. Es sind zwei Zuordnungen desselben +Kontos, mehr nicht. + +--- + +## 56. Wehren und Konten verwalten + +Unter **Wehren und Konten**. Der Menüpunkt erscheint nur für den, der die +Installation betreibt — nicht für Kommandanten, auch nicht für +Administratoren einer einzelnen Wehr. + +> Dieses Merkmal hebt die Trennung zwischen den Wehren auf. Es gehört an so +> wenige Konten wie möglich, im Regelfall an eines. + +### Eine Wehr anlegen + +Vollständiger Name, Kurzname und eine **Kennung**. Die Kennung steht später +im Terminal und in Dateipfaden und lässt sich nicht mehr ändern — also mit +Bedacht wählen: Kleinbuchstaben, Ziffern, Bindestriche, etwa +`ff-musterhausen`. + +Die neue Wehr bekommt sofort ihre Rollen samt Rechten. Ein Konto bekommt sie +nicht: das ist der zweite Schritt. + +### Ein Konto anlegen + +E-Mail-Adresse und ein erstes Passwort. Die Adresse gilt über alle Wehren +hinweg und ist damit die Anmeldung. + +Das Passwort steht im Klartext da, damit Sie es weitergeben können — FLORIAN +verschickt keines. Es gehört nach der ersten Anmeldung geändert. + +Gibt es zu der Adresse schon ein Konto, sagt FLORIAN das und legt **kein +zweites** an. Wenn diese Person zu einer weiteren Wehr gehören soll, bekommt +sie eine weitere Zuordnung — genau dafür ist die Trennung da. + +### Zuordnen + +Konto wählen, **Wehr zuordnen**, dann: + +- **Mitglied** — zusätzlich die Personenakte in dieser Wehr wählen. Zur + Auswahl steht, wer noch kein Konto hat: eine Akte gehört zu höchstens einem, + sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat. +- **Kreisaufsicht** — keine Akte, nur Leserechte. + +Jede Zuordnung wird **in der betroffenen Wehr** protokolliert. Dort steht also +nachlesbar, dass jemand von außen Zugang bekommen hat. + +### Eine Zuordnung beenden + +Über das Papierkorb-Symbol. Die Personenakte in der Wehr bleibt bestehen — +entfernt wird nur der Zugang. Wer ausgetreten ist, wird in der Mitgliederliste +ausgetragen, nicht hier. + +--- + +## 57. Rollen und was sie dürfen Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr @@ -2002,6 +2133,7 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert. | **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen | | **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen | | **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten, im Wiki lesen | +| **Kreisebene** | sieht die betreuten Wehren, ändert nichts — siehe [Kapitel 55](#55-kreisbrandrat-und-kreisbrandinspektion) | | **Terminal** | nur anmelden und erfassen — nie verwalten | Zwei Dinge sind bewusst so gebaut: @@ -2020,7 +2152,7 @@ sind nur die Textvorlagen, und die hängen an einem eigenen Recht. --- -## 55. Anmeldung über authentik einrichten +## 58. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -2073,7 +2205,7 @@ ganze Wehr aus. --- -## 56. PIN und Ausweiskarte verwalten +## 59. PIN und Ausweiskarte verwalten Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum, was dahinter liegt. @@ -2096,7 +2228,7 @@ Huber), PIN `471203`. --- -## 57. Was protokolliert wird +## 60. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -2121,7 +2253,7 @@ jeweiligen Detailseite sichtbar. --- -## 58. Wenn etwas nicht geht +## 61. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 2a15658..500f821 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -40,7 +40,8 @@ Faktoren, die in die Schätzung eingehen: | 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** | -| | **Summe** | **123–164 PT** | **28–37 h** | **28:30 h** | +| 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 h** | +| | **Summe** | **127–170 PT** | **29–38,5 h** | **32:50 h** | 123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet @@ -432,6 +433,31 @@ der Prüfbericht, der nach dem Festschreiben entsteht, und die DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen scheiterte. +## Phase 10 — Mandantenfähigkeit + +**Vergleichsaufwand: 4–6 PT** — aus der Kandidatenliste vorgezogen, am +05.09.2026 umgesetzt. + +| Baustein | PT | +|---|---:| +| `Benutzer` und `Mitgliedschaft`, datenerhaltende Umstellung | 1,5 | +| Anmeldung: Konto statt Akte, Wehrwechsel im Sitzungstoken | 1,5 | +| Kreisebene mit reinen Leserechten | 1,0 | +| Wehrwähler und Verwaltung der Installation | 1,5 | + +Die Schätzung ging von „technisch bereits möglich, Oberfläche fehlt" aus — +und das war zur Hälfte falsch. Datenmodell und RLS trugen tatsächlich mehrere +Wehren, aber die Anmeldung nicht: Passwort und OIDC-Subject hingen an der +Personenakte, und die gehört genau einer Wehr. Das war kein Oberflächenthema, +sondern ein Umbau am Fundament. + +**Tatsächlich: 4:20 h.** Getragen hat eine Entscheidung ganz am Anfang: die +Sitzung behält ihre Form und entsteht nur anders. Ohne sie wären 61 Dateien +mit 414 Fundstellen zu ändern gewesen; so waren es acht. Die Zeit ging +stattdessen in die Absicherung — zwei Trigger, vier CHECKs und elf +Datenbanktests — und das ist die richtige Verteilung für eine Phase, deren +einziger Zweck es ist, dass eine Wehr die andere nicht sieht. + --- ## Was hier nicht drin ist diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 562e87c..b7a69cc 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -22,7 +22,109 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 | | Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 | | Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 | -| **Summe** | **28:30 h** | **12** | | +| Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 | +| **Summe** | **32:50 h** | **13** | | + +--- + +## 2026-09-05 · Phase 10 — Mandantenfähigkeit + +**08:10 – 12:30 · 4:20 h · Version 0.11.0** + +### Was entstanden ist + +Die einzige Phase bisher, die die Anmeldung selbst anfasst. + +| Bereich | Umfang | +|---|---| +| Datenmodell | `Benutzer`, `Mitgliedschaft`, `MitgliedschaftArt`; `Person` verliert die Anmeldefelder | +| Absicherung | RLS für `mitgliedschaft`, zwei Trigger, vier CHECKs, ein REVOKE | +| Anmeldung | `lib/auth.ts` neu: Konto statt Akte, Mitgliedschaftswahl, Wehrwechsel im Token | +| Rechte | Rolle `AUFSICHT`, gefiltert statt aufgezählt; `istVerwalter` als Kontomerkmal | +| Oberfläche | Wehrwähler, Aufsichtshinweis, `/wehren` zur Verwaltung | +| Tests | 19 neue Unit-Tests (11 gegen die Datenbank), 13 E2E-Tests | + +### Entscheidungen + +**Die Sitzung behält ihre Form.** `sitzung.organisationId` und +`sitzung.personId` werden an 329 bzw. 85 Stellen in 61 Dateien gelesen. Hätte +sich ihre Bedeutung geändert, wäre das ein Umbau durch das halbe Projekt +geworden. Stattdessen entsteht die Sitzung nur *anders* — aus der aktiven +Mitgliedschaft statt aus der Person. Keine dieser 61 Dateien musste angefasst +werden. + +**`personId` ist nullbar geworden.** Das war die eine Ausnahme davon, und zwar +absichtlich: eine Aufsichtssitzung hat keine Akte. Der Typechecker hat daraus +eine Liste gemacht — genau fünf Stellen verlangen eine Person, und alle fünf +sind Dinge, an denen die Kreisaufsicht nichts verloren hat (Anmeldemarke, +Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz). Ein `?? ""` hätte +dieselben fünf Stellen still passieren lassen. + +**`Benutzer` ohne `organisationId`.** Die zweite Ausnahme von der +Architekturregel nach `Berechtigung` — und dieselbe Begründung: die Tabelle +steht über allen Mandanten, weil sie der Angelpunkt ist, an dem ein Mensch zu +mehreren Wehren gehört. Geschützt über Rechte statt RLS. + +**`AUFSICHT` steht nicht im Prisma-Enum.** Alle anderen Rollen werden einem +Menschen in einer Wehr zugewiesen. Diese ergibt sich aus der Mitgliedschaftsart. +Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an +einer anderen verteilen könnte. + +**Ihre Rechte werden gefiltert, nicht aufgezählt.** Alles, was `.lesen` heißt, +minus vier ausdrückliche Ausnahmen. Eine Liste von Hand wäre bei Phase 11 +veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand +nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue +Schreibrechte nie. Der Test dazu ist entsprechend formuliert: nicht „diese +dreizehn Rechte", sondern „kein einziges, das nicht lesen heißt". + +**Die Kasse ist ausgenommen**, obwohl `kasse.lesen` auf `.lesen` endet. Sie +gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. Sie wäre nur +deshalb dabei gewesen, weil das Recht zufällig richtig heißt — und genau +dagegen ist die Ausnahmeliste da. + +**Die Migration ist datenerhaltend, mit Abbruchprüfung.** Konten und +Zuordnungen entstehen, *bevor* die alten Spalten fallen; am Ende prüft ein +`DO`-Block, dass keine Person mit Anmeldedaten ohne Konto dasteht, und bricht +sonst ab. Eine Migration, die die Anmeldung anfasst und auf halbem Weg +liegenbleibt, sperrt alle aus. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| Nach dem Wehrwechsel landete alles auf der Anmeldeseite | `aktuelleSitzung()` verlangte eine Personenkennung — die Aufsicht hat keine und galt damit als nicht angemeldet | Eine Sitzung ist gültig, wenn Wehr und Konto feststehen; die Akte darf fehlen | +| Der Wechsel schien nicht zu greifen | Er griff: das Token trug die neue Wehr. Es war der dritte hängengebliebene Entwicklungsserver dieser Woche — `npm run build` neben laufendem `next dev` macht `.next` unbrauchbar, und der Server antwortet danach nur noch mit 500 | Server beenden, `.next` löschen. Diesmal notiert, damit es nicht ein viertes Mal passiert | +| Der Seed brach ab | Er legt die Rollen aus `ROLLEN` an, und dort steht jetzt `AUFSICHT` — die es im Prisma-Enum bewusst nicht gibt | Im Seed gefiltert, mit Begründung | +| `unstable_update` nahm den Wechselwunsch nicht an | Es akzeptiert nur `Partial` | Der Wunsch reist als `user.mitgliedschaftId` und wird serverseitig gegen die Mitgliedschaften geprüft | +| Die Nachbarwehr war fest verdrahtet | `ff-nachbarshausen` für jeden Mandanten — genau die Vermischung von Test- und Beispieldaten, die Phase 8 beseitigt hat | Aus dem Mandanten abgeleitet: `-nachbar` | +| Ein alter Datenbanktest wurde unzuverlässig | `ki-datenbank.test.ts` legte Wehr und Person idempotent an, die Wiki-Seite aber nicht. Die neue Testdatei verschob die Reihenfolge, und ein Rest aus einem abgebrochenen Lauf ließ den ganzen Datei-Lauf scheitern | Auch dort ein `upsert` | +| Nach `db:test-reset` zeigte der Wehrwähler nur noch die betreute Wehr | Der Reset löschte die Testwehr, aber nicht ihre Nachbarwehr. Der Seed sah die Nachbarwehr, hielt alles für erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an | Der Reset nimmt Nachbarwehr und Kreiskonto mit; der Seed prüft beide Teile getrennt statt mit einem gemeinsamen Ausstieg. Gefunden hat es ein E2E-Test | +| Im vollen Lauf fielen fremde Testdateien mit „Anmeldung fehlgeschlagen“ aus | Der Fehlversuch-Test meldete sich mit der Adresse des Administrators an. Fünf Fehlversuche sperren das Konto fünf Minuten — bei zwei Projekten und Wiederholungen kam das zusammen, und alle anderen Dateien brauchen dasselbe Konto | Der Test nimmt jetzt eine Adresse, die es nicht gibt. Die Meldung muss ohnehin dieselbe sein, damit sie nicht verrät, ob es die Adresse gibt | + +### Abweichung von der Vorgabe + +**Zwei Architekturregeln bekommen eine dokumentierte Ausnahme.** + +„Jede Tabelle hat `organisationId`" gilt für `Benutzer` nicht — sie kann es +nicht, ohne den Zweck der Tabelle aufzuheben. Die Ausnahme steht im Schema, in +der Migration und in der Roadmap, und die Tabelle ist dafür über Rechte +geschützt statt über RLS. + +„Berechtigungen zentral in `lib/authz.ts`" gilt für den Verwalterzugang nicht: +er hängt an `Benutzer.istVerwalter` und nicht an einem Recht. Rechte gelten je +Wehr — ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die +Hintertür, die diese Phase gerade schließen soll. + +### Nachweise + +- `npm run typecheck` — ohne Ausgabe +- `npm run lint` — ohne Befund +- `npm run test` — 581 Tests grün, davon 11 neu gegen eine echte Datenbank +- `npm run test:e2e` — grün über Desktop und Tablet, davon 26 neu +- `npm run db:seed` und `npm run db:test-reset` — beide Mandanten samt + Nachbarwehr und Kreisebene +- Anmeldung nach der Umstellung von Hand geprüft: Konto gefunden, Passwort + richtig, Sitzung trägt Konto, Mitgliedschaft und Verwaltermerkmal --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8232d6c..3d7f85b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,6 +19,7 @@ Phase 6 ████████████████████ fertig Phase 7 ████████████████████ fertig 0.8.0 Phase 8 ████████████████████ fertig 0.9.0 Phase 9 ████████████████████ fertig 0.10.0 +Phase 10 ████████████████████ fertig 0.11.0 Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 ``` @@ -260,6 +261,35 @@ ein Skript oder ein Import würden sonst reichen, um den geprüften Bestand zu **Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf Knopfdruck erzeugen. +## Phase 10 — Mandantenfähigkeit · fertig + +Am 05.09.2026 ergänzt. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren; +was fehlte, war alles darüber. + +- `Benutzer` und `Mitgliedschaft`: das Anmeldekonto ist von der Personenakte + gelöst. Ein Mensch, ein Passwort, beliebig viele Wehren. +- Wehrwähler in der Seitenleiste — er erscheint nur, wenn es etwas zu wählen + gibt. +- Die Rolle `AUFSICHT` für die Kreisebene: Kreisbrandrat und + Kreisbrandinspektion sehen die betreuten Wehren und ändern nichts. +- Verwaltung der Installation unter `/wehren`: Wehren anlegen, Konten führen, + Zuordnungen vergeben. + +Die Entscheidung, die alles andere bestimmt: **das Konto trägt keine +Mandantenkennung.** Es kann keine tragen — es ist ja der Angelpunkt, an dem +ein Mensch zu mehreren Wehren gehört. Geschützt wird es deshalb wie +`Berechtigung` über Rechte statt über Row Level Security, und was ein Konto in +einer Wehr darf, entscheidet nicht es selbst, sondern seine `Mitgliedschaft` — +und die trägt `organisationId` wie jede andere Tabelle. + +**Bewusst nicht dabei:** wehrübergreifende Auswertungen. Eine Zahl über +mehrere Wehren hinweg braucht eine abgestimmte Definition, und ohne die ist +sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln. + +**Die Wehr kann jetzt:** sich einen Server mit anderen teilen, ohne dass +jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne +etwas anfassen zu können. + ## 1.0.0 — Freigabe Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im @@ -282,6 +312,9 @@ Am 03.09.2026 entschieden: |---|---| | Kassenmodul | Als **Phase 9** ergänzt und mit 0.10.0 umgesetzt. Spezifikation in `Phase-9.md`. | | `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. | +| `Benutzer` ohne `organisationId` | Notwendige Ausnahme, seit Phase 10. Die Tabelle ist der Angelpunkt der Doppelmitgliedschaft — mit Mandantenkennung könnte sie das nicht sein. Geschützt über Rechte statt RLS, wie `Berechtigung`: die Anwendungsrolle darf lesen und die Anmeldefelder fortschreiben, mehr nicht. | +| `AUFSICHT` nicht im Prisma-Enum | Die Kreisebene ergibt sich aus der Mitgliedschaftsart, nicht aus einer Rollenzuweisung. Vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an einer anderen verteilen könnte. | +| `istVerwalter` als Kontomerkmal | Kein Recht. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die Hintertür, die Phase 10 vermeiden soll. | | Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. | ## Weitere Kandidaten für später @@ -292,10 +325,8 @@ Am 03.09.2026 entschieden: | Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT | | Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT | | Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS | -| Mehrere Wehren je Installation | technisch bereits möglich, Oberfläche fehlt | 4–6 PT | -Die Mandantenfähigkeit ist der Punkt, der ohne Zusatzaufwand am weitesten -trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist -eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende -Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das -der nächste sinnvolle Schritt. +Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene, +Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die +gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte +Definition dessen, was verglichen werden soll. diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index 0766e4c..56d6024 100644 Binary files a/docs/screenshots/02-uebersicht-dark.png and b/docs/screenshots/02-uebersicht-dark.png differ diff --git a/docs/screenshots/02-uebersicht-light.png b/docs/screenshots/02-uebersicht-light.png index f5bc0e2..e6bc561 100644 Binary files a/docs/screenshots/02-uebersicht-light.png and b/docs/screenshots/02-uebersicht-light.png differ diff --git a/docs/screenshots/03-geraeteliste-dark.png b/docs/screenshots/03-geraeteliste-dark.png index 1cc92e0..0fd6bf0 100644 Binary files a/docs/screenshots/03-geraeteliste-dark.png and b/docs/screenshots/03-geraeteliste-dark.png differ diff --git a/docs/screenshots/03-geraeteliste-light.png b/docs/screenshots/03-geraeteliste-light.png index 915238f..a621849 100644 Binary files a/docs/screenshots/03-geraeteliste-light.png and b/docs/screenshots/03-geraeteliste-light.png differ diff --git a/docs/screenshots/04-geraet-dark.png b/docs/screenshots/04-geraet-dark.png index bc1cd85..1d24dab 100644 Binary files a/docs/screenshots/04-geraet-dark.png and b/docs/screenshots/04-geraet-dark.png differ diff --git a/docs/screenshots/04-geraet-light.png b/docs/screenshots/04-geraet-light.png index ccdbf7c..a4b2b35 100644 Binary files a/docs/screenshots/04-geraet-light.png and b/docs/screenshots/04-geraet-light.png differ diff --git a/docs/screenshots/05-fahrzeuge-dark.png b/docs/screenshots/05-fahrzeuge-dark.png index d63475c..590c420 100644 Binary files a/docs/screenshots/05-fahrzeuge-dark.png and b/docs/screenshots/05-fahrzeuge-dark.png differ diff --git a/docs/screenshots/05-fahrzeuge-light.png b/docs/screenshots/05-fahrzeuge-light.png index 3e96ed8..448677f 100644 Binary files a/docs/screenshots/05-fahrzeuge-light.png and b/docs/screenshots/05-fahrzeuge-light.png differ diff --git a/docs/screenshots/06-fahrzeug-dark.png b/docs/screenshots/06-fahrzeug-dark.png index d5a5396..1135d81 100644 Binary files a/docs/screenshots/06-fahrzeug-dark.png and b/docs/screenshots/06-fahrzeug-dark.png differ diff --git a/docs/screenshots/06-fahrzeug-light.png b/docs/screenshots/06-fahrzeug-light.png index 7eb982b..17169ed 100644 Binary files a/docs/screenshots/06-fahrzeug-light.png and b/docs/screenshots/06-fahrzeug-light.png differ diff --git a/docs/screenshots/08-qualifikationen-dark.png b/docs/screenshots/08-qualifikationen-dark.png index 210a232..b99fada 100644 Binary files a/docs/screenshots/08-qualifikationen-dark.png and b/docs/screenshots/08-qualifikationen-dark.png differ diff --git a/docs/screenshots/08-qualifikationen-light.png b/docs/screenshots/08-qualifikationen-light.png index 502691d..44264f9 100644 Binary files a/docs/screenshots/08-qualifikationen-light.png and b/docs/screenshots/08-qualifikationen-light.png differ diff --git a/docs/screenshots/11-fristen-dark.png b/docs/screenshots/11-fristen-dark.png index 4534c29..c80ee90 100644 Binary files a/docs/screenshots/11-fristen-dark.png and b/docs/screenshots/11-fristen-dark.png differ diff --git a/docs/screenshots/11-fristen-light.png b/docs/screenshots/11-fristen-light.png index 69ee556..2dbd7f5 100644 Binary files a/docs/screenshots/11-fristen-light.png and b/docs/screenshots/11-fristen-light.png differ diff --git a/docs/screenshots/15-lebensdauer-dark.png b/docs/screenshots/15-lebensdauer-dark.png index c92ad6a..1bf5acf 100644 Binary files a/docs/screenshots/15-lebensdauer-dark.png and b/docs/screenshots/15-lebensdauer-dark.png differ diff --git a/docs/screenshots/15-lebensdauer-light.png b/docs/screenshots/15-lebensdauer-light.png index c6c2ff7..6a8133f 100644 Binary files a/docs/screenshots/15-lebensdauer-light.png and b/docs/screenshots/15-lebensdauer-light.png differ diff --git a/docs/screenshots/17-checkliste-light.png b/docs/screenshots/17-checkliste-light.png index e0cd827..6385d1f 100644 Binary files a/docs/screenshots/17-checkliste-light.png and b/docs/screenshots/17-checkliste-light.png differ diff --git a/docs/screenshots/20-statistik-dark.png b/docs/screenshots/20-statistik-dark.png index d3dcb0c..57ab600 100644 Binary files a/docs/screenshots/20-statistik-dark.png and b/docs/screenshots/20-statistik-dark.png differ diff --git a/docs/screenshots/22-einsatz-dark.png b/docs/screenshots/22-einsatz-dark.png index d31a9b6..ec46ba5 100644 Binary files a/docs/screenshots/22-einsatz-dark.png and b/docs/screenshots/22-einsatz-dark.png differ diff --git a/docs/screenshots/22-einsatz-light.png b/docs/screenshots/22-einsatz-light.png index b4d2bb4..f134321 100644 Binary files a/docs/screenshots/22-einsatz-light.png and b/docs/screenshots/22-einsatz-light.png differ diff --git a/docs/screenshots/23-uebung-light.png b/docs/screenshots/23-uebung-light.png index d970a3f..728d441 100644 Binary files a/docs/screenshots/23-uebung-light.png and b/docs/screenshots/23-uebung-light.png differ diff --git a/docs/screenshots/24-portal-dark.png b/docs/screenshots/24-portal-dark.png index 6edfe2f..013ff15 100644 Binary files a/docs/screenshots/24-portal-dark.png and b/docs/screenshots/24-portal-dark.png differ diff --git a/docs/screenshots/24-portal-light.png b/docs/screenshots/24-portal-light.png index ee663bf..bed6868 100644 Binary files a/docs/screenshots/24-portal-light.png and b/docs/screenshots/24-portal-light.png differ diff --git a/docs/screenshots/26-kiosk-mangel-dark.png b/docs/screenshots/26-kiosk-mangel-dark.png index ded6df7..662abd3 100644 Binary files a/docs/screenshots/26-kiosk-mangel-dark.png and b/docs/screenshots/26-kiosk-mangel-dark.png differ diff --git a/docs/screenshots/26-kiosk-mangel-light.png b/docs/screenshots/26-kiosk-mangel-light.png index 7a4864d..4d82f12 100644 Binary files a/docs/screenshots/26-kiosk-mangel-light.png and b/docs/screenshots/26-kiosk-mangel-light.png differ diff --git a/docs/screenshots/27-kiosk-monitor-dark.png b/docs/screenshots/27-kiosk-monitor-dark.png index eb1e3d7..3323eae 100644 Binary files a/docs/screenshots/27-kiosk-monitor-dark.png and b/docs/screenshots/27-kiosk-monitor-dark.png differ diff --git a/docs/screenshots/27-kiosk-monitor-light.png b/docs/screenshots/27-kiosk-monitor-light.png index cbfc7d4..addacf3 100644 Binary files a/docs/screenshots/27-kiosk-monitor-light.png and b/docs/screenshots/27-kiosk-monitor-light.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-dark.png b/docs/screenshots/28-terminal-uebersicht-dark.png index cf16f08..47ab19b 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-dark.png and b/docs/screenshots/28-terminal-uebersicht-dark.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-light.png b/docs/screenshots/28-terminal-uebersicht-light.png index 4260534..8b3c742 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-light.png and b/docs/screenshots/28-terminal-uebersicht-light.png differ diff --git a/docs/screenshots/29-anmeldemarke-dark.png b/docs/screenshots/29-anmeldemarke-dark.png index ee0a585..79a3a37 100644 Binary files a/docs/screenshots/29-anmeldemarke-dark.png and b/docs/screenshots/29-anmeldemarke-dark.png differ diff --git a/docs/screenshots/29-anmeldemarke-light.png b/docs/screenshots/29-anmeldemarke-light.png index ddec0b1..3606d26 100644 Binary files a/docs/screenshots/29-anmeldemarke-light.png and b/docs/screenshots/29-anmeldemarke-light.png differ diff --git a/docs/screenshots/31-atemschutz-dark.png b/docs/screenshots/31-atemschutz-dark.png index ed66f2d..d39ca15 100644 Binary files a/docs/screenshots/31-atemschutz-dark.png and b/docs/screenshots/31-atemschutz-dark.png differ diff --git a/docs/screenshots/31-atemschutz-light.png b/docs/screenshots/31-atemschutz-light.png index 835bf97..9c25740 100644 Binary files a/docs/screenshots/31-atemschutz-light.png and b/docs/screenshots/31-atemschutz-light.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-dark.png b/docs/screenshots/32-atemschutz-nachweise-dark.png index ebed6f1..88190e4 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-dark.png and b/docs/screenshots/32-atemschutz-nachweise-dark.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-light.png b/docs/screenshots/32-atemschutz-nachweise-light.png index 9112977..7f59b41 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-light.png and b/docs/screenshots/32-atemschutz-nachweise-light.png differ diff --git a/docs/screenshots/33-atemschutz-ueberwachung-dark.png b/docs/screenshots/33-atemschutz-ueberwachung-dark.png index 70df9be..72bf752 100644 Binary files a/docs/screenshots/33-atemschutz-ueberwachung-dark.png and b/docs/screenshots/33-atemschutz-ueberwachung-dark.png differ diff --git a/docs/screenshots/34-karte-dark.png b/docs/screenshots/34-karte-dark.png index 9ef7277..746d4bd 100644 Binary files a/docs/screenshots/34-karte-dark.png and b/docs/screenshots/34-karte-dark.png differ diff --git a/docs/screenshots/34-karte-light.png b/docs/screenshots/34-karte-light.png index de13e81..347ab9f 100644 Binary files a/docs/screenshots/34-karte-light.png and b/docs/screenshots/34-karte-light.png differ diff --git a/docs/screenshots/35-objekte-dark.png b/docs/screenshots/35-objekte-dark.png index d017023..f6d5f4a 100644 Binary files a/docs/screenshots/35-objekte-dark.png and b/docs/screenshots/35-objekte-dark.png differ diff --git a/docs/screenshots/35-objekte-light.png b/docs/screenshots/35-objekte-light.png index ce3ac15..c5748e3 100644 Binary files a/docs/screenshots/35-objekte-light.png and b/docs/screenshots/35-objekte-light.png differ diff --git a/docs/screenshots/37-termine-dark.png b/docs/screenshots/37-termine-dark.png index 5768978..e5a2463 100644 Binary files a/docs/screenshots/37-termine-dark.png and b/docs/screenshots/37-termine-dark.png differ diff --git a/docs/screenshots/37-termine-light.png b/docs/screenshots/37-termine-light.png index f042731..51226ff 100644 Binary files a/docs/screenshots/37-termine-light.png and b/docs/screenshots/37-termine-light.png differ diff --git a/docs/screenshots/38-benachrichtigungen-dark.png b/docs/screenshots/38-benachrichtigungen-dark.png index c00bb6e..d72218a 100644 Binary files a/docs/screenshots/38-benachrichtigungen-dark.png and b/docs/screenshots/38-benachrichtigungen-dark.png differ diff --git a/docs/screenshots/38-benachrichtigungen-light.png b/docs/screenshots/38-benachrichtigungen-light.png index 093e0da..1eea4d9 100644 Binary files a/docs/screenshots/38-benachrichtigungen-light.png and b/docs/screenshots/38-benachrichtigungen-light.png differ diff --git a/docs/screenshots/39-vorlagen-dark.png b/docs/screenshots/39-vorlagen-dark.png index 74bb2aa..2a49bfb 100644 Binary files a/docs/screenshots/39-vorlagen-dark.png and b/docs/screenshots/39-vorlagen-dark.png differ diff --git a/docs/screenshots/39-vorlagen-light.png b/docs/screenshots/39-vorlagen-light.png index 32d17bf..af9a9b0 100644 Binary files a/docs/screenshots/39-vorlagen-light.png and b/docs/screenshots/39-vorlagen-light.png differ diff --git a/docs/screenshots/40-dienstausweis-dark.png b/docs/screenshots/40-dienstausweis-dark.png index 802c643..5fbc8a4 100644 Binary files a/docs/screenshots/40-dienstausweis-dark.png and b/docs/screenshots/40-dienstausweis-dark.png differ diff --git a/docs/screenshots/40-dienstausweis-light.png b/docs/screenshots/40-dienstausweis-light.png index c66d263..6724d83 100644 Binary files a/docs/screenshots/40-dienstausweis-light.png and b/docs/screenshots/40-dienstausweis-light.png differ diff --git a/docs/screenshots/42-belege-light.png b/docs/screenshots/42-belege-light.png index b64335d..555e261 100644 Binary files a/docs/screenshots/42-belege-light.png and b/docs/screenshots/42-belege-light.png differ diff --git a/docs/screenshots/43-ki-protokoll-light.png b/docs/screenshots/43-ki-protokoll-light.png index 23580f4..bbaa0b4 100644 Binary files a/docs/screenshots/43-ki-protokoll-light.png and b/docs/screenshots/43-ki-protokoll-light.png differ diff --git a/docs/screenshots/46-kassenbuch-light.png b/docs/screenshots/46-kassenbuch-light.png index 57fadfb..a0aa304 100644 Binary files a/docs/screenshots/46-kassenbuch-light.png and b/docs/screenshots/46-kassenbuch-light.png differ diff --git a/docs/screenshots/48-rechnungen-light.png b/docs/screenshots/48-rechnungen-light.png index 50a7d46..9219935 100644 Binary files a/docs/screenshots/48-rechnungen-light.png and b/docs/screenshots/48-rechnungen-light.png differ diff --git a/docs/screenshots/49-kassenabschluss-light.png b/docs/screenshots/49-kassenabschluss-light.png index 9a8688f..d882309 100644 Binary files a/docs/screenshots/49-kassenabschluss-light.png and b/docs/screenshots/49-kassenabschluss-light.png differ diff --git a/docs/screenshots/50-wehren-dark.png b/docs/screenshots/50-wehren-dark.png new file mode 100644 index 0000000..7abd786 Binary files /dev/null and b/docs/screenshots/50-wehren-dark.png differ diff --git a/docs/screenshots/50-wehren-light.png b/docs/screenshots/50-wehren-light.png new file mode 100644 index 0000000..bc25138 Binary files /dev/null and b/docs/screenshots/50-wehren-light.png differ diff --git a/package.json b/package.json index f05121d..86c7f0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.10.0", + "version": "0.11.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { diff --git a/prisma/migrations/20260905090000_mandantenfaehigkeit/migration.sql b/prisma/migrations/20260905090000_mandantenfaehigkeit/migration.sql new file mode 100644 index 0000000..e52c7b0 --- /dev/null +++ b/prisma/migrations/20260905090000_mandantenfaehigkeit/migration.sql @@ -0,0 +1,189 @@ +-- ============================================================================= +-- Phase 10 — Mandantenfähigkeit: Konto und Mitgliedschaft +-- +-- Bis hierher war die Personenakte zugleich das Anmeldekonto: Passwort-Hash +-- und OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. +-- Ein Mensch konnte damit nicht zu zwei Wehren gehören, ohne zwei Passwörter +-- zu haben — und die Anmeldung per E-Mail war mehrdeutig, sobald dieselbe +-- Adresse in zwei Wehren stand (die Eindeutigkeit galt nur je Organisation). +-- +-- Ab jetzt: +-- +-- benutzer das Konto. Global eindeutige E-Mail, ein Passwort. +-- mitgliedschaft die Zugehörigkeit zu einer Wehr, mit oder ohne Akte. +-- person bleibt die Akte je Wehr — ohne Anmeldedaten. +-- +-- DIESE MIGRATION IST DATENERHALTEND. Jede Person mit Anmeldedaten bekommt +-- ein Konto und eine Mitgliedschaft, bevor die Spalten fallen. Wer schon +-- heute in zwei Wehren mit derselben E-Mail steht, bekommt EIN Konto mit ZWEI +-- Mitgliedschaften — also genau das, was vorher nicht möglich war. +-- +-- HINWEIS zu den entfernten DropIndex-Bloecken: Prisma schlaegt bei jeder +-- Migration erneut vor, die von Hand angelegten GiST- und Trigram-Indizes aus +-- Phase 1 abzuraeumen. Es kennt sie nicht, weil sie nicht aus dem Schema +-- stammen. Zehnte Migration in Folge, in der sie herausgenommen werden. +-- ============================================================================= + +-- CreateEnum +CREATE TYPE "MitgliedschaftArt" AS ENUM ('MITGLIED', 'AUFSICHT'); + +-- CreateTable +CREATE TABLE "benutzer" ( + "id" UUID NOT NULL, + "email" VARCHAR(200) NOT NULL, + "passwortHash" VARCHAR(120), + "passwortGeaendertAm" TIMESTAMPTZ(3), + "oidcSubject" VARCHAR(255), + "oidcGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[], + "letzteAnmeldung" TIMESTAMPTZ(3), + "fehlversuche" INTEGER NOT NULL DEFAULT 0, + "gesperrtBis" TIMESTAMPTZ(3), + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "istVerwalter" BOOLEAN NOT NULL DEFAULT false, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "benutzer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mitgliedschaft" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "benutzerId" UUID NOT NULL, + "personId" UUID, + "art" "MitgliedschaftArt" NOT NULL DEFAULT 'MITGLIED', + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "vergebenVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mitgliedschaft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "benutzer_email_key" ON "benutzer"("email"); +CREATE UNIQUE INDEX "benutzer_oidcSubject_key" ON "benutzer"("oidcSubject"); +CREATE INDEX "benutzer_oidcSubject_idx" ON "benutzer"("oidcSubject"); + +CREATE UNIQUE INDEX "mitgliedschaft_benutzerId_organisationId_key" + ON "mitgliedschaft"("benutzerId", "organisationId"); +CREATE UNIQUE INDEX "mitgliedschaft_personId_key" ON "mitgliedschaft"("personId"); +CREATE INDEX "mitgliedschaft_organisationId_art_idx" + ON "mitgliedschaft"("organisationId", "art"); + +-- AddForeignKey +ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_organisationId_fkey" + FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_benutzerId_fkey" + FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_personId_fkey" + FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ----------------------------------------------------------------------------- +-- Die Umstellung der vorhandenen Anmeldungen +-- ----------------------------------------------------------------------------- + +-- Ein Konto je E-Mail-Adresse, über alle Wehren hinweg. +-- +-- Angelegt wird nur für Personen, die sich überhaupt anmelden konnten: mit +-- Passwort-Hash oder OIDC-Subject. Eine Akte ohne beides ist eine Akte und +-- kein Konto — dabei bleibt es. +-- +-- `DISTINCT ON (lower(email))` entscheidet bei mehreren Akten mit derselben +-- Adresse: die älteste gewinnt und stellt die Anmeldedaten. Die anderen +-- werden gleich darunter zu weiteren Mitgliedschaften desselben Kontos. +INSERT INTO "benutzer" ( + "id", "email", "passwortHash", "passwortGeaendertAm", + "oidcSubject", "oidcGruppen", "letzteAnmeldung", "fehlversuche", + "gesperrtBis", "aktiv", "istVerwalter", "erstelltAm", "geaendertAm" +) +SELECT DISTINCT ON (lower(p."email")) + gen_random_uuid(), + lower(p."email"), + p."passwortHash", + p."passwortGeaendertAm", + p."oidcSubject", + p."oidcGruppen", + p."letzteAnmeldung", + p."fehlversuche", + p."gesperrtBis", + p."aktiv", + -- Wer bislang SUPERADMIN war, verwaltet ab jetzt die Installation. Das + -- ist der einzige Weg, auf dem dieses Merkmal je gesetzt wird, ohne dass + -- ein Mensch es vergibt. + EXISTS ( + SELECT 1 + FROM "person_rolle" pr + JOIN "rolle" r ON r."id" = pr."rolleId" + WHERE pr."personId" = p."id" + AND r."schluessel" = 'SUPERADMIN' + ), + p."erstelltAm", + now() + FROM "person" p + WHERE p."email" IS NOT NULL + AND p."geloeschtAm" IS NULL + AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL) + ORDER BY lower(p."email"), p."erstelltAm" ASC; + +-- Je Akte eine Mitgliedschaft am passenden Konto. +-- +-- Auch für die Akten, die beim Anlegen der Konten übergangen wurden: genau +-- sie sind die Doppelmitgliedschaften, um die es in dieser Phase geht. +INSERT INTO "mitgliedschaft" ( + "id", "organisationId", "benutzerId", "personId", "art", "aktiv", + "erstelltAm", "geaendertAm" +) +SELECT + gen_random_uuid(), + p."organisationId", + b."id", + p."id", + 'MITGLIED', + p."aktiv", + p."erstelltAm", + now() + FROM "person" p + JOIN "benutzer" b ON b."email" = lower(p."email") + WHERE p."email" IS NOT NULL + AND p."geloeschtAm" IS NULL + AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL); + +-- Ein zweites Konto in derselben Wehr kann es nach der Umstellung nicht +-- geben — der eindeutige Index oben hätte es verhindert. Diese Prüfung ist +-- die Gegenprobe: schlägt sie an, ist etwas an den Annahmen falsch, und die +-- Migration bricht ab, statt einen halben Zustand zu hinterlassen. +DO $$ +DECLARE + ohne_konto INTEGER; +BEGIN + SELECT count(*) INTO ohne_konto + FROM "person" p + WHERE p."geloeschtAm" IS NULL + AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL) + AND NOT EXISTS (SELECT 1 FROM "mitgliedschaft" m WHERE m."personId" = p."id"); + + IF ohne_konto > 0 THEN + RAISE EXCEPTION + 'Umstellung unvollstaendig: % Personen mit Anmeldedaten haben kein Konto ' + 'bekommen. Vermutlich fehlt ihnen die E-Mail-Adresse.', ohne_konto; + END IF; +END $$; + +-- ----------------------------------------------------------------------------- +-- Erst jetzt fallen die alten Spalten +-- ----------------------------------------------------------------------------- + +DROP INDEX IF EXISTS "person_organisationId_oidcSubject_key"; +DROP INDEX IF EXISTS "person_oidcSubject_idx"; + +ALTER TABLE "person" + DROP COLUMN "passwortHash", + DROP COLUMN "passwortGeaendertAm", + DROP COLUMN "oidcSubject", + DROP COLUMN "oidcGruppen", + DROP COLUMN "letzteAnmeldung", + DROP COLUMN "fehlversuche", + DROP COLUMN "gesperrtBis"; diff --git a/prisma/migrations/20260905091500_mandantenfaehigkeit_rls/migration.sql b/prisma/migrations/20260905091500_mandantenfaehigkeit_rls/migration.sql new file mode 100644 index 0000000..dd0172b --- /dev/null +++ b/prisma/migrations/20260905091500_mandantenfaehigkeit_rls/migration.sql @@ -0,0 +1,153 @@ +-- ============================================================================= +-- Phase 10 — Mandantenfähigkeit: Absicherung +-- +-- Zwei Tabellen, zwei sehr verschiedene Schutzbedürfnisse. +-- +-- `mitgliedschaft` trägt `organisationId` und wird behandelt wie jede andere +-- Tabelle: Row Level Security, Policy auf die aktuelle Organisation. Wer in +-- Wehr A angemeldet ist, sieht die Mitgliedschaften von Wehr A — und damit +-- übrigens auch nicht, in welchen anderen Wehren ein Kollege noch geführt +-- wird. Das ist Absicht. +-- +-- `benutzer` trägt bewusst KEINE `organisationId` und kann es nicht: sie ist +-- der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift hier +-- also nicht. Stattdessen wird sie über Rechte geschützt — dieselbe Lösung wie +-- bei `berechtigung` aus Phase 0, und aus demselben Grund: eine Tabelle, die +-- über allen Mandanten steht, gehört nicht in die Hand der Anwendungsrolle. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Row Level Security für die Mitgliedschaft +-- ----------------------------------------------------------------------------- +ALTER TABLE "mitgliedschaft" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "mitgliedschaft" FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "mitgliedschaft_mandant" ON "mitgliedschaft"; +CREATE POLICY "mitgliedschaft_mandant" ON "mitgliedschaft" FOR ALL + USING ("organisationId" = aktuelle_organisation()) + WITH CHECK ("organisationId" = aktuelle_organisation()); + +-- ----------------------------------------------------------------------------- +-- Prüfbedingungen +-- ----------------------------------------------------------------------------- + +-- Eine Mitgliedschaft als MITGLIED braucht die Personenakte — sie ist ja +-- genau die Verbindung zwischen Konto und Wehr. Die Aufsicht darf keine +-- haben: die Kreisebene gehört der Wehr nicht an und hat dort nichts in der +-- Mitgliederliste zu suchen. +ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_akte_passend" CHECK ( + ("art" = 'MITGLIED' AND "personId" IS NOT NULL) + OR ("art" = 'AUFSICHT' AND "personId" IS NULL) +); + +-- Die Akte muss zu derselben Wehr gehören wie die Mitgliedschaft. Ein +-- Fremdschlüssel allein sagt darüber nichts — er prüft nur, dass es die Akte +-- gibt, nicht, wo sie liegt. Ohne diese Bedingung ließe sich ein Konto über +-- eine Akte aus Wehr A in Wehr B hineinhängen, und die Sitzung stünde mit +-- einer personId da, die dort niemandem gehört. +CREATE OR REPLACE FUNCTION mitgliedschaft_akte_gleiche_wehr() +RETURNS TRIGGER AS $$ +DECLARE + akte_wehr uuid; +BEGIN + IF NEW."personId" IS NULL THEN + RETURN NEW; + END IF; + + SELECT "organisationId" INTO akte_wehr FROM "person" WHERE "id" = NEW."personId"; + + IF akte_wehr IS DISTINCT FROM NEW."organisationId" THEN + RAISE EXCEPTION + 'Die Personenakte gehoert zu einer anderen Wehr als die Mitgliedschaft. ' + 'Ein Konto wird je Wehr an die dortige Akte gehaengt.'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS mitgliedschaft_akte_gleiche_wehr_trigger ON "mitgliedschaft"; +CREATE TRIGGER mitgliedschaft_akte_gleiche_wehr_trigger + BEFORE INSERT OR UPDATE OF "personId", "organisationId" ON "mitgliedschaft" + FOR EACH ROW + EXECUTE FUNCTION mitgliedschaft_akte_gleiche_wehr(); + +-- Eine E-Mail-Adresse ohne @ ist keine. Die Anmeldung hängt daran, und ein +-- Konto, mit dem sich niemand anmelden kann, fällt sonst erst auf, wenn es +-- jemand versucht. +ALTER TABLE "benutzer" ADD CONSTRAINT "benutzer_email_plausibel" CHECK ( + "email" LIKE '%@%.%' AND length("email") >= 6 +); + +-- Ein Konto ohne jeden Anmeldeweg wäre eine Karteileiche. Entweder ein +-- Passwort oder ein OIDC-Subject — sonst ist es keines. +ALTER TABLE "benutzer" ADD CONSTRAINT "benutzer_anmeldeweg_vorhanden" CHECK ( + "passwortHash" IS NOT NULL OR "oidcSubject" IS NOT NULL +); + +-- ----------------------------------------------------------------------------- +-- Rechte +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app'; + EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app'; + + EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app'; + EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app'; + EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app'; + EXECUTE 'REVOKE DELETE ON "kassenabschluss" FROM florian_app'; + + -- Konten legt die Anwendungsrolle nicht an und löscht sie nicht. + -- + -- Beides geht über den Systemzugang, also über denselben Weg, über + -- den sich schon heute die Anmeldung eine Person sucht (RLS kennt vor + -- der Anmeldung ja noch keine Organisation). UPDATE bleibt erlaubt, + -- weil jede Anmeldung eines schreibt: letzte Anmeldung, Fehlversuche, + -- Sperre. Ein REVOKE darauf hiesse, dass niemand sich mehr anmelden + -- kann. + EXECUTE 'REVOKE INSERT, DELETE ON "benutzer" FROM florian_app'; + END IF; +END $$; + +-- ----------------------------------------------------------------------------- +-- Ein Konto behält seine Kennung +-- ----------------------------------------------------------------------------- + +-- Was die Anwendungsrolle am Konto ändern darf, ist genau das, was bei jeder +-- Anmeldung anfällt. Alles Übrige — E-Mail, Passwort, Verwalterrecht — +-- gehört in die Kontenverwaltung und damit an den Systemzugang. +-- +-- Ohne diese Sperre wäre `istVerwalter` über jeden Weg erreichbar, der ein +-- Konto schreibt. Und ein Merkmal, das die Mandantentrennung aufhebt, darf +-- nicht dort gesetzt werden können, wo eine Wehr ihre eigenen Daten pflegt. +CREATE OR REPLACE FUNCTION benutzer_kennung_unveraenderlich() +RETURNS TRIGGER AS $$ +BEGIN + IF current_user <> 'florian_app' THEN + RETURN NEW; + END IF; + + IF NEW."email" IS DISTINCT FROM OLD."email" + OR NEW."istVerwalter" IS DISTINCT FROM OLD."istVerwalter" + OR NEW."oidcSubject" IS DISTINCT FROM OLD."oidcSubject" THEN + RAISE EXCEPTION + 'E-Mail, OIDC-Subject und das Verwalterrecht eines Kontos werden ueber die ' + 'Kontenverwaltung geaendert, nicht aus einer Wehr heraus.'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS benutzer_kennung_unveraenderlich_trigger ON "benutzer"; +CREATE TRIGGER benutzer_kennung_unveraenderlich_trigger + BEFORE UPDATE ON "benutzer" + FOR EACH ROW + EXECUTE FUNCTION benutzer_kennung_unveraenderlich(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3265df4..f00728c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -107,6 +107,21 @@ enum FahrzeugArt { } /// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl). +/// Wie jemand zu einer Wehr gehört. +/// +/// MITGLIED ist der Regelfall: das Konto hängt an einer Personenakte in dieser +/// Wehr und bringt deren Rollen mit. +/// +/// AUFSICHT ist die Kreisebene — Kreisbrandrat, Kreisbrandinspektion. Sie +/// gehört der Wehr nicht an, hat dort also auch keine Personenakte, und darf +/// ausschließlich lesen. Ein Kreisbrandinspektor, der zugleich Mitglied seiner +/// Heimatwehr ist, hat schlicht zwei Mitgliedschaften: dort MITGLIED, in den +/// betreuten Wehren AUFSICHT. +enum MitgliedschaftArt { + MITGLIED + AUFSICHT +} + enum Dienstgrad { FEUERWEHRANWAERTER FEUERWEHRMANN @@ -153,6 +168,7 @@ model Organisation { geloeschtAm DateTime? @db.Timestamptz(3) personen Person[] + mitgliedschaften Mitgliedschaft[] rollen Rolle[] personRollen PersonRolle[] rolleRechte RolleBerechtigung[] @@ -280,18 +296,13 @@ model Person { /// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied. abteilung String? @db.VarChar(60) - /// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird. - passwortHash String? @db.VarChar(120) - passwortGeaendertAm DateTime? @db.Timestamptz(3) - /// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto. - oidcSubject String? @db.VarChar(255) - /// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit. - oidcGruppen String[] @default([]) - - letzteAnmeldung DateTime? @db.Timestamptz(3) - fehlversuche Int @default(0) - gesperrtBis DateTime? @db.Timestamptz(3) - aktiv Boolean @default(true) + /// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier. + /// + /// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird, + /// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject + /// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können, + /// und die Anmeldung per E-Mail wäre mehrdeutig. + aktiv Boolean @default(true) /// Profilbild. Liegt wie alles andere in MinIO. avatarDateiId String? @db.Uuid @@ -301,6 +312,7 @@ model Person { geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson") avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull) rollen PersonRolle[] @relation("PersonRollenInhaber") vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber") @@ -375,19 +387,104 @@ model Person { zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer") erstellteRechnungen Rechnung[] @relation("RechnungErsteller") - /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe - /// Person kann in zwei Wehren Mitglied sein. + /// Die E-Mail ist je Wehr eindeutig, nicht global: dieselbe Person kann in + /// zwei Wehren eine Akte haben. Angemeldet wird sich nicht damit, sondern + /// mit der E-Mail des `Benutzer` — die ist global eindeutig. @@unique([organisationId, email]) - @@unique([organisationId, oidcSubject]) @@unique([organisationId, mitgliedsnummer]) @@index([organisationId, nachname, vorname]) @@index([organisationId, aktiv]) - @@index([oidcSubject]) @@map("person") } /// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene /// Rollen kann der Kommandant ergänzen. +/// Ein Anmeldekonto. +/// +/// **Die einzige Tabelle neben `Berechtigung` ohne `organisationId`** — und +/// zwar notwendigerweise: sie ist der Angelpunkt, an dem ein Mensch zu +/// mehreren Wehren gehören kann. Ein Konto mit Mandantenkennung könnte genau +/// das nicht. +/// +/// Sie ist deshalb auch nicht über Row Level Security geschützt, sondern über +/// Rechte: die Anwendungsrolle darf sie nur lesen und die Anmeldefelder +/// fortschreiben, angelegt und verändert werden Konten über den +/// Systemzugang. Was ein Konto in einer Wehr sehen darf, entscheidet nicht es +/// selbst, sondern seine `Mitgliedschaft` — und die trägt `organisationId` +/// wie jede andere Tabelle. +model Benutzer { + id String @id @default(uuid()) @db.Uuid + + /// Global eindeutig. Das ist der Unterschied zur E-Mail an der Personenakte: + /// die ist eine Kontaktangabe je Wehr, diese hier ist die Anmeldung. + email String @unique @db.VarChar(200) + + /// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird. + passwortHash String? @db.VarChar(120) + passwortGeaendertAm DateTime? @db.Timestamptz(3) + /// "sub"-Claim aus authentik. + oidcSubject String? @unique @db.VarChar(255) + /// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit. + oidcGruppen String[] @default([]) + + letzteAnmeldung DateTime? @db.Timestamptz(3) + fehlversuche Int @default(0) + gesperrtBis DateTime? @db.Timestamptz(3) + aktiv Boolean @default(true) + + /// Verwaltet die Installation: Wehren anlegen und in jede hineinschalten. + /// + /// Bewusst ein Merkmal des Kontos und keine Rolle innerhalb einer Wehr. Eine + /// Rolle gilt je Organisation — wer Wehren anlegt, steht aber über allen, + /// und ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre + /// genau die Art Hintertür, die die Mandantentrennung aushöhlt. + istVerwalter Boolean @default(false) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + mitgliedschaften Mitgliedschaft[] + + @@index([oidcSubject]) + @@map("benutzer") +} + +/// Die Zugehörigkeit eines Kontos zu einer Wehr. +/// +/// Sie ist das, was eine Sitzung ausmacht: erst die Wahl einer Mitgliedschaft +/// entscheidet, welche Wehr in `app.current_organisation` steht und welche +/// Rollen gelten. Wer nur eine hat — die allermeisten —, merkt davon nichts. +model Mitgliedschaft { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + benutzerId String @db.Uuid + + /// Die Personenakte in dieser Wehr. Bei AUFSICHT leer: die Kreisebene ist + /// dort nicht Mitglied und soll auch in keiner Mitgliederliste auftauchen. + personId String? @db.Uuid + + art MitgliedschaftArt @default(MITGLIED) + aktiv Boolean @default(true) + + /// Wer sie eingerichtet hat. Bei der Umstellung aus Phase 10 leer. + vergebenVonId String? @db.Uuid + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + person Person? @relation("MitgliedschaftPerson", fields: [personId], references: [id], onDelete: Cascade) + + /// Ein Konto gehört einer Wehr höchstens einmal an. + @@unique([benutzerId, organisationId]) + /// Und eine Personenakte hängt an höchstens einem Konto — sonst wäre nicht + /// entscheidbar, wer unter ihrem Namen gehandelt hat. + @@unique([personId]) + @@index([organisationId, art]) + @@map("mitgliedschaft") +} + model Rolle { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid diff --git a/prisma/seed.ts b/prisma/seed.ts index 605cbda..c3a9ab1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -57,6 +57,7 @@ import { LebensdauerBezug, LebensdauerKategorie, MangelPrioritaet, + MitgliedschaftArt, MangelStatus, PrismaClient, PruefErgebnis, @@ -198,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: Map(); - for (const schluessel of ROLLEN) { + // AUFSICHT ist keine vergebbare Rolle und steht deshalb auch nicht im + // Prisma-Enum. Sie ergibt sich seit Phase 10 daraus, dass ein Konto einer + // Wehr als Kreisaufsicht zugeordnet ist — sie hier anzulegen hiesse, dass + // jemand sie innerhalb einer Wehr vergeben könnte. + const vergebbar = ROLLEN.filter((schluessel) => schluessel !== "AUFSICHT"); + + for (const schluessel of vergebbar) { const rolle = await prisma.rolle.upsert({ where: { organisationId_schluessel: { @@ -499,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map { } +// ----------------------------------------------------------------------------- +// Phase 10 — Mandantenfähigkeit +// +// Eine zweite, kleine Wehr und ein Konto, das beide sieht. +// +// Ohne die zweite Wehr lässt sich die ganze Phase nicht ausprobieren: der +// Wehrwähler erscheint erst, wenn es etwas zu wählen gibt, und eine +// Mandantentrennung, die nie zwei Mandanten hatte, ist keine geprüfte. +// +// Die Geschichte ist die übliche in Bayern: der Kommandant von Musterhausen +// ist zugleich Kreisbrandinspektor und betreut die Nachbarwehr. In seiner +// eigenen Wehr führt er alles, in der betreuten sieht er zu — mehr nicht. +// +// Nachbarshausen bleibt bewusst dünn: drei Leute, ein Fahrzeug. Es soll +// zeigen, dass die Trennung hält, und nicht die Beispieldaten verdoppeln. +// ----------------------------------------------------------------------------- + +/** + * Die Nachbarwehr leitet sich aus der Wehr ab, die gerade angelegt wird. + * + * Fest verdrahtet wäre sie das, was Phase 8 gerade beseitigt hat: der + * Testlauf und die Beispielwehr griffen auf denselben Datensatz zu. So bekommt + * jeder Mandant sein eigenes Paar — und die Trennung gilt auch für das, womit + * sie geprüft wird. + */ +const NACHBAR_SLUG = `${ORGANISATION_SLUG}-nachbar`; +const NACHBAR_NAME = `${ORGANISATION_NAME} — Nachbarwehr`; +const NACHBAR_KURZNAME = `${ORGANISATION_KURZNAME} Nachbar`; +const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${ORGANISATION_SLUG}.de`; + +async function kreisebeneAnlegen( + heimatOrganisationId: string, + katalog: Map, +): Promise { + /** + * Zwei Teile, zwei Prüfungen — und das ist wichtiger, als es aussieht. + * + * Ein gemeinsamer Ausstieg am Anfang hätte heißen: steht die Nachbarwehr, + * geschieht gar nichts mehr. Genau das ist beim Zurücksetzen der Testwehr + * passiert — die Nachbarwehr blieb stehen, die Mitgliedschaft in der neuen + * Testwehr wurde nie wieder angelegt, und der Wehrwähler zeigte nur noch + * die betreute Wehr. Gefunden hat das ein E2E-Test. + */ + const nachbar = + (await prisma.organisation.findUnique({ where: { slug: NACHBAR_SLUG } })) ?? + (await nachbarwehrAnlegen(katalog)); + + // --- Das Konto, das beide Wehren sieht ------------------------------------ + + await kreiskontoAnlegen(heimatOrganisationId, nachbar.id); +} + +/** Die Nachbarwehr: dünn besetzt, ein Fahrzeug. Sie soll zeigen, dass die + * Trennung hält, und nicht die Beispieldaten verdoppeln. */ +async function nachbarwehrAnlegen(katalog: Map) { + const nachbar = await prisma.organisation.create({ + data: { + slug: NACHBAR_SLUG, + name: NACHBAR_NAME, + kurzname: NACHBAR_KURZNAME, + strasse: "Dorfstraße 3", + plz: "91316", + ort: "Nachbarshausen", + landkreis: "Musterkreis", + funkPraefix: "Florian Nachbarshausen", + }, + }); + + const nachbarRollen = await rollenAnlegen(nachbar.id, katalog); + + const besetzung: Array<{ + vorname: string; + nachname: string; + nummer: string; + dienstgrad: Dienstgrad; + rolle: RolleSchluessel; + }> = [ + { + vorname: "Georg", + nachname: "Sailer", + nummer: "0001", + dienstgrad: Dienstgrad.BRANDMEISTER, + rolle: RolleSchluessel.KOMMANDANT, + }, + { + vorname: "Marianne", + nachname: "Kraus", + nummer: "0002", + dienstgrad: Dienstgrad.LOESCHMEISTER, + rolle: RolleSchluessel.GERAETEWART, + }, + { + vorname: "Tobias", + nachname: "Ostermeier", + nummer: "0003", + dienstgrad: Dienstgrad.FEUERWEHRMANN, + rolle: RolleSchluessel.MITGLIED, + }, + ]; + + for (const eintrag of besetzung) { + const person = await prisma.person.create({ + data: { + organisationId: nachbar.id, + vorname: eintrag.vorname, + nachname: eintrag.nachname, + mitgliedsnummer: eintrag.nummer, + dienstgrad: eintrag.dienstgrad, + abteilung: "Aktive", + eintrittsdatum: datum(2012, 4, 1), + }, + }); + + const rolleId = nachbarRollen.get(eintrag.rolle); + if (rolleId) { + await prisma.personRolle.create({ + data: { organisationId: nachbar.id, personId: person.id, rolleId }, + }); + } + } + + const standort = await prisma.standort.create({ + data: { + organisationId: nachbar.id, + name: "Gerätehaus Nachbarshausen", + art: StandortArt.GERAETEHAUS, + strasse: "Dorfstraße 3", + plz: "91316", + ort: "Nachbarshausen", + }, + }); + + await prisma.fahrzeug.create({ + data: { + organisationId: nachbar.id, + funkrufname: "Florian Nachbarshausen 42/1", + bezeichnung: "TSF-W", + art: FahrzeugArt.LOESCHFAHRZEUG, + kennzeichen: "MK-FW 421", + baujahr: 2009, + standortId: standort.id, + }, + }); + + log(`Nachbarwehr angelegt: ${besetzung.length} Personen, 1 Fahrzeug.`); + return nachbar; +} + +/** Das Konto der Kreisebene: Mitglied in der Heimatwehr, Aufsicht nebenan. */ +async function kreiskontoAnlegen(heimatOrganisationId: string, nachbarId: string) { + const kommandant = await prisma.person.findFirst({ + where: { + organisationId: heimatOrganisationId, + rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } }, + }, + orderBy: { mitgliedsnummer: "asc" }, + }); + + if (!kommandant) { + log("Kein Kommandant gefunden — die Kreisebene wird übersprungen."); + return; + } + + const email = KBI_EMAIL; + const passwort = process.env.SEED_KBI_PASSWORT ?? "Feuerwehr2026"; + + const konto = await prisma.benutzer.upsert({ + where: { email }, + update: {}, + create: { + email, + passwortHash: await bcrypt.hash(passwort, RUNDEN), + passwortGeaendertAm: new Date(), + }, + }); + + // In der eigenen Wehr: Mitglied mit allem, was seine Rollen hergeben. + await prisma.mitgliedschaft.upsert({ + where: { + benutzerId_organisationId: { + benutzerId: konto.id, + organisationId: heimatOrganisationId, + }, + }, + update: {}, + create: { + organisationId: heimatOrganisationId, + benutzerId: konto.id, + personId: kommandant.id, + art: MitgliedschaftArt.MITGLIED, + }, + }); + + // In der Nachbarwehr: Aufsicht. Ohne Akte — er gehört ihr nicht an und + // steht deshalb auch in keiner ihrer Mitgliederlisten. + await prisma.mitgliedschaft.upsert({ + where: { + benutzerId_organisationId: { benutzerId: konto.id, organisationId: nachbarId }, + }, + update: {}, + create: { + organisationId: nachbarId, + benutzerId: konto.id, + art: MitgliedschaftArt.AUFSICHT, + }, + }); + + log( + `Kreisebene: ${email} — Mitglied in ${ORGANISATION_KURZNAME}, ` + + `Aufsicht in ${NACHBAR_KURZNAME}.`, + ); +} + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -3946,6 +4185,9 @@ async function main(): Promise { // Phase 9 — Kassenverwaltung await kassendatenAnlegen(organisation.id); + // Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene + await kreisebeneAnlegen(organisation.id, katalog); + log("Fertig."); log(""); log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 82f25d9..438ad10 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -288,6 +288,14 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "Kassenprüfung mit Differenz und Erläuterung", angemeldet: true, }, + + // Phase 10 — Mandantenfähigkeit + { + name: "50-wehren", + pfad: "/wehren", + beschreibung: "Wehren dieser Installation, Konten und ihre Zuordnungen", + angemeldet: true, + }, ]; /** @@ -372,6 +380,18 @@ async function fertigGeladen(seite: Page): Promise { .addStyleTag({ content: "nextjs-portal { display: none !important; }" }) .catch(() => undefined); + /** + * Erst warten, dass React überhaupt gelaufen ist. + * + * Ohne diese Pause war die Bedingung unten in einem Fall erfüllt, in dem sie + * nichts bedeutet: „keine Ladeanzeige“ trifft auch zu, *bevor* sie erscheint. + * Bei einer Route, die der Entwicklungsserver erst übersetzen muss, kam der + * Auslöser genau in diesem Moment — und das Bild zeigte eine leere Seite mit + * Überschriften. + */ + await seite.waitForTimeout(1_500); + await seite.waitForLoadState("networkidle"); + try { await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, { timeout: 15_000, diff --git a/scripts/testdaten.ts b/scripts/testdaten.ts index b22088d..5fd30d2 100644 --- a/scripts/testdaten.ts +++ b/scripts/testdaten.ts @@ -53,8 +53,20 @@ async function main(): Promise { ); } - const vorhanden = await prisma.organisation.findUnique({ - where: { slug: SLUG }, + /** + * Auch die Nachbarwehr aus Phase 10 und das Konto der Kreisebene. + * + * Beide gehören zur Testwehr, liegen aber nicht in ihr: die Nachbarwehr ist + * eine eigene Organisation, das Konto gehört gar keiner. Blieben sie beim + * Zurücksetzen stehen, sähe der Seed die Nachbarwehr, hielte alles für + * erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an + * — der Wehrwähler zeigte danach nur noch die betreute Wehr. + */ + const NACHBAR_SLUG = `${SLUG}-nachbar`; + const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${SLUG}.de`; + + const vorhanden = await prisma.organisation.findFirst({ + where: { slug: { in: [SLUG, NACHBAR_SLUG] } }, select: { id: true, name: true }, }); @@ -79,7 +91,8 @@ async function main(): Promise { await tx.$executeRawUnsafe(`ALTER TABLE "${tabelle}" DISABLE TRIGGER USER`); } - await tx.organisation.delete({ where: { slug: SLUG } }); + await tx.organisation.deleteMany({ where: { slug: { in: [SLUG, NACHBAR_SLUG] } } }); + await tx.benutzer.deleteMany({ where: { email: KBI_EMAIL } }); for (const tabelle of [ "druckmessung", diff --git a/src/app/(app)/anmeldemarke/page.tsx b/src/app/(app)/anmeldemarke/page.tsx index 3b938d0..a207b3a 100644 --- a/src/app/(app)/anmeldemarke/page.tsx +++ b/src/app/(app)/anmeldemarke/page.tsx @@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() { const sitzung = await aktuelleSitzung(); if (!sitzung) redirect("/anmelden"); + // Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören + // einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen + // Ausweis für eine Wehr, der man nicht angehört, gibt es nicht. + if (!sitzung.personId) redirect("/"); + const marke = markeErzeugen(sitzung.personId, sitzung.organisationId); const bild = await qrTextAlsDataUri(marke, 420); diff --git a/src/app/(app)/ausweis/page.tsx b/src/app/(app)/ausweis/page.tsx index b34b32b..6f721fb 100644 --- a/src/app/(app)/ausweis/page.tsx +++ b/src/app/(app)/ausweis/page.tsx @@ -25,9 +25,18 @@ export default async function AusweisSeite() { const sitzung = await aktuelleSitzung(); if (!sitzung) redirect("/anmelden"); + // Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören + // einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen + // Ausweis für eine Wehr, der man nicht angehört, gibt es nicht. + if (!sitzung.personId) redirect("/"); + + // In eine eigene Konstante, damit die Einengung auch im Rueckruf gilt: + // TypeScript verfolgt sie ueber eine Eigenschaft hinweg nicht. + const eigeneAkteId = sitzung.personId; + const daten = await fuerOrganisation(sitzung.organisationId, async (db) => { const person = await db.person.findUnique({ - where: { id: sitzung.personId }, + where: { id: eigeneAkteId }, select: { vorname: true, nachname: true, @@ -48,7 +57,7 @@ export default async function AusweisSeite() { return person; }); - const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId); + const code = ausweisErzeugen(eigeneAkteId, sitzung.organisationId); const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`; const bild = await qrTextAlsDataUri(adresse, 420); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fcbb060..906e673 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth"; import { fuerOrganisation } from "@/lib/db"; import { rechteVon } from "@/lib/authz"; import { Seitenleiste } from "@/components/layout/seitenleiste"; +import { AufsichtHinweis } from "@/components/layout/wehrwaehler"; import { ThemeToggle } from "@/components/layout/theme-toggle"; import { TrpcProvider } from "@/lib/trpc/provider"; @@ -31,9 +32,11 @@ export default async function AngemeldetLayout({ rollen={sitzung.rollen} organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"} personName={sitzung.name} + istVerwalter={sitzung.istVerwalter ?? false} />
+
diff --git a/src/app/(app)/mitglieder/[id]/page.tsx b/src/app/(app)/mitglieder/[id]/page.tsx index c11cee7..f270bbf 100644 --- a/src/app/(app)/mitglieder/[id]/page.tsx +++ b/src/app/(app)/mitglieder/[id]/page.tsx @@ -48,7 +48,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id: eintrittsdatum: true, austrittsdatum: true, aktiv: true, - letzteAnmeldung: true, + // Seit Phase 10 hängt die Anmeldung am Konto, nicht an der Akte. Ob + // ein Mitglied überhaupt eines hat, ist die nützlichere Auskunft als + // ein Zeitstempel: ohne Konto kommt es nicht in FLORIAN hinein. + mitgliedschaft: { + select: { + art: true, + benutzer: { + select: { email: true, letzteAnmeldung: true, aktiv: true }, + }, + }, + }, rollen: { include: { rolle: true } }, qualifikationen: { where: { geloeschtAm: null }, @@ -86,6 +96,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id: "Anschrift", person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—", ], + [ + "Zugang", + person.mitgliedschaft?.benutzer ? ( + <> + {person.mitgliedschaft.benutzer.email} + + {person.mitgliedschaft.benutzer.aktiv + ? person.mitgliedschaft.benutzer.letzteAnmeldung + ? `zuletzt angemeldet am ${alsDatum(person.mitgliedschaft.benutzer.letzteAnmeldung)}` + : "noch nie angemeldet" + : "Konto abgeschaltet"} + + + ) : ( + <> + kein Konto + + Diese Person ist geführt, meldet sich aber nicht an. + + + ), + ], ]; /* eslint-enable react/jsx-key */ diff --git a/src/app/(app)/pruefungen/neu/page.tsx b/src/app/(app)/pruefungen/neu/page.tsx index 07e2bb9..2e1cde4 100644 --- a/src/app/(app)/pruefungen/neu/page.tsx +++ b/src/app/(app)/pruefungen/neu/page.tsx @@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({ redirect("/pruefungen"); } + // Eine Prüfung braucht einen Prüfer mit Namen und Akte — sie ist der + // Nachweis gegenüber der Unfallkasse. Das Recht oben schließt die + // Kreisaufsicht bereits aus; die Zusicherung steht hier trotzdem, weil + // sich Rechte verschieben lassen und ein Protokoll ohne Prüfer wertlos ist. + if (!sitzung.personId) redirect("/pruefungen"); + const { geraetId, terminId, vorlageId } = await searchParams; if (!geraetId) redirect("/pruefungen"); diff --git a/src/app/(app)/wehr-wechseln.ts b/src/app/(app)/wehr-wechseln.ts new file mode 100644 index 0000000..441e065 --- /dev/null +++ b/src/app/(app)/wehr-wechseln.ts @@ -0,0 +1,19 @@ +"use server"; + +import { sitzungNeuAusstellen } from "@/lib/auth"; + +/** + * In eine andere Wehr wechseln. + * + * Eine Server Action und keine tRPC-Mutation: der Wechsel ändert das + * Sitzungstoken im Cookie, und das kann nur die Anmeldeschicht. tRPC läuft + * über dieselbe Anfrage, hat aber keinen Zugriff auf das Setzen des Cookies. + * + * Übergeben wird die Kennung der **Mitgliedschaft**, nicht die der Wehr. Das + * ist der Unterschied zwischen einem Wunsch und einer Anweisung: eine fremde + * Wehr zu nennen bringt nichts, weil es dazu keine Mitgliedschaft dieses + * Kontos gibt. Geprüft wird das serverseitig im `jwt`-Rückruf, nicht hier. + */ +export async function wehrWechseln(mitgliedschaftId: string): Promise { + await sitzungNeuAusstellen({ user: { mitgliedschaftId } }); +} diff --git a/src/app/(app)/wehren/page.tsx b/src/app/(app)/wehren/page.tsx new file mode 100644 index 0000000..cf3c2bd --- /dev/null +++ b/src/app/(app)/wehren/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { Wehrverwaltung } from "@/app/(app)/wehren/verwaltung"; + +export const metadata = { title: "Wehren und Konten" }; + +/** + * Die Verwaltung der Installation. + * + * Der einzige Bereich, der über Mandantengrenzen hinweg arbeitet — und + * deshalb der einzige, dessen Zugang nicht an einem Recht innerhalb einer Wehr + * hängt, sondern am Konto selbst. + */ +export default async function WehrenSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/anmelden"); + if (!sitzung.istVerwalter) redirect("/"); + + return ( +
+
+

Wehren und Konten

+

+ Wehren dieser Installation, die Anmeldekonten und wer zu welcher Wehr gehört. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/wehren/verwaltung.tsx b/src/app/(app)/wehren/verwaltung.tsx new file mode 100644 index 0000000..c2c78e0 --- /dev/null +++ b/src/app/(app)/wehren/verwaltung.tsx @@ -0,0 +1,611 @@ +"use client"; + +import * as React from "react"; +import { Building2, Eye, KeyRound, Plus, Trash2, UserPlus } from "lucide-react"; +import { MitgliedschaftArt } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige"; +import { alsDatum } from "@/lib/utils"; + +/** + * Wehren und Konten. + * + * Zwei Listen, die zusammengehören: eine Wehr ohne Konten kann niemand + * benutzen, und ein Konto ohne Mitgliedschaft kommt nirgends hinein. Deshalb + * stehen sie auf einer Seite und nicht in zwei Menüpunkten. + */ +export function Wehrverwaltung() { + const werkzeuge = trpc.useUtils(); + + const [wehrOffen, setWehrOffen] = React.useState(false); + const [kontoOffen, setKontoOffen] = React.useState(false); + const [zuordnen, setZuordnen] = React.useState<{ id: string; email: string } | null>(null); + const [meldung, setMeldung] = React.useState(null); + const [hinweis, setHinweis] = React.useState(null); + + const wehren = trpc.wehrverwaltung.wehren.useQuery(); + const konten = trpc.wehrverwaltung.konten.useQuery(); + + const entfernen = trpc.wehrverwaltung.mitgliedschaftEntfernen.useMutation({ + onSuccess: () => { + setHinweis("Zuordnung entfernt. Die Personenakte in der Wehr bleibt bestehen."); + void werkzeuge.wehrverwaltung.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + return ( +
+ {meldung && } + + {/* Eine gescheiterte Abfrage sähe sonst aus wie „es gibt nichts" — und + genau dieser Unterschied ist hier wichtig, weil eine leere + Wehrenliste ein plausibler Zustand ist. */} + {(wehren.error || konten.error) && ( + + )} + {hinweis && ( +
+ {hinweis} +
+ )} + + {/* --- Wehren ---------------------------------------------------- */} +
+
+

Wehren

+ +
+ +
+ {wehren.isLoading && + [0, 1, 2].map((stelle) => ( +
+ ))} + + {wehren.data?.map((wehr) => ( + + +
+ {wehr.kurzname} + {!wehr.aktiv && abgeschaltet} +
+ {wehr.name} +
+ +

+ Kennung {wehr.slug} + {wehr.ort && ` · ${wehr.ort}`} +

+

+ {wehr._count.personen} {wehr._count.personen === 1 ? "Person" : "Personen"} ·{" "} + {wehr._count.mitgliedschaften}{" "} + {wehr._count.mitgliedschaften === 1 ? "Konto" : "Konten"} +

+
+
+ ))} +
+
+ + {/* --- Konten ---------------------------------------------------- */} +
+
+

Konten

+ +
+ +

+ Ein Konto gehört keiner Wehr — es gehört zu einer oder mehreren. Wer in zwei Wehren + geführt wird, bekommt deshalb kein zweites Konto, sondern eine zweite Zuordnung. +

+ + {!konten.isLoading && (konten.data?.length ?? 0) === 0 && ( + + )} + +
+ {konten.isLoading && + [0, 1].map((stelle) => ( +
+ ))} + + {konten.data?.map((konto) => ( + + +
+
+ {konto.email} + + {konto.letzteAnmeldung + ? `zuletzt angemeldet am ${alsDatum(konto.letzteAnmeldung)}` + : "noch nie angemeldet"} + {konto.oidcSubject && " · über authentik verknüpft"} + +
+ +
+ {konto.istVerwalter && Verwalter} + {!konto.aktiv && abgeschaltet} + +
+
+
+ + + {konto.mitgliedschaften.length === 0 ? ( +

+ Keiner Wehr zugeordnet — die Anmeldung führt damit nirgendwohin. +

+ ) : ( +
    + {konto.mitgliedschaften.map((eintrag) => ( +
  • + {eintrag.art === MitgliedschaftArt.AUFSICHT ? ( + + ) : ( + + )} + + {eintrag.organisation.kurzname} + + + {eintrag.art === MitgliedschaftArt.AUFSICHT + ? "Kreisaufsicht — nur lesen" + : eintrag.person + ? `${eintrag.person.nachname}, ${eintrag.person.vorname}` + : "Mitglied"} + + + +
  • + ))} +
+ )} +
+
+ ))} +
+
+ + setWehrOffen(false)} + fehler={setMeldung} + fertig={(name) => { + setWehrOffen(false); + setHinweis(`${name} angelegt, samt Rollen. Jetzt fehlt noch ein Konto dafür.`); + void werkzeuge.wehrverwaltung.invalidate(); + }} + /> + + setKontoOffen(false)} + fehler={setMeldung} + fertig={(email) => { + setKontoOffen(false); + setHinweis(`Konto ${email} angelegt. Es gehört noch keiner Wehr an.`); + void werkzeuge.wehrverwaltung.invalidate(); + }} + /> + + setZuordnen(null)} + fehler={setMeldung} + fertig={() => { + setZuordnen(null); + void werkzeuge.wehrverwaltung.invalidate(); + }} + /> +
+ ); +} + +function WehrAnlegen({ + offen, + schliessen, + fertig, + fehler, +}: { + offen: boolean; + schliessen: () => void; + fertig: (name: string) => void; + fehler: (nachricht: string) => void; +}) { + const [slug, setSlug] = React.useState(""); + const [name, setName] = React.useState(""); + const [kurzname, setKurzname] = React.useState(""); + const [ort, setOrt] = React.useState(""); + const [landkreis, setLandkreis] = React.useState(""); + + const anlegen = trpc.wehrverwaltung.wehrAnlegen.useMutation({ + onSuccess: (wehr) => { + setSlug(""); + setName(""); + setKurzname(""); + setOrt(""); + setLandkreis(""); + fertig(wehr.kurzname); + }, + onError: (aufruf) => fehler(aufruf.message), + }); + + return ( + !zustand && schliessen()}> + + + Wehr anlegen + + Sie bekommt sofort ihre Systemrollen. Ein Konto bekommt sie nicht — wer sie führen + soll, wird im zweiten Schritt zugeordnet. + + + +
+
+ + setName(ereignis.target.value)} + placeholder="Freiwillige Feuerwehr Musterhausen" + /> +
+ +
+
+ + setKurzname(ereignis.target.value)} + placeholder="FF Musterhausen" + /> +
+ +
+ + setSlug(ereignis.target.value.toLowerCase())} + placeholder="ff-musterhausen" + /> +

+ Kleinbuchstaben, Ziffern, Bindestriche. Sie steht später im Terminal und in + Dateipfaden und lässt sich nicht mehr ändern. +

+
+ +
+ + setOrt(ereignis.target.value)} + /> +
+ +
+ + setLandkreis(ereignis.target.value)} + /> +
+
+
+ + + + + +
+
+ ); +} + +function KontoAnlegen({ + offen, + schliessen, + fertig, + fehler, +}: { + offen: boolean; + schliessen: () => void; + fertig: (email: string) => void; + fehler: (nachricht: string) => void; +}) { + const [email, setEmail] = React.useState(""); + const [passwort, setPasswort] = React.useState(""); + const [verwalter, setVerwalter] = React.useState(false); + + const anlegen = trpc.wehrverwaltung.kontoAnlegen.useMutation({ + onSuccess: (konto) => { + setEmail(""); + setPasswort(""); + setVerwalter(false); + fertig(konto.email); + }, + onError: (aufruf) => fehler(aufruf.message), + }); + + return ( + !zustand && schliessen()}> + + + Konto anlegen + + Die E-Mail-Adresse ist die Anmeldung und gilt über alle Wehren hinweg. Das Passwort + geben Sie der Person selbst weiter — FLORIAN verschickt keines. + + + +
+
+ + setEmail(ereignis.target.value)} + /> +
+ +
+ + setPasswort(ereignis.target.value)} + placeholder="mindestens zwölf Zeichen" + /> +

+ Steht im Klartext, damit Sie es weitergeben können. Es gehört nach der ersten + Anmeldung geändert. +

+
+ + +
+ + + + + +
+
+ ); +} + +/** Ein Konto einer Wehr zuordnen — als Mitglied oder als Kreisaufsicht. */ +function WehrZuordnen({ + konto, + schliessen, + fertig, + fehler, +}: { + konto: { id: string; email: string } | null; + schliessen: () => void; + fertig: () => void; + fehler: (nachricht: string) => void; +}) { + const [organisationId, setOrganisationId] = React.useState(""); + const [art, setArt] = React.useState(MitgliedschaftArt.MITGLIED); + const [personId, setPersonId] = React.useState(""); + + const wehren = trpc.wehrverwaltung.wehren.useQuery(undefined, { enabled: konto !== null }); + const personen = trpc.wehrverwaltung.personenIn.useQuery( + { organisationId }, + { enabled: konto !== null && Boolean(organisationId) && art === MitgliedschaftArt.MITGLIED }, + ); + + const anlegen = trpc.wehrverwaltung.mitgliedschaftAnlegen.useMutation({ + onSuccess: () => { + setOrganisationId(""); + setPersonId(""); + setArt(MitgliedschaftArt.MITGLIED); + fertig(); + }, + onError: (aufruf) => fehler(aufruf.message), + }); + + // Die Wehr wechselt, die vorher gewählte Akte gehört zur alten. + React.useEffect(() => setPersonId(""), [organisationId, art]); + + return ( + !zustand && schliessen()}> + + + Wehr zuordnen + + {konto?.email} — als Mitglied mit eigener Akte oder als Kreisaufsicht, die nur + zusieht. + + + +
+
+ + +
+ +
+ {Object.values(MitgliedschaftArt).map((wert) => ( + + ))} +
+ + {art === MitgliedschaftArt.MITGLIED ? ( +
+ + +

+ Zur Auswahl steht, wer noch kein Konto hat. Eine Akte gehört zu höchstens einem + — sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat. +

+
+ ) : ( +

+ Die Kreisaufsicht bekommt keine Personenakte. Sie gehört der Wehr + nicht an, taucht in keiner Mitgliederliste auf und kann dort nichts ändern — auch + die Kasse und das Protokoll bleiben ihr verschlossen. +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/app/api/dateien/route.ts b/src/app/api/dateien/route.ts index e8de282..eb55a8f 100644 --- a/src/app/api/dateien/route.ts +++ b/src/app/api/dateien/route.ts @@ -107,6 +107,19 @@ export async function POST(anfrage: Request) { ); } + // Wer hochlädt, muss eine Akte in dieser Wehr haben: die Datei bekommt + // einen Urheber, und „irgendwer aus der Kreisaufsicht“ ist keiner. + if (!sitzung.personId) { + return NextResponse.json( + { + fehler: + "Die Kreisaufsicht hat in dieser Wehr keine Mitgliedschaft und kann deshalb " + + "keine Dateien hochladen.", + }, + { status: 403 }, + ); + } + const inhalt = Buffer.from(await datei.arrayBuffer()); try { diff --git a/src/components/layout/seitenleiste.tsx b/src/components/layout/seitenleiste.tsx index 05daeeb..87b590b 100644 --- a/src/components/layout/seitenleiste.tsx +++ b/src/components/layout/seitenleiste.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Album, + Building2, BellRing, BookMarked, BookOpenCheck, @@ -30,6 +31,7 @@ import { X, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Wehrwaehler } from "@/components/layout/wehrwaehler"; import { Button } from "@/components/ui/button"; import type { Recht, Rolle } from "@/lib/authz"; @@ -52,6 +54,8 @@ interface Eintrag { rechte?: Recht[]; /** Ab welcher Phase es die Ansicht wirklich gibt. */ abPhase?: number; + /** Nur für den, der die Installation verwaltet — nicht über ein Recht. */ + nurVerwalter?: boolean; } const EINTRAEGE: Eintrag[] = [ @@ -174,27 +178,50 @@ const EINTRAEGE: Eintrag[] = [ rechte: ["system.auditlog"], abPhase: 8, }, + { + // Ganz unten und nur für den Betreiber: der einzige Eintrag, der über die + // eigene Wehr hinausreicht. + titel: "Wehren und Konten", + pfad: "/wehren", + symbol: Building2, + nurVerwalter: true, + abPhase: 10, + }, ]; /** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist * sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */ -const AKTUELLE_PHASE = 9; +const AKTUELLE_PHASE = 10; export interface SeitenleisteProps { rechte: Recht[]; rollen: Rolle[]; organisationName: string; personName: string; + /** + * Verwaltet die Installation. + * + * Kein Recht, sondern ein Merkmal des Kontos — Rechte gelten je Wehr, und + * wer Wehren anlegt, steht über allen. Deshalb kommt es hier als eigene + * Angabe an und nicht über `rechte`. + */ + istVerwalter?: boolean; } -export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) { +export function Seitenleiste({ + rechte, + organisationName, + personName, + istVerwalter = false, +}: SeitenleisteProps) { const pfad = usePathname(); const [offen, setOffen] = React.useState(false); const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]); - const sichtbar = EINTRAEGE.filter( - (eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)), - ); + const sichtbar = EINTRAEGE.filter((eintrag) => { + if (eintrag.nurVerwalter) return istVerwalter; + return !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)); + }); return ( <> @@ -221,9 +248,7 @@ export function Seitenleiste({ rechte, organisationName, personName }: Seitenlei

FLORIAN

-

- {organisationName} -

+
diff --git a/src/components/layout/wehrwaehler.tsx b/src/components/layout/wehrwaehler.tsx new file mode 100644 index 0000000..6086acb --- /dev/null +++ b/src/components/layout/wehrwaehler.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Building2, Check, ChevronsUpDown, Eye } from "lucide-react"; +import { trpc } from "@/lib/trpc/client"; +import { wehrWechseln } from "@/app/(app)/wehr-wechseln"; +import { cn } from "@/lib/utils"; + +/** + * Der Wehrwähler. + * + * Er erscheint **nur**, wenn das Konto zu mehr als einer Wehr gehört. Für die + * allermeisten ist das nie der Fall, und ein Auswahlfeld mit genau einem + * Eintrag ist kein Angebot, sondern eine Frage ohne Antwortmöglichkeit. + * + * Der Wechsel läuft über eine Aktualisierung der Sitzung und nicht über einen + * Aufruf, der irgendwo eine Wehr „setzt": das Sitzungstoken trägt die Wehr, + * also muss es neu ausgestellt werden. Welche Wehr dabei zulässig ist, + * entscheidet der Server anhand der Mitgliedschaften des Kontos — was hier + * geschickt wird, ist ein Wunsch, keine Anweisung. + */ +export function Wehrwaehler({ organisationName }: { organisationName: string }) { + const router = useRouter(); + const [offen, setOffen] = React.useState(false); + const [wechseltZu, setWechseltZu] = React.useState(null); + + const ich = trpc.sitzung.ich.useQuery(); + const werkzeuge = trpc.useUtils(); + + const wehren = ich.data?.wehren ?? []; + const aktive = ich.data?.mitgliedschaftId ?? null; + + // Eine Wehr — dann gibt es nichts zu wählen, und der Name steht schlicht da. + if (wehren.length <= 1) { + return ( +

{organisationName}

+ ); + } + + async function wechseln(mitgliedschaftId: string) { + if (mitgliedschaftId === aktive) { + setOffen(false); + return; + } + + setWechseltZu(mitgliedschaftId); + try { + await wehrWechseln(mitgliedschaftId); + // Alles neu holen: die zwischengespeicherten Antworten gehören zur + // vorherigen Wehr. Sie stehen zu lassen wäre der sicherste Weg, Daten + // der einen Wehr unter dem Namen der anderen anzuzeigen. + await werkzeuge.invalidate(); + router.refresh(); + } finally { + setWechseltZu(null); + setOffen(false); + } + } + + return ( +
+ + + {offen && ( + <> + + + ); + })} + + + )} +
+ ); +} + +/** + * Der Hinweis, dass hier nichts geändert werden kann. + * + * Er steht bewusst dauerhaft und nicht als kurz aufblitzende Meldung: wer als + * Kreisaufsicht in einer fremden Wehr unterwegs ist, soll das nicht erst + * merken, wenn ein Knopf fehlt. + */ +export function AufsichtHinweis() { + const ich = trpc.sitzung.ich.useQuery(); + + if (!ich.data?.nurLesend) return null; + + return ( +
+ + + Sie sehen diese Wehr als Kreisaufsicht. Geändert werden kann hier + nichts — auch die Kasse und das Protokoll bleiben der Wehr vorbehalten. + +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 93259c1..c668d44 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -48,6 +48,93 @@ async function personMitRollen(personId: string) { }); } +/** + * Alle Wehren, zu denen ein Konto gehört. + * + * Ohne RLS, denn genau hier ist der Mandant noch nicht bekannt — das ist der + * einzige Zweck des Systemzugangs. Nach der Wahl einer Mitgliedschaft läuft + * wieder alles über `fuerOrganisation`. + */ +async function mitgliedschaftenVon(benutzerId: string) { + return prismaSystem.mitgliedschaft.findMany({ + where: { + benutzerId, + aktiv: true, + organisation: { aktiv: true, geloeschtAm: null }, + }, + include: { + organisation: { select: { id: true, slug: true, name: true, kurzname: true } }, + person: { + select: { id: true, vorname: true, nachname: true, aktiv: true, geloeschtAm: true }, + }, + }, + orderBy: [{ art: "asc" }, { organisation: { name: "asc" } }], + }); +} + +/** + * Aus einer Mitgliedschaft die Angaben für die Sitzung machen. + * + * Zwei Fälle, und der Unterschied ist der Kern dieser Phase: + * + * MITGLIED — es gibt eine Akte, und deren Rollen gelten. + * AUFSICHT — es gibt keine Akte. Die Kreisebene bekommt genau eine Rolle, + * und die trägt nur Leserechte. Sie hier zu setzen statt in der + * Oberfläche ist Absicht: eine Aufsicht, die schreiben darf, + * weil irgendwo eine Prüfung fehlt, wäre ein Mandantenleck. + */ +async function sitzungAus(mitgliedschaft: { + id: string; + art: "MITGLIED" | "AUFSICHT"; + organisationId: string; + personId: string | null; +}): Promise<{ + personId: string | null; + organisationId: string; + mitgliedschaftId: string; + name: string | null; + rollen: Rolle[]; + nurLesend: boolean; +} | null> { + if (mitgliedschaft.art === "AUFSICHT") { + return { + personId: null, + organisationId: mitgliedschaft.organisationId, + mitgliedschaftId: mitgliedschaft.id, + name: null, + rollen: ["AUFSICHT"], + nurLesend: true, + }; + } + + if (!mitgliedschaft.personId) return null; + + const person = await personMitRollen(mitgliedschaft.personId); + if (!person || !person.organisation.aktiv) return null; + + return { + personId: person.id, + organisationId: person.organisationId, + mitgliedschaftId: mitgliedschaft.id, + name: `${person.vorname} ${person.nachname}`, + rollen: rollenAus(person.rollen), + nurLesend: false, + }; +} + +/** + * Welche Wehr nach der Anmeldung offen ist. + * + * Die eigene vor der betreuten: wer Mitglied einer Wehr ist und daneben acht + * andere beaufsichtigt, will in seiner Wehr landen. Wechseln kann er danach + * mit einem Klick. + */ +function ersteMitgliedschaft( + alle: T[], +): T | undefined { + return alle.find((eintrag) => eintrag.art === "MITGLIED") ?? alle[0]; +} + function rollenAus( eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>, ): Rolle[] { @@ -60,19 +147,24 @@ function rollenAus( } /** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */ -function istGesperrt(person: { gesperrtBis: Date | null }): boolean { - return person.gesperrtBis !== null && person.gesperrtBis > new Date(); +function istGesperrt(konto: { gesperrtBis: Date | null }): boolean { + return konto.gesperrtBis !== null && konto.gesperrtBis > new Date(); } -/** Fehlversuch zählen und ab der Grenze sperren. */ -async function fehlversuchVermerken(personId: string): Promise { - const person = await prismaSystem.person.findUnique({ - where: { id: personId }, +/** + * Fehlversuch zählen und ab der Grenze sperren. + * + * Am Konto, nicht an der Akte: sonst könnte man dieselbe Anmeldung über eine + * zweite Wehr weiterprobieren, in der derselbe Mensch eine zweite Akte hat. + */ +async function fehlversuchVermerken(benutzerId: string): Promise { + const konto = await prismaSystem.benutzer.findUnique({ + where: { id: benutzerId }, select: { fehlversuche: true }, }); - const neu = (person?.fehlversuche ?? 0) + 1; - await prismaSystem.person.update({ - where: { id: personId }, + const neu = (konto?.fehlversuche ?? 0) + 1; + await prismaSystem.benutzer.update({ + where: { id: benutzerId }, data: { fehlversuche: neu, gesperrtBis: @@ -83,9 +175,9 @@ async function fehlversuchVermerken(personId: string): Promise { }); } -async function anmeldungVermerken(personId: string): Promise { - await prismaSystem.person.update({ - where: { id: personId }, +async function anmeldungVermerken(benutzerId: string): Promise { + await prismaSystem.benutzer.update({ + where: { id: benutzerId }, data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null }, }); } @@ -122,64 +214,75 @@ const passwortAnbieter = Credentials({ const { email, passwort } = geprueft.data; - // Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist - // der Mandant noch nicht bekannt. - const person = await prismaSystem.person.findFirst({ + // Die E-Mail-Suche geht über die Konten, nicht über die Personenakten: + // seit Phase 10 ist sie dort global eindeutig. Vorher war sie es nur je + // Wehr, und wer in zwei Wehren stand, meldete sich in einer zufälligen an. + const konto = await prismaSystem.benutzer.findFirst({ where: { email: email.toLowerCase(), aktiv: true, geloeschtAm: null }, - include: { - rollen: { include: { rolle: true } }, - organisation: { select: { id: true, aktiv: true } }, - }, }); - if (!person || !person.organisation.aktiv) { + if (!konto) { // Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt. await pruefen(passwort, null); return null; } - if (istGesperrt(person)) { - await protokolliereAnmeldung({ - organisationId: person.organisationId, - personId: person.id, - name: `${person.vorname} ${person.nachname}`, - anmeldeArt: AnmeldeArt.LOKAL, - erfolgreich: false, - grund: "Konto vorübergehend gesperrt", - }); + const mitgliedschaften = await mitgliedschaftenVon(konto.id); + const gewaehlt = ersteMitgliedschaft(mitgliedschaften); + + // Ein Konto ohne Mitgliedschaft ist ausgetreten oder noch nicht + // zugeordnet. Es kommt bis zur Wehr gar nicht, deshalb gibt es auch + // nichts zu protokollieren — das Audit-Log hängt an einer Organisation. + if (!gewaehlt) { + await pruefen(passwort, null); return null; } - const stimmt = await pruefen(passwort, person.passwortHash); + const melden = (erfolgreich: boolean, grund?: string) => + protokolliereAnmeldung({ + organisationId: gewaehlt.organisationId, + personId: gewaehlt.personId, + name: gewaehlt.person + ? `${gewaehlt.person.vorname} ${gewaehlt.person.nachname}` + : konto.email, + anmeldeArt: AnmeldeArt.LOKAL, + erfolgreich, + grund, + }); + + if (istGesperrt(konto)) { + await melden(false, "Konto vorübergehend gesperrt"); + return null; + } + + const stimmt = await pruefen(passwort, konto.passwortHash); if (!stimmt) { - await fehlversuchVermerken(person.id); - await protokolliereAnmeldung({ - organisationId: person.organisationId, - personId: person.id, - name: `${person.vorname} ${person.nachname}`, - anmeldeArt: AnmeldeArt.LOKAL, - erfolgreich: false, - grund: "Falsches Passwort", - }); + await fehlversuchVermerken(konto.id); + await melden(false, "Falsches Passwort"); return null; } - await anmeldungVermerken(person.id); - await protokolliereAnmeldung({ - organisationId: person.organisationId, - personId: person.id, - name: `${person.vorname} ${person.nachname}`, - anmeldeArt: AnmeldeArt.LOKAL, - erfolgreich: true, - }); + const sitzung = await sitzungAus(gewaehlt); + if (!sitzung) { + await melden(false, "Personenakte in dieser Wehr nicht nutzbar"); + return null; + } + + await anmeldungVermerken(konto.id); + await melden(true); return { - id: person.id, - name: `${person.vorname} ${person.nachname}`, - email: person.email ?? undefined, - organisationId: person.organisationId, - rollen: rollenAus(person.rollen), + id: sitzung.personId ?? konto.id, + name: sitzung.name ?? konto.email, + email: konto.email, + organisationId: sitzung.organisationId, + rollen: sitzung.rollen, anmeldeArt: "LOKAL" as const, + benutzerId: konto.id, + mitgliedschaftId: sitzung.mitgliedschaftId, + personId: sitzung.personId, + nurLesend: sitzung.nurLesend, + istVerwalter: konto.istVerwalter, }; }, }); @@ -383,36 +486,41 @@ interface OidcProfil { /** * Verknüpft ein authentik-Konto mit einer Person. Reihenfolge: - * 1. bereits verknüpft (oidcSubject) + * 1. bereits verknüpft (oidcSubject am Konto) * 2. E-Mail-Adresse stimmt überein -> verknüpfen - * 3. AUTH_OIDC_AUTO_PROVISION -> neue Person anlegen + * 3. AUTH_OIDC_AUTO_PROVISION -> neues Konto samt Akte anlegen * Sonst: Anmeldung abgelehnt. + * + * Seit Phase 10 hängt das OIDC-Subject am Konto, nicht an der Akte. Wer über + * authentik kommt und in zwei Wehren geführt wird, ist damit derselbe Mensch + * und nicht zwei — vorher entschied die Reihenfolge in der Datenbank, welche + * Akte gefunden wurde. */ async function personAusOidc(profil: OidcProfil): Promise<{ - personId: string; + personId: string | null; organisationId: string; name: string; rollen: Rolle[]; } | null> { const email = profil.email?.toLowerCase(); - const vorhanden = await prismaSystem.person.findFirst({ + const vorhanden = await prismaSystem.benutzer.findFirst({ where: { OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])], geloeschtAm: null, }, - include: { - rollen: { include: { rolle: true } }, - organisation: { select: { id: true, aktiv: true } }, - }, }); const gruppen = gruppenAusProfil(profil); if (vorhanden) { - if (!vorhanden.aktiv || !vorhanden.organisation.aktiv) return null; + if (!vorhanden.aktiv) return null; - await prismaSystem.person.update({ + const mitgliedschaften = await mitgliedschaftenVon(vorhanden.id); + const gewaehlt = ersteMitgliedschaft(mitgliedschaften); + if (!gewaehlt) return null; + + await prismaSystem.benutzer.update({ where: { id: vorhanden.id }, data: { oidcSubject: profil.sub, @@ -424,17 +532,21 @@ async function personAusOidc(profil: OidcProfil): Promise<{ }); // Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von - // Hand vergeben hat, bleiben unangetastet. - await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen); + // Hand vergeben hat, bleiben unangetastet. Und sie wirken nur auf die + // Wehr, in der die Akte liegt — eine authentik-Gruppe darf nicht über + // Mandantengrenzen hinweg Rollen verteilen. + if (gewaehlt.personId) { + await gruppenRollenVorbelegen(gewaehlt.organisationId, gewaehlt.personId, gruppen); + } - const aktualisiert = await personMitRollen(vorhanden.id); - if (!aktualisiert) return null; + const sitzung = await sitzungAus(gewaehlt); + if (!sitzung) return null; return { - personId: aktualisiert.id, - organisationId: aktualisiert.organisationId, - name: `${aktualisiert.vorname} ${aktualisiert.nachname}`, - rollen: rollenAus(aktualisiert.rollen), + personId: sitzung.personId, + organisationId: sitzung.organisationId, + name: sitzung.name ?? vorhanden.email, + rollen: sitzung.rollen, }; } @@ -458,20 +570,37 @@ async function personAusOidc(profil: OidcProfil): Promise<{ const nachname = profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt"; + // Ohne E-Mail kein Konto: sie ist die Kennung, unter der es geführt wird. + if (!email) { + console.error("[auth] OIDC-Profil ohne E-Mail — Konto kann nicht angelegt werden."); + return null; + } + const neu = await fuerOrganisation(organisation.id, (db) => db.person.create({ data: { organisationId: organisation.id, vorname, nachname, - email: email ?? null, - oidcSubject: profil.sub, - oidcGruppen: gruppen, - letzteAnmeldung: new Date(), + email, }, }), ); + // Konto und Mitgliedschaft über den Systemzugang: `benutzer` steht über + // allen Mandanten, und die Anwendungsrolle darf dort nichts anlegen. + await prismaSystem.benutzer.create({ + data: { + email, + oidcSubject: profil.sub, + oidcGruppen: gruppen, + letzteAnmeldung: new Date(), + mitgliedschaften: { + create: { organisationId: organisation.id, personId: neu.id }, + }, + }, + }); + await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen); const mitRollen = await personMitRollen(neu.id); @@ -581,7 +710,7 @@ export const authConfig: NextAuthConfig = { return true; }, - async jwt({ token, user, account, trigger }) { + async jwt({ token, user, account, trigger, session }) { // Erste Anmeldung über einen Credentials-Anbieter. if (user && "organisationId" in user) { const angemeldet = user as unknown as { @@ -590,44 +719,122 @@ export const authConfig: NextAuthConfig = { organisationId: string; rollen: Rolle[]; anmeldeArt: Sitzung["anmeldeArt"]; + benutzerId?: string; + mitgliedschaftId?: string; + personId?: string | null; + nurLesend?: boolean; + istVerwalter?: boolean; }; - token.personId = angemeldet.id; + // `id` trägt bei einer Aufsichtssitzung die Kontokennung, weil es + // keine Akte gibt. `personId` ist deshalb das verlässliche Feld. + token.personId = angemeldet.personId ?? angemeldet.id; token.organisationId = angemeldet.organisationId; token.rollen = angemeldet.rollen; token.anmeldeArt = angemeldet.anmeldeArt; + token.benutzerId = angemeldet.benutzerId; + token.mitgliedschaftId = angemeldet.mitgliedschaftId; + token.nurLesend = angemeldet.nurLesend ?? false; + token.istVerwalter = angemeldet.istVerwalter ?? false; token.geladenAm = Date.now(); return token; } // Erste Anmeldung über OIDC — hier steht das Profil im account/profile. if (account?.provider === "authentik" && token.sub) { - const person = await prismaSystem.person.findFirst({ + const konto = await prismaSystem.benutzer.findFirst({ where: { oidcSubject: token.sub, geloeschtAm: null }, - include: { rollen: { include: { rolle: true } } }, }); - if (person) { - token.personId = person.id; - token.organisationId = person.organisationId; - token.rollen = rollenAus(person.rollen); - token.anmeldeArt = "OIDC"; + if (konto) { + const gewaehlt = ersteMitgliedschaft(await mitgliedschaftenVon(konto.id)); + const sitzung = gewaehlt ? await sitzungAus(gewaehlt) : null; + + if (sitzung) { + token.personId = sitzung.personId ?? undefined; + token.organisationId = sitzung.organisationId; + token.rollen = sitzung.rollen; + token.anmeldeArt = "OIDC"; + token.benutzerId = konto.id; + token.mitgliedschaftId = sitzung.mitgliedschaftId; + token.nurLesend = sitzung.nurLesend; + token.istVerwalter = konto.istVerwalter; + token.geladenAm = Date.now(); + } + } + return token; + } + + /** + * Nachladen. + * + * Zwei Anlässe: eine Rollenänderung soll nicht bis zum nächsten Login + * warten, und ein Wehrwechsel muss sofort greifen. Beim Wechsel setzt + * die Oberfläche `wehrWechselAuf` und stösst ein `update` an — die + * Prüfung, ob die Mitgliedschaft dem Konto überhaupt gehört, findet + * hier statt und nicht dort. Alles andere wäre eine Einladung, sich + * über einen selbst gewählten Wert in eine fremde Wehr zu schreiben. + */ + /** + * Der Wunsch, in eine andere Wehr zu wechseln. + * + * Er kommt aus der Server Action und ist eine Behauptung: geprueft wird + * hier, gegen die Mitgliedschaften des angemeldeten Kontos. Wer eine + * fremde Kennung schickt, bekommt seine bisherige Sitzung zurueck. + */ + const wunsch = session as { user?: { mitgliedschaftId?: unknown } } | undefined; + const gewuenschteWehr = + trigger === "update" && typeof wunsch?.user?.mitgliedschaftId === "string" + ? wunsch.user.mitgliedschaftId + : null; + + if (gewuenschteWehr && typeof token.benutzerId === "string") { + const ziel = (await mitgliedschaftenVon(token.benutzerId)).find( + (eintrag) => eintrag.id === gewuenschteWehr, + ); + + if (!ziel) { + console.warn("[auth] Wehrwechsel auf eine fremde Mitgliedschaft abgelehnt."); + return token; + } + + const sitzung = await sitzungAus(ziel); + if (sitzung) { + token.personId = sitzung.personId ?? undefined; + token.organisationId = sitzung.organisationId; + token.rollen = sitzung.rollen; + token.mitgliedschaftId = sitzung.mitgliedschaftId; + token.nurLesend = sitzung.nurLesend; token.geladenAm = Date.now(); } return token; } - // Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet. const veraltet = typeof token.geladenAm !== "number" || Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS; - if (trigger === "update" || (veraltet && typeof token.personId === "string")) { - const person = await personMitRollen(token.personId as string); - if (!person) { - // Person deaktiviert oder gelöscht: Sitzung entwerten. + if ( + (trigger === "update" || veraltet) && + typeof token.mitgliedschaftId === "string" && + typeof token.benutzerId === "string" + ) { + const aktuell = (await mitgliedschaftenVon(token.benutzerId)).find( + (eintrag) => eintrag.id === token.mitgliedschaftId, + ); + + // Mitgliedschaft beendet, Konto oder Wehr abgeschaltet: entwerten. + if (!aktuell) { return { ...token, personId: undefined, organisationId: undefined, rollen: [] }; } - token.rollen = rollenAus(person.rollen); - token.organisationId = person.organisationId; + + const sitzung = await sitzungAus(aktuell); + if (!sitzung) { + return { ...token, personId: undefined, organisationId: undefined, rollen: [] }; + } + + token.personId = sitzung.personId ?? undefined; + token.organisationId = sitzung.organisationId; + token.rollen = sitzung.rollen; + token.nurLesend = sitzung.nurLesend; token.geladenAm = Date.now(); } @@ -641,13 +848,30 @@ export const authConfig: NextAuthConfig = { organisationId: (token.organisationId as string) ?? "", rollen: (token.rollen as Rolle[]) ?? [], anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL", + benutzerId: token.benutzerId as string | undefined, + mitgliedschaftId: token.mitgliedschaftId as string | undefined, + nurLesend: (token.nurLesend as boolean | undefined) ?? false, + istVerwalter: (token.istVerwalter as boolean | undefined) ?? false, }; return session; }, }, }; -export const { handlers, auth, signIn, signOut } = NextAuth(authConfig); +export const { + handlers, + auth, + signIn, + signOut, + /** + * Stellt das Sitzungstoken neu aus. + * + * Der einzige Weg, die aktive Wehr zu wechseln: sie steht im Token, also + * muss ein neues her. Ohne `SessionProvider` im Browser läuft das über eine + * Server Action — siehe `src/app/(app)/wehr-wechseln.ts`. + */ + unstable_update: sitzungNeuAusstellen, +} = NextAuth(authConfig); /** * Die Sitzung, wie der Rest der Anwendung sie erwartet. Gibt null zurück, @@ -655,13 +879,30 @@ export const { handlers, auth, signIn, signOut } = NextAuth(authConfig); */ export async function aktuelleSitzung(): Promise { const sitzung = await auth(); - if (!sitzung?.user?.id || !sitzung.user.organisationId) return null; + if (!sitzung?.user?.organisationId) return null; + + /** + * Eine Sitzung ohne Personenakte ist gueltig — seit Phase 10. + * + * Die Kreisaufsicht gehoert der Wehr nicht an und hat dort keine Akte. Bis + * hierher galt genau das als „nicht angemeldet“, und die Folge war, dass + * jeder Aufruf nach dem Wechsel auf der Anmeldeseite endete. Was eine + * Sitzung ausmacht, ist die Wehr und das Konto — nicht die Akte. + */ + if (!sitzung.user.id && !sitzung.user.benutzerId) return null; return { - personId: sitzung.user.id, + // Bei einer Aufsichtssitzung gibt es keine Akte in dieser Wehr. Dann + // steht hier null, und jede Stelle, die einen Urheber festhalten will, + // muss sich dazu verhalten — statt eine fremde Kennung einzutragen. + personId: sitzung.user.nurLesend ? null : sitzung.user.id, organisationId: sitzung.user.organisationId, name: sitzung.user.name ?? "Unbekannt", rollen: sitzung.user.rollen ?? [], anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL", + benutzerId: sitzung.user.benutzerId, + mitgliedschaftId: sitzung.user.mitgliedschaftId, + nurLesend: sitzung.user.nurLesend ?? false, + istVerwalter: sitzung.user.istVerwalter ?? false, }; } diff --git a/src/lib/authz.ts b/src/lib/authz.ts index 862d373..783db63 100644 --- a/src/lib/authz.ts +++ b/src/lib/authz.ts @@ -14,7 +14,16 @@ * sie nachträglich anpassen kann, ohne dass jemand Code ändern muss. */ -/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */ +/** + * Rollen wie im Prisma-Schema (enum RolleSchluessel) — mit einer Ausnahme. + * + * `AUFSICHT` steht dort **nicht**, und das ist Absicht. Alle anderen Rollen + * werden einem Menschen in einer Wehr zugewiesen und liegen als + * `PersonRolle` in der Datenbank. AUFSICHT wird nicht zugewiesen: sie ergibt + * sich daraus, dass ein Konto zu einer Wehr als `MitgliedschaftArt.AUFSICHT` + * gehört. Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr + * Rechte an einer anderen verteilen könnte. + */ export const ROLLEN = [ "SUPERADMIN", "KOMMANDANT", @@ -25,6 +34,7 @@ export const ROLLEN = [ "AUSBILDER", "MITGLIED", "KIOSK", + "AUFSICHT", ] as const; export type Rolle = (typeof ROLLEN)[number]; @@ -40,9 +50,12 @@ export const ROLLEN_BEZEICHNUNG: Record = { AUSBILDER: "Ausbilder", MITGLIED: "Mitglied", KIOSK: "Terminal im Gerätehaus", + AUFSICHT: "Kreisebene", }; export const ROLLEN_BESCHREIBUNG: Record = { + AUFSICHT: + "Kreisbrandrat oder Kreisbrandinspektion: sieht die betreuten Wehren, ändert nichts.", SUPERADMIN: "Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.", KOMMANDANT: @@ -296,6 +309,39 @@ export const ROLLEN_RECHTE: Record, Recht[]> = { // Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich // zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person. KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"], + + /** + * Die Kreisebene. + * + * Nicht aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und + * sonst nichts. Eine Liste von Hand wäre bei der nächsten Phase veraltet, + * und zwar unbemerkt — ein neues Modul brächte Rechte mit, die hier + * niemand nachträgt. So bekommt die Aufsicht neue Leserechte automatisch + * und neue Schreibrechte nie. + * + * Bewusst NICHT dabei, obwohl es auf `.lesen` endet: + * + * person.export das Auskunftsrecht nach DSGVO ist Sache der Wehr, + * die die Daten führt, nicht der Aufsicht. + * system.auditlog wer wann was getan hat, bleibt innerhalb der Wehr. + * ki.protokoll dasselbe. + * kasse.lesen die Kasse gehört dem Verein oder der Kommune, nicht + * der Kreisaufsicht. Sie wäre nur deshalb hier + * gelandet, weil das Recht zufällig auf „.lesen“ + * endet — und genau das soll die Ausnahmeliste + * verhindern. + * + * Der Atemschutz steht dagegen bewusst drin: ob ein Träger tauglich ist, + * ist genau die Frage, für die es eine Kreisebene gibt. + */ + AUFSICHT: ALLE_RECHTE.filter( + (recht) => + recht.endsWith(".lesen") && + recht !== "person.export" && + recht !== "system.auditlog" && + recht !== "ki.protokoll" && + recht !== "kasse.lesen", + ), }; // ----------------------------------------------------------------------------- @@ -304,12 +350,35 @@ export const ROLLEN_RECHTE: Record, Recht[]> = { /** Was die Anwendung über den gerade Angemeldeten weiß. */ export interface Sitzung { - personId: string; + /** + * Die Personenakte in der aktiven Wehr. + * + * Seit Phase 10 kann sie fehlen: eine Sitzung der Kreisebene (AUFSICHT) + * gehört der Wehr nicht an und hat dort keine Akte. Genau deshalb ist das + * Feld nullbar und nicht bloß leer — jede Stelle, die einen Urheber + * festhalten will, muss sich dazu verhalten, statt einen Namen zu erfinden. + */ + personId: string | null; organisationId: string; name: string; rollen: Rolle[]; /** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */ anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM"; + + /** Das Anmeldekonto. Bleibt beim Wechsel der Wehr dasselbe. */ + benutzerId?: string; + /** Die gewählte Mitgliedschaft — sie bestimmt Wehr und Rollen. */ + mitgliedschaftId?: string; + /** + * Aufsicht statt Mitgliedschaft: die Kreisebene sieht, ändert aber nichts. + * + * Die Rollen sind bei einer solchen Sitzung ohnehin auf die Leserechte + * beschränkt; dieses Merkmal macht es zusätzlich sichtbar — für die + * Oberfläche, die dann keine Knöpfe anbietet, die zu nichts führen. + */ + nurLesend?: boolean; + /** Verwaltet die Installation: Wehren anlegen, in jede hineinschalten. */ + istVerwalter?: boolean; } /** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */ diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 0fb0f1b..0df264f 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc"; import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz"; -import { datenbankErreichbar } from "@/lib/db"; +import { datenbankErreichbar, prismaSystem } from "@/lib/db"; import { speicherErreichbar } from "@/lib/storage"; import { geraetRouter } from "@/server/routers/stammdaten/geraet"; import { fahrzeugRouter } from "@/server/routers/stammdaten/fahrzeug"; @@ -9,6 +9,7 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog"; import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person"; import { importRouter } from "@/server/routers/stammdaten/import"; +import { wehrverwaltungRouter } from "@/server/routers/verwaltung/wehr"; import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage"; import { wartungsterminRouter } from "@/server/routers/wartung/termin"; import { pruefungRouter } from "@/server/routers/wartung/pruefung"; @@ -59,30 +60,80 @@ const systemRouter = router({ }); const sitzungRouter = router({ - /** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */ + /** + * Wer bin ich, in welcher Wehr bin ich gerade, was darf ich — und welche + * Wehren stehen mir sonst noch offen? + * + * Der letzte Teil ist neu in Phase 10 und speist den Wehrwähler. Er kommt + * bewusst aus derselben Abfrage: eine eigene für die Mitgliedschaften wäre + * ein zweiter Weg zu derselben Auskunft, und zwei Wege bedeuten früher oder + * später zwei Antworten. + */ ich: angemeldet.query(async ({ ctx }) => { - const person = await ctx.db((db) => - db.person.findUnique({ - where: { id: ctx.sitzung.personId }, - select: { - id: true, - vorname: true, - nachname: true, - email: true, - dienstgrad: true, - mitgliedsnummer: true, - letzteAnmeldung: true, - organisation: { select: { id: true, name: true, kurzname: true, slug: true } }, - }, - }), - ); + // Ohne Akte — die Kreisaufsicht. Dann gibt es hier nichts zu laden, und + // die Oberfläche zeigt das Konto statt eines Mitglieds. + const person = ctx.sitzung.personId + ? await ctx.db((db) => + db.person.findUnique({ + where: { id: ctx.sitzung.personId as string }, + select: { + id: true, + vorname: true, + nachname: true, + email: true, + dienstgrad: true, + mitgliedsnummer: true, + organisation: { select: { id: true, name: true, kurzname: true, slug: true } }, + }, + }), + ) + : null; + + /** + * Die Wehren dieses Kontos. + * + * Über den Systemzugang, und nur hier: die Frage lautet ja gerade „wo + * gehöre ich überall hin", und die lässt sich im Mandantenkontext einer + * einzelnen Wehr nicht beantworten. Zurückgegeben wird deshalb auch nur + * das Nötigste — Name der Wehr und Art der Zugehörigkeit, nichts aus + * ihren Daten. + */ + const wehren = ctx.sitzung.benutzerId + ? await prismaSystem.mitgliedschaft.findMany({ + where: { + benutzerId: ctx.sitzung.benutzerId, + aktiv: true, + organisation: { aktiv: true, geloeschtAm: null }, + }, + select: { + id: true, + art: true, + organisation: { select: { id: true, name: true, kurzname: true, slug: true } }, + }, + orderBy: [{ art: "asc" }, { organisation: { name: "asc" } }], + }) + : []; + + const konto = ctx.sitzung.benutzerId + ? await prismaSystem.benutzer.findUnique({ + where: { id: ctx.sitzung.benutzerId }, + select: { id: true, email: true, letzteAnmeldung: true, istVerwalter: true }, + }) + : null; return { person, + konto, rollen: ctx.sitzung.rollen, rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]), rechte: [...rechteVon(ctx.sitzung.rollen)], anmeldeArt: ctx.sitzung.anmeldeArt, + /** Alle Wehren dieses Kontos, für den Wehrwähler. */ + wehren, + /** Die gerade aktive Mitgliedschaft. */ + mitgliedschaftId: ctx.sitzung.mitgliedschaftId ?? null, + nurLesend: ctx.sitzung.nurLesend ?? false, + istVerwalter: ctx.sitzung.istVerwalter ?? false, }; }), }); @@ -152,6 +203,7 @@ export const appRouter = router({ system: systemRouter, sitzung: sitzungRouter, organisation: organisationRouter, + wehrverwaltung: wehrverwaltungRouter, audit: auditRouter, // Phase 1 — Stammdaten diff --git a/src/server/routers/atemschutz/ueberwachung.ts b/src/server/routers/atemschutz/ueberwachung.ts index 72c26dc..efd09a5 100644 --- a/src/server/routers/atemschutz/ueberwachung.ts +++ b/src/server/routers/atemschutz/ueberwachung.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { DruckArt, EreignisArt, TruppStatus } from "@prisma/client"; -import { router, angemeldet, mitRecht } from "@/server/trpc"; +import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { env } from "@/lib/env"; @@ -390,7 +390,7 @@ export const atemschutzRouter = router({ bezeichnung: input.bezeichnung, einsatzId: input.einsatzId ?? null, uebungId: input.uebungId ?? null, - ueberwacherId: ctx.sitzung.personId, + ueberwacherId: eigeneAkte(ctx.sitzung), abfrageIntervall: input.abfrageIntervall ?? env.ATEMSCHUTZ_ABFRAGE_INTERVALL, sicherheitszuschlag: input.sicherheitszuschlag ?? env.ATEMSCHUTZ_ZUSCHLAG, warndruck: input.warndruck ?? env.ATEMSCHUTZ_WARNDRUCK, diff --git a/src/server/routers/kiosk/kiosk.ts b/src/server/routers/kiosk/kiosk.ts index 95a7041..4a9e503 100644 --- a/src/server/routers/kiosk/kiosk.ts +++ b/src/server/routers/kiosk/kiosk.ts @@ -9,7 +9,7 @@ import { Prisma, WaescheStatus, } from "@prisma/client"; -import { router, angemeldet, mitRecht } from "@/server/trpc"; +import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import type { MandantenClient } from "@/lib/db"; @@ -322,7 +322,7 @@ export const kioskRouter = router({ const ergebnisId = await vorgangAnwenden( db, ctx.sitzung.organisationId, - ctx.sitzung.personId, + eigeneAkte(ctx.sitzung), ctx.sitzung.name, eingabe, ); @@ -458,12 +458,12 @@ export const kioskRouter = router({ ctx.db(async (db) => { const [anwesenheit, fahrt, waesche] = await Promise.all([ db.anwesenheit.findFirst({ - where: { personId: ctx.sitzung.personId, abgemeldetUm: null }, + where: { personId: eigeneAkte(ctx.sitzung), abgemeldetUm: null }, orderBy: { angemeldetUm: "desc" }, select: { id: true, anlass: true, angemeldetUm: true }, }), db.fahrt.findFirst({ - where: { fahrerId: ctx.sitzung.personId, rueckkehrUm: null }, + where: { fahrerId: eigeneAkte(ctx.sitzung), rueckkehrUm: null }, orderBy: { abfahrtUm: "desc" }, select: { id: true, @@ -474,7 +474,7 @@ export const kioskRouter = router({ }, }), db.waescheabgabe.count({ - where: { personId: ctx.sitzung.personId, status: { not: WaescheStatus.ZURUECK } }, + where: { personId: eigeneAkte(ctx.sitzung), status: { not: WaescheStatus.ZURUECK } }, }), ]); diff --git a/src/server/routers/stammdaten/dienstausweis.ts b/src/server/routers/stammdaten/dienstausweis.ts index d4fe342..97a8bd1 100644 --- a/src/server/routers/stammdaten/dienstausweis.ts +++ b/src/server/routers/stammdaten/dienstausweis.ts @@ -1,4 +1,4 @@ -import { router, angemeldet } from "@/server/trpc"; +import { router, angemeldet, eigeneAkte } from "@/server/trpc"; import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis"; import { env } from "@/lib/env"; @@ -16,7 +16,7 @@ export const dienstausweisRouter = router({ eigener: angemeldet.query(async ({ ctx }) => { const person = await ctx.db((db) => db.person.findUnique({ - where: { id: ctx.sitzung.personId }, + where: { id: eigeneAkte(ctx.sitzung) }, select: { vorname: true, nachname: true, @@ -26,7 +26,7 @@ export const dienstausweisRouter = router({ }), ); - const code = ausweisErzeugen(ctx.sitzung.personId, ctx.sitzung.organisationId); + const code = ausweisErzeugen(eigeneAkte(ctx.sitzung), ctx.sitzung.organisationId); return { person, diff --git a/src/server/routers/stammdaten/person.ts b/src/server/routers/stammdaten/person.ts index 6b5046e..0e5817d 100644 --- a/src/server/routers/stammdaten/person.ts +++ b/src/server/routers/stammdaten/person.ts @@ -146,10 +146,11 @@ export const personRouter = router({ throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." }); } - // Der Hash gehört auch dann nicht nach draußen, wenn er nur im Speicher - // liegt — das Feld wird hier ausdrücklich entfernt. - const { passwortHash: _hash, ...ohneGeheimnis } = person; - return ohneGeheimnis; + // Bis Phase 9 wurde hier der Passwort-Hash aus dem Ergebnis geschnitten. + // Das ist nicht mehr nötig: die Anmeldedaten liegen seit Phase 10 am + // `Benutzer`, und eine Personenabfrage kann sie gar nicht mehr + // mitbringen. Die Sorgfaltsmaßnahme ist durch eine Struktur ersetzt. + return person; }); }), @@ -177,8 +178,7 @@ export const personRouter = router({ userAgent: ctx.userAgent, }); - const { passwortHash: _hash, ...ohneGeheimnis } = person; - return ohneGeheimnis; + return person; }), ), @@ -215,8 +215,7 @@ export const personRouter = router({ userAgent: ctx.userAgent, }); - const { passwortHash: _hash, ...ohneGeheimnis } = nachher; - return ohneGeheimnis; + return nachher; }), ), diff --git a/src/server/routers/termin/benachrichtigung.ts b/src/server/routers/termin/benachrichtigung.ts index 557d9bb..94d0c9d 100644 --- a/src/server/routers/termin/benachrichtigung.ts +++ b/src/server/routers/termin/benachrichtigung.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createHash, randomBytes } from "node:crypto"; import { EreignisTyp, Kanal, Zustellstand } from "@prisma/client"; -import { router, angemeldet, mitRecht } from "@/server/trpc"; +import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { @@ -79,7 +79,7 @@ export const benachrichtigungRouter = router({ eigeneKanaele: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) => ctx.db((db) => db.zustellkanal.findMany({ - where: { personId: ctx.sitzung.personId }, + where: { personId: eigeneAkte(ctx.sitzung) }, orderBy: [{ kanal: "asc" }, { erstelltAm: "asc" }], select: { id: true, @@ -116,7 +116,7 @@ export const benachrichtigungRouter = router({ if (input.kanal === Kanal.MAIL && input.ziel) { const person = await ctx.db((db) => db.person.findUnique({ - where: { id: ctx.sitzung.personId }, + where: { id: eigeneAkte(ctx.sitzung) }, select: { email: true }, }), ); @@ -136,14 +136,14 @@ export const benachrichtigungRouter = router({ const kanal = await db.zustellkanal.upsert({ where: { personId_kanal_ziel: { - personId: ctx.sitzung.personId, + personId: eigeneAkte(ctx.sitzung), kanal: input.kanal, ziel: input.ziel, }, }, create: { organisationId: ctx.sitzung.organisationId, - personId: ctx.sitzung.personId, + personId: eigeneAkte(ctx.sitzung), kanal: input.kanal, ziel: input.ziel, p256dh: input.p256dh ?? null, @@ -284,7 +284,7 @@ export const benachrichtigungRouter = router({ db.benachrichtigung.create({ data: { organisationId: ctx.sitzung.organisationId, - personId: ctx.sitzung.personId, + personId: eigeneAkte(ctx.sitzung), ereignis: EreignisTyp.TERMIN_ERINNERUNG, kanal: kanal.kanal, ziel: kanal.ziel, @@ -313,7 +313,7 @@ export const benachrichtigungRouter = router({ .query(async ({ ctx, input }) => ctx.db((db) => db.benachrichtigung.findMany({ - where: { personId: ctx.sitzung.personId }, + where: { personId: eigeneAkte(ctx.sitzung) }, orderBy: { erstelltAm: "desc" }, take: input?.anzahl ?? 50, select: { @@ -487,7 +487,7 @@ export const benachrichtigungRouter = router({ kalenderzugaenge: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) => ctx.db((db) => db.kalenderzugang.findMany({ - where: { personId: ctx.sitzung.personId }, + where: { personId: eigeneAkte(ctx.sitzung) }, orderBy: { erstelltAm: "desc" }, select: { id: true, @@ -529,7 +529,7 @@ export const benachrichtigungRouter = router({ const eintrag = await db.kalenderzugang.create({ data: { organisationId: ctx.sitzung.organisationId, - personId: ctx.sitzung.personId, + personId: eigeneAkte(ctx.sitzung), bezeichnung: input.bezeichnung, nurEigene: input.nurEigene, tokenHash, diff --git a/src/server/routers/termin/termin.ts b/src/server/routers/termin/termin.ts index e8abd9c..405bdbd 100644 --- a/src/server/routers/termin/termin.ts +++ b/src/server/routers/termin/termin.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { EreignisTyp, Serienmuster, Teilnahmestand, TerminArt } from "@prisma/client"; -import { router, angemeldet, mitRecht } from "@/server/trpc"; +import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { HOECHSTZAHL, serientermine } from "@/lib/termin/serie"; @@ -128,7 +128,7 @@ export const terminRouter = router({ ...(input?.art ? { art: input.art } : {}), ...(input?.mitAbgesagten === false ? { abgesagtAm: null } : {}), ...(input?.nurEigene - ? { anmeldungen: { some: { personId: ctx.sitzung.personId } } } + ? { anmeldungen: { some: { personId: eigeneAkte(ctx.sitzung) } } } : {}), }, orderBy: { beginn: rueckwaerts ? "desc" : "asc" }, @@ -137,7 +137,7 @@ export const terminRouter = router({ ...listenauswahl, _count: { select: { anmeldungen: { where: { stand: Teilnahmestand.ZUGESAGT } } } }, anmeldungen: { - where: { personId: ctx.sitzung.personId }, + where: { personId: eigeneAkte(ctx.sitzung) }, select: { stand: true }, take: 1, }, @@ -540,7 +540,7 @@ export const terminRouter = router({ } const bisher = await db.terminanmeldung.findUnique({ - where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } }, + where: { terminId_personId: { terminId: termin.id, personId: eigeneAkte(ctx.sitzung) } }, select: { id: true, stand: true }, }); @@ -560,11 +560,11 @@ export const terminRouter = router({ } const eintrag = await db.terminanmeldung.upsert({ - where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } }, + where: { terminId_personId: { terminId: termin.id, personId: eigeneAkte(ctx.sitzung) } }, create: { organisationId: ctx.sitzung.organisationId, terminId: termin.id, - personId: ctx.sitzung.personId, + personId: eigeneAkte(ctx.sitzung), stand, grund: input.grund ?? null, }, diff --git a/src/server/routers/verwaltung/wehr.ts b/src/server/routers/verwaltung/wehr.ts new file mode 100644 index 0000000..863f1ff --- /dev/null +++ b/src/server/routers/verwaltung/wehr.ts @@ -0,0 +1,360 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { MitgliedschaftArt, Prisma, RolleSchluessel } from "@prisma/client"; +import { router, nurVerwalter } from "@/server/trpc"; +import { prismaSystem, fuerOrganisation } from "@/lib/db"; +import { AuditAktion, protokolliereIn } from "@/lib/audit"; +import { hashen } from "@/lib/passwort"; +import { ROLLEN, ROLLEN_BESCHREIBUNG, ROLLEN_BEZEICHNUNG, ROLLEN_RECHTE } from "@/lib/authz"; + +/** + * Die Verwaltung der Installation. + * + * Wehren anlegen, Konten führen, Mitgliedschaften vergeben — das Einzige, was + * es in FLORIAN über Mandantengrenzen hinweg gibt. + * + * Drei Festlegungen bestimmen alles hier: + * + * 1. **Es läuft über den Systemzugang.** Row Level Security bindet jede + * Abfrage an eine Organisation; genau die gibt es hier nicht. Deshalb + * hängt der ganze Bereich an einer eigenen Prozedur (`nurVerwalter`) und + * nicht an einem Recht, das innerhalb einer Wehr vergeben werden könnte. + * + * 2. **Ein Konto entsteht ohne Wehr.** Erst die Mitgliedschaft ordnet es + * einer zu. Wer das umdreht, kann Doppelmitgliedschaften nicht abbilden — + * und genau darum ging es in dieser Phase. + * + * 3. **Jede Änderung wird in der betroffenen Wehr protokolliert.** Eine + * Mitgliedschaft, die von außen vergeben wurde, muss innerhalb der Wehr + * sichtbar sein. Sonst hätte der Betreiber einen Weg, sich still in + * fremde Daten zu setzen. + */ + +const slugMuster = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const wehrverwaltungRouter = router({ + /** Alle Wehren dieser Installation, mit ein paar Kennzahlen. */ + wehren: nurVerwalter.query(async () => { + const wehren = await prismaSystem.organisation.findMany({ + where: { geloeschtAm: null }, + orderBy: { name: "asc" }, + select: { + id: true, + slug: true, + name: true, + kurzname: true, + ort: true, + landkreis: true, + aktiv: true, + erstelltAm: true, + _count: { select: { personen: true, mitgliedschaften: true } }, + }, + }); + + return wehren; + }), + + /** + * Eine Wehr anlegen. + * + * Sie bekommt sofort ihre neun Systemrollen mit den Rechten aus + * `lib/authz.ts` — ohne sie könnte niemand darin etwas tun, und die erste + * Amtshandlung wäre eine Fehlersuche. + * + * Was sie **nicht** bekommt, ist ein Konto. Wer sie führen soll, wird im + * zweiten Schritt zugeordnet: eine Wehr anzulegen und gleich jemanden + * hineinzusetzen sind zwei Entscheidungen, und die zweite trifft in aller + * Regel jemand anders. + */ + wehrAnlegen: nurVerwalter + .input( + z.object({ + slug: z + .string() + .trim() + .min(3) + .max(64) + .regex( + slugMuster, + "Nur Kleinbuchstaben, Ziffern und Bindestriche, z. B. ff-musterhausen.", + ), + name: z.string().trim().min(3).max(200), + kurzname: z.string().trim().min(2).max(60), + ort: z.string().trim().max(120).nullish(), + landkreis: z.string().trim().max(120).nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + const vorhanden = await prismaSystem.organisation.findUnique({ + where: { slug: input.slug }, + select: { id: true }, + }); + + if (vorhanden) { + throw new TRPCError({ + code: "CONFLICT", + message: `Die Kennung „${input.slug}" ist bereits vergeben.`, + }); + } + + const wehr = await prismaSystem.organisation.create({ + data: { + slug: input.slug, + name: input.name, + kurzname: input.kurzname, + ort: input.ort ?? null, + landkreis: input.landkreis ?? null, + }, + }); + + // Die Systemrollen samt Rechten. AUFSICHT bleibt außen vor: sie wird + // nicht vergeben, sondern ergibt sich aus der Mitgliedschaftsart. + const katalog = await prismaSystem.berechtigung.findMany({ + select: { id: true, schluessel: true }, + }); + const rechteKarte = new Map(katalog.map((eintrag) => [eintrag.schluessel, eintrag.id])); + + for (const schluessel of ROLLEN) { + if (schluessel === "AUFSICHT") continue; + + const rolle = await prismaSystem.rolle.create({ + data: { + organisationId: wehr.id, + schluessel: schluessel as RolleSchluessel, + name: ROLLEN_BEZEICHNUNG[schluessel], + beschreibung: ROLLEN_BESCHREIBUNG[schluessel], + systemRolle: true, + }, + }); + + const rechte = + schluessel === "SUPERADMIN" ? [...rechteKarte.keys()] : ROLLEN_RECHTE[schluessel]; + + for (const recht of rechte) { + const berechtigungId = rechteKarte.get(recht); + if (berechtigungId) { + await prismaSystem.rolleBerechtigung.create({ + data: { organisationId: wehr.id, rolleId: rolle.id, berechtigungId }, + }); + } + } + } + + // In der neuen Wehr protokollieren, nicht in der des Verwalters: dort + // ist es geschehen, und dort muss es nachlesbar sein. + await fuerOrganisation(wehr.id, (db) => + protokolliereIn(db, { + organisationId: wehr.id, + sitzung: ctx.sitzung, + aktion: AuditAktion.ERSTELLT, + entitaet: "Organisation", + entitaetId: wehr.id, + beschreibung: `Wehr ${wehr.name} angelegt`, + nachher: { slug: wehr.slug, name: wehr.name }, + }), + ); + + return wehr; + }), + + /** Alle Konten der Installation, mit ihren Wehren. */ + konten: nurVerwalter.query(async () => + prismaSystem.benutzer.findMany({ + where: { geloeschtAm: null }, + orderBy: { email: "asc" }, + select: { + id: true, + email: true, + aktiv: true, + istVerwalter: true, + letzteAnmeldung: true, + oidcSubject: true, + mitgliedschaften: { + select: { + id: true, + art: true, + aktiv: true, + organisation: { select: { id: true, kurzname: true } }, + person: { select: { id: true, vorname: true, nachname: true } }, + }, + orderBy: { organisation: { name: "asc" } }, + }, + }, + }), + ), + + /** + * Ein Konto anlegen. + * + * Ohne Wehr — die kommt über die Mitgliedschaft. Das Passwort wird hier + * vergeben und nicht zugeschickt: FLORIAN verschickt keine Passwörter, und + * ein Einladungsverfahren gibt es (noch) nicht. + */ + kontoAnlegen: nurVerwalter + .input( + z.object({ + email: z.string().trim().toLowerCase().email().max(200), + passwort: z.string().min(12, "Mindestens zwölf Zeichen."), + istVerwalter: z.boolean().default(false), + }), + ) + .mutation(async ({ input }) => { + const vorhanden = await prismaSystem.benutzer.findUnique({ + where: { email: input.email }, + select: { id: true }, + }); + + if (vorhanden) { + throw new TRPCError({ + code: "CONFLICT", + message: + `Für ${input.email} gibt es bereits ein Konto. Wenn diese Person zu einer ` + + "weiteren Wehr gehören soll, wird ihm eine Mitgliedschaft hinzugefügt — " + + "kein zweites Konto.", + }); + } + + const konto = await prismaSystem.benutzer.create({ + data: { + email: input.email, + passwortHash: await hashen(input.passwort), + passwortGeaendertAm: new Date(), + istVerwalter: input.istVerwalter, + }, + select: { id: true, email: true, istVerwalter: true }, + }); + + return konto; + }), + + /** Die Personenakten einer Wehr — zur Auswahl beim Zuordnen. */ + personenIn: nurVerwalter + .input(z.object({ organisationId: z.string().uuid() })) + .query(async ({ input }) => + prismaSystem.person.findMany({ + where: { + organisationId: input.organisationId, + geloeschtAm: null, + // Wer schon ein Konto hat, steht nicht zur Auswahl: eine Akte hängt + // an höchstens einem. + mitgliedschaft: null, + }, + orderBy: [{ nachname: "asc" }, { vorname: "asc" }], + select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true }, + }), + ), + + /** + * Ein Konto einer Wehr zuordnen. + * + * MITGLIED verlangt eine Akte in dieser Wehr, AUFSICHT verlangt, dass keine + * angegeben wird — beides setzt die Datenbank zusätzlich durch. Hier steht + * die Prüfung trotzdem, damit die Meldung erklärt statt nur abzulehnen. + */ + mitgliedschaftAnlegen: nurVerwalter + .input( + z.object({ + benutzerId: z.string().uuid(), + organisationId: z.string().uuid(), + art: z.nativeEnum(MitgliedschaftArt), + personId: z.string().uuid().nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.art === MitgliedschaftArt.MITGLIED && !input.personId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Eine Mitgliedschaft braucht die Personenakte in dieser Wehr. Ohne sie wäre " + + "nicht entscheidbar, wer unter diesem Konto gehandelt hat.", + }); + } + + if (input.art === MitgliedschaftArt.AUFSICHT && input.personId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Die Kreisaufsicht gehört der Wehr nicht an und bekommt deshalb keine " + + "Personenakte — sie soll in keiner Mitgliederliste auftauchen.", + }); + } + + try { + const mitgliedschaft = await prismaSystem.mitgliedschaft.create({ + data: { + organisationId: input.organisationId, + benutzerId: input.benutzerId, + personId: input.personId ?? null, + art: input.art, + }, + include: { + benutzer: { select: { email: true } }, + organisation: { select: { name: true } }, + }, + }); + + await fuerOrganisation(input.organisationId, (db) => + protokolliereIn(db, { + organisationId: input.organisationId, + sitzung: ctx.sitzung, + aktion: AuditAktion.ERSTELLT, + entitaet: "Mitgliedschaft", + entitaetId: mitgliedschaft.id, + beschreibung: + `${mitgliedschaft.benutzer.email} als ${input.art === "AUFSICHT" ? "Kreisaufsicht" : "Mitglied"} ` + + `zu ${mitgliedschaft.organisation.name} hinzugefügt`, + nachher: { art: input.art, personId: input.personId ?? null }, + }), + ); + + return mitgliedschaft; + } catch (fehler) { + if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + message: "Dieses Konto gehört dieser Wehr bereits an.", + }); + } + throw fehler; + } + }), + + /** + * Eine Mitgliedschaft beenden. + * + * Gelöscht, nicht stillgelegt: sie ist keine Nachweisunterlage, sondern eine + * Zugangsregel. Was unter ihr getan wurde, steht ohnehin im Audit-Log und + * hängt an der Personenakte — die bleibt. + */ + mitgliedschaftEntfernen: nurVerwalter + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const vorher = await prismaSystem.mitgliedschaft.findUnique({ + where: { id: input.id }, + include: { + benutzer: { select: { email: true } }, + organisation: { select: { name: true } }, + }, + }); + + if (!vorher) { + throw new TRPCError({ code: "NOT_FOUND", message: "Diese Zuordnung gibt es nicht." }); + } + + await prismaSystem.mitgliedschaft.delete({ where: { id: input.id } }); + + await fuerOrganisation(vorher.organisationId, (db) => + protokolliereIn(db, { + organisationId: vorher.organisationId, + sitzung: ctx.sitzung, + aktion: AuditAktion.GELOESCHT, + entitaet: "Mitgliedschaft", + entitaetId: input.id, + beschreibung: `${vorher.benutzer.email} aus ${vorher.organisation.name} entfernt`, + vorher: { art: vorher.art, personId: vorher.personId }, + }), + ); + + return { erfolgreich: true }; + }), +}); diff --git a/src/server/routers/wartung/pruefung.ts b/src/server/routers/wartung/pruefung.ts index 182389c..a2e3e84 100644 --- a/src/server/routers/wartung/pruefung.ts +++ b/src/server/routers/wartung/pruefung.ts @@ -10,7 +10,7 @@ import { SignaturRolle, WartungsterminStatus, } from "@prisma/client"; -import { router, mitRecht } from "@/server/trpc"; +import { router, mitRecht, eigeneAkte } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { AUFTRAG, beauftrage } from "@/lib/warteschlange"; @@ -282,7 +282,7 @@ export const pruefungRouter = router({ if (laufender) return { pruefung: laufender, fortgesetzt: true }; const person = await db.person.findUnique({ - where: { id: ctx.sitzung.personId }, + where: { id: eigeneAkte(ctx.sitzung) }, select: { vorname: true, nachname: true }, }); @@ -724,7 +724,7 @@ export const pruefungRouter = router({ } const person = await db.person.findUnique({ - where: { id: ctx.sitzung.personId }, + where: { id: eigeneAkte(ctx.sitzung) }, select: { vorname: true, nachname: true }, }); diff --git a/src/server/trpc.ts b/src/server/trpc.ts index c9de9f5..178e1c8 100644 --- a/src/server/trpc.ts +++ b/src/server/trpc.ts @@ -41,6 +41,30 @@ export const createCallerFactory = t.createCallerFactory; /** Ohne Anmeldung erreichbar. */ export const oeffentlich = t.procedure; +/** + * Die eigene Personenakte in der aktiven Wehr. + * + * Fuer alles, was „meine Termine“, „meine Benachrichtigungen“ oder „mein + * Ausweis“ bedeutet. Seit Phase 10 kann sie fehlen: eine Aufsichtssitzung + * der Kreisebene gehört der Wehr nicht an und hat dort keine Akte. + * + * Dass diese Funktion wirft statt null zurückzugeben, ist Absicht. Ein + * stilles null würde in einer Abfrage landen, die dann alle Datensätze der + * Wehr liefert statt keinen — aus „meine Termine“ würde „alle Termine“, + * und niemand würde es bemerken. + */ +export function eigeneAkte(sitzung: Sitzung): string { + if (!sitzung.personId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Diese Ansicht zeigt eigene Daten. Die Kreisaufsicht hat in dieser Wehr keine " + + "Mitgliedschaft und damit auch keine eigenen Daten.", + }); + } + return sitzung.personId; +} + /** Abfrage im Mandantenkontext der Sitzung. */ export type MandantenAbfrage = (fn: (db: MandantenClient) => Promise) => Promise; @@ -95,3 +119,28 @@ export function mitRecht(recht: Recht) { return next(); }); } + +/** + * Prozedur, die nur der Installationsverwalter aufrufen darf. + * + * Das ist die einzige Stelle, an der über Mandantengrenzen hinweg gearbeitet + * wird: Wehren anlegen, Konten führen, Mitgliedschaften vergeben. Sie hängt + * bewusst nicht an einem Recht aus `lib/authz.ts`, denn Rechte gelten je Wehr + * — und wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben + * wird und in Wehr B wirkt, wäre genau die Hintertür, die diese Phase + * vermeiden soll. + * + * Die Prozeduren dahinter arbeiten deshalb auch nicht über `ctx.db`, sondern + * über den Systemzugang: es gibt bei ihnen keine „aktuelle Organisation". + */ +export const nurVerwalter = angemeldet.use(async ({ ctx, next }) => { + if (!ctx.sitzung.istVerwalter) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Diesen Bereich verwaltet, wer die Installation betreibt. Innerhalb einer Wehr " + + "lässt er sich nicht freischalten.", + }); + } + return next(); +}); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 19da6d3..7eb637c 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -2,7 +2,11 @@ import type { Rolle, Sitzung } from "@/lib/authz"; /** * Erweitert die Typen von Auth.js um das, was FLORIAN zusätzlich in der - * Sitzung führt: Person, Wehr, Rollen und die Art der Anmeldung. + * Sitzung führt: Konto, Mitgliedschaft, Wehr, Rollen und die Art der + * Anmeldung. + * + * Seit Phase 10 sind Konto und Wehr getrennt. Das Konto bleibt über einen + * Wehrwechsel hinweg dasselbe; alles andere wird beim Wechsel neu gesetzt. */ declare module "next-auth" { interface Session { @@ -14,6 +18,14 @@ declare module "next-auth" { organisationId: string; rollen: Rolle[]; anmeldeArt: Sitzung["anmeldeArt"]; + /** Das Anmeldekonto. Überdauert den Wehrwechsel. */ + benutzerId?: string; + /** Die aktive Mitgliedschaft — sie bestimmt Wehr und Rollen. */ + mitgliedschaftId?: string; + /** Aufsichtssitzung der Kreisebene: sieht, ändert nichts. */ + nurLesend?: boolean; + /** Verwaltet die Installation: Wehren anlegen, in jede hineinschalten. */ + istVerwalter?: boolean; }; } @@ -21,6 +33,11 @@ declare module "next-auth" { organisationId?: string; rollen?: Rolle[]; anmeldeArt?: Sitzung["anmeldeArt"]; + benutzerId?: string; + mitgliedschaftId?: string; + personId?: string | null; + nurLesend?: boolean; + istVerwalter?: boolean; } } @@ -32,6 +49,11 @@ declare module "next-auth/jwt" { anmeldeArt?: Sitzung["anmeldeArt"]; /** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */ geladenAm?: number; + + benutzerId?: string; + mitgliedschaftId?: string; + nurLesend?: boolean; + istVerwalter?: boolean; } } diff --git a/tests/e2e/anmeldung.spec.ts b/tests/e2e/anmeldung.spec.ts index de03bd0..cc356e4 100644 --- a/tests/e2e/anmeldung.spec.ts +++ b/tests/e2e/anmeldung.spec.ts @@ -26,7 +26,17 @@ test.describe("Anmeldung", () => { }) => { await page.goto("/anmelden"); - await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); + // Bewusst eine Adresse, die es nicht gibt, statt der des Administrators. + // + // Ein Fehlversuch zählt auf das Konto, und nach fünf davon ist es fünf + // Minuten gesperrt. Bei zwei Projekten und Wiederholungen kamen im vollen + // Lauf genug zusammen, um andere Testdateien mitzureißen — sie fielen + // dann mit „Anmeldung fehlgeschlagen“ aus, ohne dass es etwas mit dem zu + // tun hatte, was sie prüfen. + // + // Die Meldung muss ohnehin dieselbe sein: sie darf nicht verraten, ob es + // die Adresse gibt. Genau das prüft dieser Test mit. + await page.getByLabel("E-Mail-Adresse").fill("gibt-es-nicht@teststelle.de"); await page.getByLabel("Passwort").fill("definitiv-das-falsche-passwort"); await page.getByRole("button", { name: "Anmelden" }).click(); diff --git a/tests/e2e/mandanten.spec.ts b/tests/e2e/mandanten.spec.ts new file mode 100644 index 0000000..363150d --- /dev/null +++ b/tests/e2e/mandanten.spec.ts @@ -0,0 +1,241 @@ +import { expect, test } from "@playwright/test"; +import { ADMIN_EMAIL, ADMIN_PASSWORT, anmelden } from "./wehr"; + +/** + * E2E-Tests für Phase 10 — Mandantenfähigkeit. + * + * Geprüft wird das, wovon die Trennung abhängt: + * + * * wer nur zu einer Wehr gehört, sieht keinen Wähler + * * wer zu mehreren gehört, landet in seiner eigenen und kann wechseln + * * nach dem Wechsel stehen die Daten der anderen Wehr da, nicht die alten + * * als Kreisaufsicht steht der Hinweis, und es gibt nichts zu ändern + * * die Kasse der betreuten Wehr bleibt zu + * + * Voraussetzung: `npm run db:test-reset`. Der Seed legt dabei die Testwehr, + * eine Nachbarwehr und ein Konto an, das in der einen Mitglied und in der + * anderen Aufsicht ist. + */ +test.describe.configure({ mode: "serial" }); + +/** Das Konto der Kreisebene — Mitglied hier, Aufsicht nebenan. */ +const KBI_EMAIL = process.env.E2E_KBI_EMAIL ?? "kbi@ff-teststelle.de"; +const KBI_PASSWORT = process.env.E2E_KBI_PASSWORT ?? "Feuerwehr2026"; + +/** + * Öffnet die Seitenleiste, falls sie hinter dem Menüknopf steckt. + * + * Auf dem Tablet (Galaxy Tab S4 quer, 1138 px) liegt die Leiste unterhalb der + * `md`-Grenze und ist eingeklappt. Der Wehrwähler sitzt darin — wer ihn dort + * sucht, ohne vorher aufzuklappen, wartet vergeblich. + */ +async function seitenleisteOeffnen(page: import("@playwright/test").Page) { + const knopf = page.getByRole("button", { name: "Navigation öffnen" }); + if (await knopf.isVisible()) await knopf.click(); +} + +/** Meldet ein beliebiges Konto an. */ +async function anmeldenAls(page: import("@playwright/test").Page, email: string, passwort: string) { + await page.goto("/anmelden"); + await page.getByLabel("E-Mail-Adresse").fill(email); + await page.getByLabel("Passwort").fill(passwort); + await page.getByRole("button", { name: "Anmelden" }).click(); + await expect(page, `Anmeldung als ${email} fehlgeschlagen.`).toHaveURL("/"); +} + +test.describe("Ein Konto, eine Wehr", () => { + test("zeigt keinen Wehrwähler, wenn es nichts zu wählen gibt", async ({ page }) => { + await anmelden(page); + + // Der Name der Wehr steht da — aber nicht als Auswahl. Ein Auswahlfeld mit + // genau einem Eintrag ist eine Frage ohne Antwortmöglichkeit. + await expect(page.getByRole("button", { name: /Wehr wechseln/ })).toHaveCount(0); + await expect(page.getByRole("listbox", { name: "Wehr wechseln" })).toHaveCount(0); + }); + + test("sieht keinen Aufsichtshinweis", async ({ page }) => { + await anmelden(page); + await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0); + }); +}); + +test.describe("Ein Konto, zwei Wehren", () => { + test("landet nach der Anmeldung in der eigenen Wehr, nicht in der betreuten", async ({ + page, + }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + // Die eigene vor der betreuten: wer eine Wehr führt und eine zweite + // beaufsichtigt, will in seiner landen. + await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0); + }); + + test("bietet den Wehrwähler an", async ({ page }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + + const liste = page.getByRole("listbox", { name: "Wehr wechseln" }); + await expect(liste).toBeVisible(); + await expect(liste.getByRole("option")).toHaveCount(2); + + // Beide Arten stehen mit Klartext da — nicht nur mit einem Symbol. + await expect(liste.getByText("Mitglied", { exact: true })).toBeVisible(); + await expect(liste.getByText("Aufsicht — nur lesen")).toBeVisible(); + }); + + test("wechselt in die betreute Wehr und sagt, dass dort nichts geht", async ({ page }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + await page + .getByRole("listbox", { name: "Wehr wechseln" }) + .getByRole("option") + .filter({ hasText: "Aufsicht" }) + .click(); + + // Der Hinweis steht dauerhaft, nicht als kurz aufblitzende Meldung. + await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible(); + await expect(page.getByRole("note").filter({ hasText: "Kreisaufsicht" })).toBeVisible(); + }); + + test("zeigt nach dem Wechsel die Mitglieder der anderen Wehr", async ({ page }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + await page + .getByRole("listbox", { name: "Wehr wechseln" }) + .getByRole("option") + .filter({ hasText: "Aufsicht" }) + .click(); + await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible(); + + await page.goto("/mitglieder"); + + // Die drei aus der Nachbarwehr — und nicht die fünfzehn der eigenen. + await expect(page.getByRole("cell", { name: /Sailer/ })).toBeVisible(); + await expect(page.getByRole("cell", { name: /Ostermeier/ })).toBeVisible(); + + // Die Kreisaufsicht selbst steht in keiner Mitgliederliste: sie gehört + // der Wehr nicht an und hat dort keine Akte. + await expect(page.getByRole("cell", { name: /Huber/ })).toHaveCount(0); + }); + + test("lässt die Kreisaufsicht in der betreuten Wehr nichts anlegen", async ({ page }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + await page + .getByRole("listbox", { name: "Wehr wechseln" }) + .getByRole("option") + .filter({ hasText: "Aufsicht" }) + .click(); + await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible(); + + await page.goto("/mitglieder"); + + // Kein Knopf, der ins Leere führt. Das Recht fehlt serverseitig ohnehin — + // die Oberfläche soll es aber gar nicht erst anbieten. + await expect(page.getByRole("button", { name: /Mitglied anlegen/ })).toHaveCount(0); + }); + + test("hält die Kasse der betreuten Wehr geschlossen", async ({ page }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + await page + .getByRole("listbox", { name: "Wehr wechseln" }) + .getByRole("option") + .filter({ hasText: "Aufsicht" }) + .click(); + await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible(); + + // Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. + await seitenleisteOeffnen(page); + await expect(page.locator("aside").getByRole("link", { name: "Kasse" })).toHaveCount(0); + + await page.goto("/kasse"); + await expect(page).not.toHaveURL(/\/kasse$/); + }); + + test("kommt in die eigene Wehr zurück", async ({ page }) => { + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + await page + .getByRole("listbox", { name: "Wehr wechseln" }) + .getByRole("option") + .filter({ hasText: "Aufsicht" }) + .click(); + await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible(); + + await seitenleisteOeffnen(page); + await page.getByRole("button", { name: /Wehr wechseln/ }).click(); + await page + .getByRole("listbox", { name: "Wehr wechseln" }) + .getByRole("option") + .filter({ hasText: "Mitglied" }) + .click(); + + await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0); + await seitenleisteOeffnen(page); + await expect(page.locator("aside").getByRole("link", { name: "Kasse" })).toBeVisible(); + }); +}); + +test.describe("Der Administrator", () => { + test("bleibt nach der Umstellung anmeldbar", async ({ page }) => { + // Die Migration hat sein Konto aus der Personenakte gelöst. Wäre dabei + // etwas verlorengegangen, käme er hier nicht mehr hinein — und niemand + // sonst auch. + await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + }); +}); + +test.describe("Wehren und Konten", () => { + test("steht nur dem Verwalter offen", async ({ page }) => { + // Der KBI führt seine Wehr, verwaltet aber nicht die Installation. + await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT); + + await seitenleisteOeffnen(page); + await expect( + page.locator("aside").getByRole("link", { name: "Wehren und Konten" }), + ).toHaveCount(0); + + // Und die Seite selbst schickt ihn weg, nicht nur das Menü. + await page.goto("/wehren"); + await expect(page).not.toHaveURL(/\/wehren$/); + }); + + test("zeigt dem Verwalter die Wehren dieser Installation", async ({ page }) => { + await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT); + await seitenleisteOeffnen(page); + await page.locator("aside").getByRole("link", { name: "Wehren und Konten" }).click(); + + await expect(page.getByRole("heading", { name: "Wehren und Konten", level: 1 })).toBeVisible(); + + // Die Testwehr und ihre Nachbarwehr aus dem Seed. Der Verwalter sieht + // bewusst **alle** Wehren der Installation — auch die einer anderen + // Kommune, wenn sie auf demselben Server liegt. Das ist der Unterschied + // zwischen dem Betreiber und einer Wehr. + await expect(page.getByRole("heading", { name: "FF Teststelle Nachbar" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "FF Teststelle", exact: true })).toBeVisible(); + }); + + test("zeigt, welches Konto zu welchen Wehren gehört", async ({ page }) => { + await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT); + await page.goto("/wehren"); + + // Das Konto der Kreisebene steht mit beiden Zuordnungen da — und die + // Aufsicht ist als solche gekennzeichnet, nicht als Mitgliedschaft. + await expect(page.getByText(KBI_EMAIL)).toBeVisible(); + await expect(page.getByText("Kreisaufsicht — nur lesen").first()).toBeVisible(); + }); +}); diff --git a/tests/unit/authz.test.ts b/tests/unit/authz.test.ts index d5e1db2..f1e7681 100644 --- a/tests/unit/authz.test.ts +++ b/tests/unit/authz.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { RolleSchluessel } from "@prisma/client"; import { ALLE_RECHTE, KeinZugriffFehler, @@ -24,7 +25,10 @@ import { * Terminal nur das Nötigste kann. */ -function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung { +function sitzung( + rollen: Sitzung["rollen"], + organisationId = "org-1", +): Sitzung & { personId: string } { return { personId: "person-1", organisationId, @@ -34,6 +38,23 @@ function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung { }; } +/** + * Eine Sitzung der Kreisaufsicht: kein Konto in dieser Wehr, keine Akte. + * + * Seit Phase 10 gibt es diesen Zustand, und er ist der einzige, in dem + * `personId` fehlt. + */ +function aufsicht(organisationId = "org-2"): Sitzung { + return { + personId: null, + organisationId, + name: "Kreisbrandinspektion", + rollen: ["AUFSICHT"], + anmeldeArt: "LOKAL", + nurLesend: true, + }; +} + describe("Rollenkatalog", () => { it("hat für jede Rolle eine deutsche Bezeichnung", () => { for (const rolle of ROLLEN) { @@ -195,3 +216,72 @@ describe("alsRolle", () => { expect(alsRolle(null)).toBeNull(); }); }); + +describe("Kreisaufsicht", () => { + /** + * Der wichtigste Test dieser Datei. + * + * Die Kreisebene sieht Wehren, denen sie nicht angehört. Bekäme sie dort + * versehentlich ein Schreibrecht, wäre das kein Bedienfehler, sondern ein + * Loch in der Mandantentrennung — und zwar eines, das niemandem auffällt, + * weil in der Oberfläche alles normal aussieht. + * + * Die Zusicherung ist deshalb bewusst allgemein formuliert: nicht "diese + * zwölf Rechte", sondern "kein einziges, das nicht lesen heißt". So gilt + * sie auch für Module, die es noch gar nicht gibt. + */ + it("hat ausschließlich Leserechte — auch für künftige Module", () => { + const rechte = [...rechteVon(["AUFSICHT"])]; + + expect(rechte.length).toBeGreaterThan(0); + expect(rechte.filter((recht) => !recht.endsWith(".lesen"))).toEqual([]); + }); + + it("sieht die Kasse nicht", () => { + // Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht — und + // wäre nur deshalb dabei, weil das Recht zufällig auf ".lesen" endet. + expect(hatRecht(aufsicht(), "kasse.lesen")).toBe(false); + }); + + it("sieht weder Audit-Log noch KI-Protokoll", () => { + // Wer wann was getan hat, bleibt innerhalb der Wehr. + expect(hatRecht(aufsicht(), "system.auditlog")).toBe(false); + expect(hatRecht(aufsicht(), "ki.protokoll")).toBe(false); + }); + + it("kann keine Personendaten exportieren", () => { + // Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt. + expect(hatRecht(aufsicht(), "person.export")).toBe(false); + }); + + it("sieht den Atemschutz — dafür gibt es sie", () => { + expect(hatRecht(aufsicht(), "atemschutz.lesen")).toBe(true); + expect(hatRecht(aufsicht(), "atemschutz.bearbeiten")).toBe(false); + expect(hatRecht(aufsicht(), "atemschutz.ueberwachung")).toBe(false); + }); + + it("hat in dieser Wehr keine eigene Akte", () => { + // Daran hängt, dass sie in keiner Mitgliederliste auftaucht und als + // Urheber keiner Änderung eingetragen werden kann. + expect(aufsicht().personId).toBeNull(); + expect(aufsicht().nurLesend).toBe(true); + }); + + it("sieht fremde Personen nur über person.lesen, nie über die eigene Akte", () => { + // `darfPersonSehen` lässt jeden die eigenen Daten sehen. Ohne Akte darf + // dieser Weg nicht versehentlich greifen: sonst entschiede ein Vergleich + // von null mit null darüber, wer wen sieht. + const kreis = aufsicht(); + expect(darfPersonSehen(kreis, "irgendeine-person")).toBe(true); + expect(hatRecht(kreis, "person.lesen")).toBe(true); + }); +}); + +describe("Rollenkatalog und Mandantentrennung", () => { + it("führt AUFSICHT nicht als vergebbare Rolle in der Datenbank", () => { + // Sie ergibt sich aus der Mitgliedschaftsart, nicht aus einer Zuweisung. + // Wäre sie vergebbar, könnte jemand innerhalb einer Wehr Rechte an einer + // anderen verteilen. + expect(RolleSchluessel).not.toHaveProperty("AUFSICHT"); + }); +}); diff --git a/tests/unit/ki-datenbank.test.ts b/tests/unit/ki-datenbank.test.ts index f255a72..06c3246 100644 --- a/tests/unit/ki-datenbank.test.ts +++ b/tests/unit/ki-datenbank.test.ts @@ -60,8 +60,14 @@ beforeAll(async () => { }); personId = person.id; - const seite = await system.wikiseite.create({ - data: { + // Wie Wehr und Person darüber: idempotent. Bleibt aus einem abgebrochenen + // Lauf etwas stehen, scheiterte ein bloßes `create` hier an der + // Eindeutigkeit — und der ganze Datei-Lauf mit ihm, aus einem Grund, der + // mit dem Geprüften nichts zu tun hat. + const seite = await system.wikiseite.upsert({ + where: { organisationId_slug: { organisationId, slug: "testseite" } }, + update: {}, + create: { organisationId, slug: "testseite", titel: "Testseite", diff --git a/tests/unit/mandanten-datenbank.test.ts b/tests/unit/mandanten-datenbank.test.ts new file mode 100644 index 0000000..23d3ce6 --- /dev/null +++ b/tests/unit/mandanten-datenbank.test.ts @@ -0,0 +1,280 @@ +import { PrismaClient } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * Konto und Mitgliedschaft — geprüft dort, wo die Trennung durchgesetzt wird. + * + * Phase 10 hat das Anmeldekonto von der Personenakte gelöst. Damit steht zum + * ersten Mal eine Tabelle über allen Mandanten (`benutzer`), und die Frage + * „wer darf welche Wehr sehen" hängt nicht mehr an der Akte, sondern an einer + * eigenen Zuordnung. + * + * Das ist der Punkt, an dem eine Mandantentrennung normalerweise leckt: eine + * Zuordnung, die auf eine Akte aus einer anderen Wehr zeigt, und schon steht + * in der Sitzung eine Personenkennung, die dort niemandem gehört. Genau + * dagegen laufen die Prüfungen hier — und zwar über den Systemzugang: kommt + * nicht einmal der Eigentümer der Datenbank daran vorbei, kommt niemand + * vorbei. + * + * Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt + * er sich selbst, damit "npm run test" auch ohne Docker durchläuft. + */ + +const APP_URL = process.env.DATABASE_URL ?? ""; +const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL; + +const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); + +const SLUG_A = "test-mandant-a"; +const SLUG_B = "test-mandant-b"; + +let erreichbar = false; +let wehrA = ""; +let wehrB = ""; +let akteA = ""; +let akteB = ""; +let konto = ""; + +beforeAll(async () => { + try { + await system.$queryRaw`SELECT 1`; + erreichbar = true; + } catch { + return; + } + + const a = await system.organisation.upsert({ + where: { slug: SLUG_A }, + update: {}, + create: { slug: SLUG_A, name: "Mandant A", kurzname: "A" }, + }); + wehrA = a.id; + + const b = await system.organisation.upsert({ + where: { slug: SLUG_B }, + update: {}, + create: { slug: SLUG_B, name: "Mandant B", kurzname: "B" }, + }); + wehrB = b.id; + + const personA = await system.person.upsert({ + where: { organisationId_mitgliedsnummer: { organisationId: wehrA, mitgliedsnummer: "A-1" } }, + update: {}, + create: { + organisationId: wehrA, + vorname: "Anna", + nachname: "Aktiv", + mitgliedsnummer: "A-1", + eintrittsdatum: new Date("2015-01-01"), + }, + }); + akteA = personA.id; + + const personB = await system.person.upsert({ + where: { organisationId_mitgliedsnummer: { organisationId: wehrB, mitgliedsnummer: "B-1" } }, + update: {}, + create: { + organisationId: wehrB, + vorname: "Bernd", + nachname: "Beisitzer", + mitgliedsnummer: "B-1", + eintrittsdatum: new Date("2015-01-01"), + }, + }); + akteB = personB.id; + + const benutzer = await system.benutzer.upsert({ + where: { email: "doppel@test-mandant.de" }, + update: {}, + create: { email: "doppel@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }); + konto = benutzer.id; +}); + +afterAll(async () => { + if (erreichbar) { + await system.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; + await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } }); + await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } }); + }); + } + await system.$disconnect(); +}); + +describe.runIf(process.env.DATABASE_URL)("Doppelmitgliedschaft", () => { + it("lässt ein Konto zu zwei Wehren gehören", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Das ist der ganze Zweck der Phase: ein Mensch, ein Passwort, zwei + // Wehren. Vorher hätte es zwei Konten gebraucht, die auseinanderlaufen. + await system.mitgliedschaft.create({ + data: { organisationId: wehrA, benutzerId: konto, personId: akteA, art: "MITGLIED" }, + }); + + await system.mitgliedschaft.create({ + data: { organisationId: wehrB, benutzerId: konto, art: "AUFSICHT" }, + }); + + const alle = await system.mitgliedschaft.findMany({ where: { benutzerId: konto } }); + expect(alle).toHaveLength(2); + }); + + it("nimmt ein Konto nur einmal je Wehr auf", async ({ skip }) => { + if (!erreichbar) return skip(); + + await expect( + system.mitgliedschaft.create({ + data: { organisationId: wehrA, benutzerId: konto, personId: akteA, art: "MITGLIED" }, + }), + ).rejects.toThrow(); + }); + + it("hängt eine Personenakte an höchstens ein Konto", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat. + const zweites = await system.benutzer.create({ + data: { email: "zweites@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }); + + await expect( + system.mitgliedschaft.create({ + data: { organisationId: wehrA, benutzerId: zweites.id, personId: akteA, art: "MITGLIED" }, + }), + ).rejects.toThrow(); + }); +}); + +describe.runIf(process.env.DATABASE_URL)("Die Akte gehört zur Wehr", () => { + it("weist eine Zuordnung über Wehrgrenzen hinweg ab", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Der eigentliche Mandantenschutz dieser Phase. Ein Fremdschlüssel prüft + // nur, dass es die Akte gibt — nicht, wo sie liegt. Ohne den Trigger + // ließe sich ein Konto über eine Akte aus Wehr A in Wehr B hängen, und + // die Sitzung stünde dort mit einer Kennung da, die niemandem gehört. + const fremd = await system.benutzer.create({ + data: { email: "fremd@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }); + + await expect( + system.mitgliedschaft.create({ + data: { + organisationId: wehrB, + benutzerId: fremd.id, + // Die Akte liegt in Wehr A. + personId: akteA, + art: "MITGLIED", + }, + }), + ).rejects.toThrow(/andere Wehr|gehoert/i); + }); + + it("weist auch das nachträgliche Umhängen ab", async ({ skip }) => { + if (!erreichbar) return skip(); + + const eigene = await system.mitgliedschaft.findFirst({ + where: { benutzerId: konto, organisationId: wehrA }, + }); + expect(eigene).not.toBeNull(); + + await expect( + system.mitgliedschaft.update({ + where: { id: eigene!.id }, + data: { personId: akteB }, + }), + ).rejects.toThrow(/andere Wehr|gehoert/i); + }); +}); + +describe.runIf(process.env.DATABASE_URL)("Was die Datenbank gar nicht erst annimmt", () => { + it("keine Mitgliedschaft ohne Akte", async ({ skip }) => { + if (!erreichbar) return skip(); + + const ohne = await system.benutzer.create({ + data: { email: "ohneakte@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }); + + await expect( + system.mitgliedschaft.create({ + data: { organisationId: wehrA, benutzerId: ohne.id, art: "MITGLIED" }, + }), + ).rejects.toThrow(/akte_passend/i); + }); + + it("keine Kreisaufsicht mit Akte", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Sie gehört der Wehr nicht an und soll in keiner Mitgliederliste stehen. + const aufsicht = await system.benutzer.create({ + data: { email: "aufsicht@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }); + + await expect( + system.mitgliedschaft.create({ + data: { organisationId: wehrB, benutzerId: aufsicht.id, personId: akteB, art: "AUFSICHT" }, + }), + ).rejects.toThrow(/akte_passend/i); + }); + + it("kein Konto ohne Anmeldeweg", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Weder Passwort noch OIDC — damit ist es eine Karteileiche, die erst + // auffällt, wenn sich jemand damit anmelden will. + await expect( + system.benutzer.create({ data: { email: "leer@test-mandant.de" } }), + ).rejects.toThrow(/anmeldeweg_vorhanden/i); + }); + + it("keine E-Mail, die keine ist", async ({ skip }) => { + if (!erreichbar) return skip(); + + await expect( + system.benutzer.create({ + data: { email: "kein-at-zeichen", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }), + ).rejects.toThrow(/email_plausibel/i); + }); + + it("keine zwei Konten mit derselben Adresse", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Global eindeutig — das ist der Unterschied zur E-Mail an der Akte, die + // je Wehr eindeutig ist. Vorher meldete sich, wer in zwei Wehren stand, + // in einer zufälligen an. + await expect( + system.benutzer.create({ + data: { email: "doppel@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" }, + }), + ).rejects.toThrow(); + }); +}); + +describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Zuordnungen", () => { + it("zeigt einer Wehr nicht, in welchen anderen ein Konto noch geführt wird", async ({ + skip, + }) => { + if (!erreichbar) return skip(); + if (!APP_URL || APP_URL === SYSTEM_URL) return skip(); + + // `mitgliedschaft` trägt `organisationId` und unterliegt damit Row Level + // Security wie jede andere Tabelle. Wer in Wehr A angemeldet ist, sieht + // die Zuordnungen von Wehr A — und erfährt insbesondere nicht, dass + // dasselbe Konto nebenan Kreisaufsicht ist. + const app = new PrismaClient({ datasources: { db: { url: APP_URL } } }); + + try { + const inA = await app.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`SELECT set_config('app.current_organisation', '${wehrA}', true)`); + return tx.mitgliedschaft.findMany({ where: { benutzerId: konto } }); + }); + + expect(inA).toHaveLength(1); + expect(inA[0]?.organisationId).toBe(wehrA); + } finally { + await app.$disconnect(); + } + }); +});