Phase 10: Mandantenfähigkeit #15

Merged
florian.wilde merged 1 commits from feat/phase-10-mandantenfaehigkeit into main 2026-09-05 13:47:47 +00:00
98 changed files with 3810 additions and 201 deletions
Showing only changes of commit ff40437a1a - Show all commits
+5
View File
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
# Nach dem ersten Login das Passwort in der Oberfläche ändern. # Nach dem ersten Login das Passwort in der Oberfläche ändern.
SEED_ADMIN_EMAIL=admin@beispielwehr.de SEED_ADMIN_EMAIL=admin@beispielwehr.de
SEED_ADMIN_PASSWORT=Feuerwehr2026 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. # bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
AUTH_BCRYPT_ROUNDS=12 AUTH_BCRYPT_ROUNDS=12
+124
View File
@@ -6,6 +6,130 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). 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 ## [0.10.0] — 2026-09-04
Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an
+142
View File
@@ -33,6 +33,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 7 — Benachrichtigungen und Termine | fertig | | Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | fertig | | Phase 8 — Lokale KI | fertig |
| Phase 9 — Kassenverwaltung | fertig | | Phase 9 — Kassenverwaltung | fertig |
| Phase 10 — Mandantenfähigkeit | fertig |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md). [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 ## Befehle
```bash ```bash
+138 -6
View File
@@ -1,6 +1,6 @@
# Anleitung # 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. 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 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 `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 | | **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen | | **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 | | **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 | | **Terminal** | nur anmelden und erfassen — nie verwalten |
Zwei Dinge sind bewusst so gebaut: 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. 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, Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt. 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, Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. 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 | | Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---| |---|---|---|
+27 -1
View File
@@ -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** | | 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** | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 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 123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet 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 DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen
scheiterte. 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 ## Was hier nicht drin ist
+103 -1
View File
@@ -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 | | Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
| Aufräumarbeiten (Issues #10–#12) | 1:20 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 | | 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<Session>` | 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: `<slug>-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
--- ---
+37 -6
View File
@@ -19,6 +19,7 @@ Phase 6 ████████████████████ fertig
Phase 7 ████████████████████ fertig 0.8.0 Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ████████████████████ fertig 0.9.0 Phase 8 ████████████████████ fertig 0.9.0
Phase 9 ████████████████████ fertig 0.10.0 Phase 9 ████████████████████ fertig 0.10.0
Phase 10 ████████████████████ fertig 0.11.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.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 **Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf
Knopfdruck erzeugen. 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 ## 1.0.0 — Freigabe
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im 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`. | | 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. | | `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. | | 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 ## 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 | | 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 | | 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 | | 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 Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das Definition dessen, was verglichen werden soll.
der nächste sinnvolle Schritt.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 KiB

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "florian", "name": "florian",
"version": "0.10.0", "version": "0.11.0",
"private": true, "private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": { "engines": {
@@ -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";
@@ -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();
+113 -16
View File
@@ -107,6 +107,21 @@ enum FahrzeugArt {
} }
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl). /// 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 { enum Dienstgrad {
FEUERWEHRANWAERTER FEUERWEHRANWAERTER
FEUERWEHRMANN FEUERWEHRMANN
@@ -153,6 +168,7 @@ model Organisation {
geloeschtAm DateTime? @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3)
personen Person[] personen Person[]
mitgliedschaften Mitgliedschaft[]
rollen Rolle[] rollen Rolle[]
personRollen PersonRolle[] personRollen PersonRolle[]
rolleRechte RolleBerechtigung[] rolleRechte RolleBerechtigung[]
@@ -280,18 +296,13 @@ model Person {
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied. /// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
abteilung String? @db.VarChar(60) abteilung String? @db.VarChar(60)
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird. /// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier.
passwortHash String? @db.VarChar(120) ///
passwortGeaendertAm DateTime? @db.Timestamptz(3) /// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird,
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto. /// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject
oidcSubject String? @db.VarChar(255) /// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können,
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit. /// und die Anmeldung per E-Mail wäre mehrdeutig.
oidcGruppen String[] @default([]) aktiv Boolean @default(true)
letzteAnmeldung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
aktiv Boolean @default(true)
/// Profilbild. Liegt wie alles andere in MinIO. /// Profilbild. Liegt wie alles andere in MinIO.
avatarDateiId String? @db.Uuid avatarDateiId String? @db.Uuid
@@ -301,6 +312,7 @@ model Person {
geloeschtAm DateTime? @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson")
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull) avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
rollen PersonRolle[] @relation("PersonRollenInhaber") rollen PersonRolle[] @relation("PersonRollenInhaber")
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber") vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
@@ -375,19 +387,104 @@ model Person {
zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer") zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer")
erstellteRechnungen Rechnung[] @relation("RechnungErsteller") erstellteRechnungen Rechnung[] @relation("RechnungErsteller")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Die E-Mail ist je Wehr eindeutig, nicht global: dieselbe Person kann in
/// Person kann in zwei Wehren Mitglied sein. /// 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, email])
@@unique([organisationId, oidcSubject])
@@unique([organisationId, mitgliedsnummer]) @@unique([organisationId, mitgliedsnummer])
@@index([organisationId, nachname, vorname]) @@index([organisationId, nachname, vorname])
@@index([organisationId, aktiv]) @@index([organisationId, aktiv])
@@index([oidcSubject])
@@map("person") @@map("person")
} }
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene /// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
/// Rollen kann der Kommandant ergänzen. /// 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 { model Rolle {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid organisationId String @db.Uuid
+245 -3
View File
@@ -57,6 +57,7 @@ import {
LebensdauerBezug, LebensdauerBezug,
LebensdauerKategorie, LebensdauerKategorie,
MangelPrioritaet, MangelPrioritaet,
MitgliedschaftArt,
MangelStatus, MangelStatus,
PrismaClient, PrismaClient,
PruefErgebnis, PruefErgebnis,
@@ -198,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: Map<string, string
const rollenKarte = new Map<string, string>(); const rollenKarte = new Map<string, string>();
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({ const rolle = await prisma.rolle.upsert({
where: { where: {
organisationId_schluessel: { organisationId_schluessel: {
@@ -499,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
nachname: "Administrator", nachname: "Administrator",
email, email,
dienstgrad: Dienstgrad.KEIN, dienstgrad: Dienstgrad.KEIN,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
abteilung: "Verwaltung", abteilung: "Verwaltung",
}, },
}); });
// Das Konto seit Phase 10 getrennt von der Akte. Die E-Mail ist global
// eindeutig — wird derselbe Seed für eine zweite Wehr gefahren, entsteht
// deshalb kein zweites Konto, sondern eine zweite Mitgliedschaft. Genau so
// sieht Doppelmitgliedschaft in echt aus.
const konto = await prisma.benutzer.upsert({
where: { email },
update: {},
create: {
email,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
istVerwalter: true,
},
});
await prisma.mitgliedschaft.upsert({
where: { benutzerId_organisationId: { benutzerId: konto.id, organisationId } },
update: {},
create: { organisationId, benutzerId: konto.id, personId: admin.id },
});
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN); const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
if (rolleId) { if (rolleId) {
await prisma.personRolle.upsert({ await prisma.personRolle.upsert({
@@ -3897,6 +3923,219 @@ async function kassendatenAnlegen(organisationId: string): Promise<void> {
} }
// -----------------------------------------------------------------------------
// 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<string, string>,
): Promise<void> {
/**
* 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<string, string>) {
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 // Ablauf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -3946,6 +4185,9 @@ async function main(): Promise<void> {
// Phase 9 — Kassenverwaltung // Phase 9 — Kassenverwaltung
await kassendatenAnlegen(organisation.id); await kassendatenAnlegen(organisation.id);
// Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene
await kreisebeneAnlegen(organisation.id, katalog);
log("Fertig."); log("Fertig.");
log(""); log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`); log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
+20
View File
@@ -288,6 +288,14 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Kassenprüfung mit Differenz und Erläuterung", beschreibung: "Kassenprüfung mit Differenz und Erläuterung",
angemeldet: true, 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<void> {
.addStyleTag({ content: "nextjs-portal { display: none !important; }" }) .addStyleTag({ content: "nextjs-portal { display: none !important; }" })
.catch(() => undefined); .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 { try {
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, { await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
timeout: 15_000, timeout: 15_000,
+16 -3
View File
@@ -53,8 +53,20 @@ async function main(): Promise<void> {
); );
} }
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 }, select: { id: true, name: true },
}); });
@@ -79,7 +91,8 @@ async function main(): Promise<void> {
await tx.$executeRawUnsafe(`ALTER TABLE "${tabelle}" DISABLE TRIGGER USER`); 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 [ for (const tabelle of [
"druckmessung", "druckmessung",
+5
View File
@@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() {
const sitzung = await aktuelleSitzung(); const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden"); 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 marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
const bild = await qrTextAlsDataUri(marke, 420); const bild = await qrTextAlsDataUri(marke, 420);
+11 -2
View File
@@ -25,9 +25,18 @@ export default async function AusweisSeite() {
const sitzung = await aktuelleSitzung(); const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden"); 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 daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const person = await db.person.findUnique({ const person = await db.person.findUnique({
where: { id: sitzung.personId }, where: { id: eigeneAkteId },
select: { select: {
vorname: true, vorname: true,
nachname: true, nachname: true,
@@ -48,7 +57,7 @@ export default async function AusweisSeite() {
return person; 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 adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
const bild = await qrTextAlsDataUri(adresse, 420); const bild = await qrTextAlsDataUri(adresse, 420);
+3
View File
@@ -3,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db"; import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz"; import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste"; import { Seitenleiste } from "@/components/layout/seitenleiste";
import { AufsichtHinweis } from "@/components/layout/wehrwaehler";
import { ThemeToggle } from "@/components/layout/theme-toggle"; import { ThemeToggle } from "@/components/layout/theme-toggle";
import { TrpcProvider } from "@/lib/trpc/provider"; import { TrpcProvider } from "@/lib/trpc/provider";
@@ -31,9 +32,11 @@ export default async function AngemeldetLayout({
rollen={sitzung.rollen} rollen={sitzung.rollen}
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"} organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
personName={sitzung.name} personName={sitzung.name}
istVerwalter={sitzung.istVerwalter ?? false}
/> />
<div className="md:pl-64"> <div className="md:pl-64">
<AufsichtHinweis />
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken"> <header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
<ThemeToggle /> <ThemeToggle />
</header> </header>
+33 -1
View File
@@ -48,7 +48,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
eintrittsdatum: true, eintrittsdatum: true,
austrittsdatum: true, austrittsdatum: true,
aktiv: 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 } }, rollen: { include: { rolle: true } },
qualifikationen: { qualifikationen: {
where: { geloeschtAm: null }, where: { geloeschtAm: null },
@@ -86,6 +96,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
"Anschrift", "Anschrift",
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—", person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
], ],
[
"Zugang",
person.mitgliedschaft?.benutzer ? (
<>
{person.mitgliedschaft.benutzer.email}
<span className="block text-xs text-[var(--muted-foreground)]">
{person.mitgliedschaft.benutzer.aktiv
? person.mitgliedschaft.benutzer.letzteAnmeldung
? `zuletzt angemeldet am ${alsDatum(person.mitgliedschaft.benutzer.letzteAnmeldung)}`
: "noch nie angemeldet"
: "Konto abgeschaltet"}
</span>
</>
) : (
<>
kein Konto
<span className="block text-xs text-[var(--muted-foreground)]">
Diese Person ist geführt, meldet sich aber nicht an.
</span>
</>
),
],
]; ];
/* eslint-enable react/jsx-key */ /* eslint-enable react/jsx-key */
+6
View File
@@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({
redirect("/pruefungen"); 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; const { geraetId, terminId, vorlageId } = await searchParams;
if (!geraetId) redirect("/pruefungen"); if (!geraetId) redirect("/pruefungen");
+19
View File
@@ -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<void> {
await sitzungNeuAusstellen({ user: { mitgliedschaftId } });
}
+31
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wehren und Konten</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wehren dieser Installation, die Anmeldekonten und wer zu welcher Wehr gehört.
</p>
</div>
<Wehrverwaltung />
</div>
);
}
+611
View File
@@ -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<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(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 (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="verwaltungsfehler" />}
{/* 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) && (
<Fehlermeldung
meldung={wehren.error?.message ?? konten.error?.message ?? "Unbekannter Fehler"}
kennung="verwaltungsabfrage"
/>
)}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{/* --- Wehren ---------------------------------------------------- */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Wehren</h2>
<Button onClick={() => setWehrOffen(true)}>
<Plus aria-hidden />
Wehr anlegen
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{wehren.isLoading &&
[0, 1, 2].map((stelle) => (
<div
key={stelle}
className="h-32 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
{wehren.data?.map((wehr) => (
<Card key={wehr.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{wehr.kurzname}</CardTitle>
{!wehr.aktiv && <Badge variant="ausgesondert">abgeschaltet</Badge>}
</div>
<CardDescription className="truncate">{wehr.name}</CardDescription>
</CardHeader>
<CardContent className="text-sm">
<p className="text-[var(--muted-foreground)]">
Kennung <code className="text-xs">{wehr.slug}</code>
{wehr.ort && ` · ${wehr.ort}`}
</p>
<p className="mt-1">
{wehr._count.personen} {wehr._count.personen === 1 ? "Person" : "Personen"} ·{" "}
{wehr._count.mitgliedschaften}{" "}
{wehr._count.mitgliedschaften === 1 ? "Konto" : "Konten"}
</p>
</CardContent>
</Card>
))}
</div>
</section>
{/* --- Konten ---------------------------------------------------- */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Konten</h2>
<Button variant="outline" onClick={() => setKontoOffen(true)}>
<KeyRound aria-hidden />
Konto anlegen
</Button>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
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.
</p>
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Konten"
hinweis="Ohne Konto kommt niemand hinein — auch nicht in eine gerade angelegte Wehr."
/>
)}
<div className="space-y-3">
{konten.isLoading &&
[0, 1].map((stelle) => (
<div
key={stelle}
className="h-32 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
{konten.data?.map((konto) => (
<Card key={konto.id}>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate text-base">{konto.email}</CardTitle>
<CardDescription>
{konto.letzteAnmeldung
? `zuletzt angemeldet am ${alsDatum(konto.letzteAnmeldung)}`
: "noch nie angemeldet"}
{konto.oidcSubject && " · über authentik verknüpft"}
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
{konto.istVerwalter && <Badge variant="secondary">Verwalter</Badge>}
{!konto.aktiv && <Badge variant="ausgesondert">abgeschaltet</Badge>}
<Button
size="sm"
variant="outline"
onClick={() => setZuordnen({ id: konto.id, email: konto.email })}
>
<UserPlus aria-hidden />
Wehr zuordnen
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{konto.mitgliedschaften.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Keiner Wehr zugeordnet — die Anmeldung führt damit nirgendwohin.
</p>
) : (
<ul className="space-y-1.5 text-sm">
{konto.mitgliedschaften.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-center gap-2 rounded-md bg-[var(--muted)] px-3 py-1.5"
>
{eintrag.art === MitgliedschaftArt.AUFSICHT ? (
<Eye className="size-4 shrink-0" aria-hidden />
) : (
<Building2 className="size-4 shrink-0" aria-hidden />
)}
<span className="font-medium">{eintrag.organisation.kurzname}</span>
<span className="text-[var(--muted-foreground)]">
{eintrag.art === MitgliedschaftArt.AUFSICHT
? "Kreisaufsicht — nur lesen"
: eintrag.person
? `${eintrag.person.nachname}, ${eintrag.person.vorname}`
: "Mitglied"}
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => entfernen.mutate({ id: eintrag.id })}
disabled={entfernen.isPending}
>
<Trash2 aria-hidden />
<span className="sr-only">Zuordnung entfernen</span>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
))}
</div>
</section>
<WehrAnlegen
offen={wehrOffen}
schliessen={() => setWehrOffen(false)}
fehler={setMeldung}
fertig={(name) => {
setWehrOffen(false);
setHinweis(`${name} angelegt, samt Rollen. Jetzt fehlt noch ein Konto dafür.`);
void werkzeuge.wehrverwaltung.invalidate();
}}
/>
<KontoAnlegen
offen={kontoOffen}
schliessen={() => setKontoOffen(false)}
fehler={setMeldung}
fertig={(email) => {
setKontoOffen(false);
setHinweis(`Konto ${email} angelegt. Es gehört noch keiner Wehr an.`);
void werkzeuge.wehrverwaltung.invalidate();
}}
/>
<WehrZuordnen
konto={zuordnen}
schliessen={() => setZuordnen(null)}
fehler={setMeldung}
fertig={() => {
setZuordnen(null);
void werkzeuge.wehrverwaltung.invalidate();
}}
/>
</div>
);
}
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 (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wehr anlegen</DialogTitle>
<DialogDescription>
Sie bekommt sofort ihre Systemrollen. Ein Konto bekommt sie nicht — wer sie führen
soll, wird im zweiten Schritt zugeordnet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wehr-name">Vollständiger Name</Label>
<Input
id="wehr-name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
placeholder="Freiwillige Feuerwehr Musterhausen"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="wehr-kurzname">Kurzname</Label>
<Input
id="wehr-kurzname"
value={kurzname}
onChange={(ereignis) => setKurzname(ereignis.target.value)}
placeholder="FF Musterhausen"
/>
</div>
<div className="space-y-2">
<Label htmlFor="wehr-slug">Kennung</Label>
<Input
id="wehr-slug"
value={slug}
onChange={(ereignis) => setSlug(ereignis.target.value.toLowerCase())}
placeholder="ff-musterhausen"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Kleinbuchstaben, Ziffern, Bindestriche. Sie steht später im Terminal und in
Dateipfaden und lässt sich nicht mehr ändern.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="wehr-ort">Ort</Label>
<Input
id="wehr-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="wehr-landkreis">Landkreis</Label>
<Input
id="wehr-landkreis"
value={landkreis}
onChange={(ereignis) => setLandkreis(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
slug,
name,
kurzname,
ort: ort || null,
landkreis: landkreis || null,
})
}
disabled={!slug || !name || !kurzname || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto anlegen</DialogTitle>
<DialogDescription>
Die E-Mail-Adresse ist die Anmeldung und gilt über alle Wehren hinweg. Das Passwort
geben Sie der Person selbst weiter — FLORIAN verschickt keines.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="konto-email">E-Mail-Adresse</Label>
<Input
id="konto-email"
type="email"
value={email}
onChange={(ereignis) => setEmail(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="konto-passwort">Erstes Passwort</Label>
<Input
id="konto-passwort"
type="text"
value={passwort}
onChange={(ereignis) => setPasswort(ereignis.target.value)}
placeholder="mindestens zwölf Zeichen"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Steht im Klartext, damit Sie es weitergeben können. Es gehört nach der ersten
Anmeldung geändert.
</p>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={verwalter}
onChange={(ereignis) => setVerwalter(ereignis.target.checked)}
className="mt-1"
/>
<span>
Verwaltet die Installation
<span className="block text-xs text-[var(--muted-foreground)]">
Darf Wehren anlegen und in jede hineinschalten. Sparsam vergeben — dieses
Merkmal hebt die Trennung zwischen den Wehren auf.
</span>
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => anlegen.mutate({ email, passwort, istVerwalter: verwalter })}
disabled={!email || passwort.length < 12 || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 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>(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 (
<Dialog open={konto !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wehr zuordnen</DialogTitle>
<DialogDescription>
{konto?.email} — als Mitglied mit eigener Akte oder als Kreisaufsicht, die nur
zusieht.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="zuordnung-wehr">Wehr</Label>
<Select value={organisationId} onValueChange={setOrganisationId}>
<SelectTrigger id="zuordnung-wehr">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{wehren.data?.map((wehr) => (
<SelectItem key={wehr.id} value={wehr.id}>
{wehr.kurzname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{Object.values(MitgliedschaftArt).map((wert) => (
<Button
key={wert}
type="button"
variant={art === wert ? "default" : "outline"}
className="flex-1"
onClick={() => setArt(wert)}
>
{wert === MitgliedschaftArt.AUFSICHT ? "Kreisaufsicht" : "Mitglied"}
</Button>
))}
</div>
{art === MitgliedschaftArt.MITGLIED ? (
<div className="space-y-2">
<Label htmlFor="zuordnung-person">Personenakte in dieser Wehr</Label>
<Select value={personId} onValueChange={setPersonId}>
<SelectTrigger id="zuordnung-person">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{personen.data?.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
{person.mitgliedsnummer && ` (${person.mitgliedsnummer})`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
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.
</p>
</div>
) : (
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
Die Kreisaufsicht bekommt <strong>keine</strong> 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.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
konto &&
anlegen.mutate({
benutzerId: konto.id,
organisationId,
art,
personId: art === MitgliedschaftArt.MITGLIED ? personId : null,
})
}
disabled={
!organisationId ||
(art === MitgliedschaftArt.MITGLIED && !personId) ||
anlegen.isPending
}
>
{anlegen.isPending ? "Wird zugeordnet …" : "Zuordnen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+13
View File
@@ -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()); const inhalt = Buffer.from(await datei.arrayBuffer());
try { try {
+33 -8
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
Album, Album,
Building2,
BellRing, BellRing,
BookMarked, BookMarked,
BookOpenCheck, BookOpenCheck,
@@ -30,6 +31,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Wehrwaehler } from "@/components/layout/wehrwaehler";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Recht, Rolle } from "@/lib/authz"; import type { Recht, Rolle } from "@/lib/authz";
@@ -52,6 +54,8 @@ interface Eintrag {
rechte?: Recht[]; rechte?: Recht[];
/** Ab welcher Phase es die Ansicht wirklich gibt. */ /** Ab welcher Phase es die Ansicht wirklich gibt. */
abPhase?: number; abPhase?: number;
/** Nur für den, der die Installation verwaltet — nicht über ein Recht. */
nurVerwalter?: boolean;
} }
const EINTRAEGE: Eintrag[] = [ const EINTRAEGE: Eintrag[] = [
@@ -174,27 +178,50 @@ const EINTRAEGE: Eintrag[] = [
rechte: ["system.auditlog"], rechte: ["system.auditlog"],
abPhase: 8, 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 /** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */ * sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 9; const AKTUELLE_PHASE = 10;
export interface SeitenleisteProps { export interface SeitenleisteProps {
rechte: Recht[]; rechte: Recht[];
rollen: Rolle[]; rollen: Rolle[];
organisationName: string; organisationName: string;
personName: 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 pfad = usePathname();
const [offen, setOffen] = React.useState(false); const [offen, setOffen] = React.useState(false);
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]); const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
const sichtbar = EINTRAEGE.filter( const sichtbar = EINTRAEGE.filter((eintrag) => {
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)), if (eintrag.nurVerwalter) return istVerwalter;
); return !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht));
});
return ( return (
<> <>
@@ -221,9 +248,7 @@ export function Seitenleiste({ rechte, organisationName, personName }: Seitenlei
<Flame className="size-6 text-[var(--primary)]" aria-hidden /> <Flame className="size-6 text-[var(--primary)]" aria-hidden />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p> <p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
<p className="truncate text-xs text-[var(--muted-foreground)]"> <Wehrwaehler organisationName={organisationName} />
{organisationName}
</p>
</div> </div>
</div> </div>
+160
View File
@@ -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<string | null>(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 (
<p className="truncate text-xs text-[var(--muted-foreground)]">{organisationName}</p>
);
}
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 (
<div className="relative">
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
aria-expanded={offen}
aria-haspopup="listbox"
// Ein Knopf, auf dem nur ein Wehrname und ein Pfeil stehen, sagt
// niemandem, was er tut — einem Screenreader schon gar nicht.
aria-label={`Wehr wechseln — gerade ${organisationName}`}
className="flex w-full items-center gap-1 rounded text-left text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="truncate">{organisationName}</span>
<ChevronsUpDown className="size-3 shrink-0" aria-hidden />
</button>
{offen && (
<>
<button
type="button"
aria-label="Auswahl schließen"
className="fixed inset-0 z-40 cursor-default"
onClick={() => setOffen(false)}
/>
<ul
role="listbox"
aria-label="Wehr wechseln"
className="absolute left-0 top-full z-50 mt-2 max-h-80 w-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--card)] p-1 shadow-lg"
>
{wehren.map((eintrag) => {
const istAktiv = eintrag.id === aktive;
const istAufsicht = eintrag.art === "AUFSICHT";
return (
<li key={eintrag.id}>
<button
type="button"
role="option"
aria-selected={istAktiv}
disabled={wechseltZu !== null}
onClick={() => void wechseln(eintrag.id)}
className={cn(
"flex w-full items-start gap-2 rounded px-2 py-2 text-left text-sm",
istAktiv
? "bg-[var(--accent)] text-[var(--accent-foreground)]"
: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
wechseltZu !== null && "opacity-60",
)}
>
{istAufsicht ? (
<Eye className="mt-0.5 size-4 shrink-0" aria-hidden />
) : (
<Building2 className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{eintrag.organisation.kurzname}</span>
<span className="block truncate text-xs text-[var(--muted-foreground)]">
{istAufsicht ? "Aufsicht — nur lesen" : "Mitglied"}
</span>
</span>
{istAktiv && <Check className="mt-0.5 size-4 shrink-0" aria-hidden />}
</button>
</li>
);
})}
</ul>
</>
)}
</div>
);
}
/**
* 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 (
<div
role="note"
className="flex items-start gap-2 border-b border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 px-4 py-2 text-sm nicht-drucken"
>
<Eye className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Sie sehen diese Wehr als <strong>Kreisaufsicht</strong>. Geändert werden kann hier
nichts — auch die Kasse und das Protokoll bleiben der Wehr vorbehalten.
</span>
</div>
);
}
+334 -93
View File
@@ -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<T extends { art: "MITGLIED" | "AUFSICHT" }>(
alle: T[],
): T | undefined {
return alle.find((eintrag) => eintrag.art === "MITGLIED") ?? alle[0];
}
function rollenAus( function rollenAus(
eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>, eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>,
): Rolle[] { ): Rolle[] {
@@ -60,19 +147,24 @@ function rollenAus(
} }
/** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */ /** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */
function istGesperrt(person: { gesperrtBis: Date | null }): boolean { function istGesperrt(konto: { gesperrtBis: Date | null }): boolean {
return person.gesperrtBis !== null && person.gesperrtBis > new Date(); return konto.gesperrtBis !== null && konto.gesperrtBis > new Date();
} }
/** Fehlversuch zählen und ab der Grenze sperren. */ /**
async function fehlversuchVermerken(personId: string): Promise<void> { * Fehlversuch zählen und ab der Grenze sperren.
const person = await prismaSystem.person.findUnique({ *
where: { id: personId }, * 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<void> {
const konto = await prismaSystem.benutzer.findUnique({
where: { id: benutzerId },
select: { fehlversuche: true }, select: { fehlversuche: true },
}); });
const neu = (person?.fehlversuche ?? 0) + 1; const neu = (konto?.fehlversuche ?? 0) + 1;
await prismaSystem.person.update({ await prismaSystem.benutzer.update({
where: { id: personId }, where: { id: benutzerId },
data: { data: {
fehlversuche: neu, fehlversuche: neu,
gesperrtBis: gesperrtBis:
@@ -83,9 +175,9 @@ async function fehlversuchVermerken(personId: string): Promise<void> {
}); });
} }
async function anmeldungVermerken(personId: string): Promise<void> { async function anmeldungVermerken(benutzerId: string): Promise<void> {
await prismaSystem.person.update({ await prismaSystem.benutzer.update({
where: { id: personId }, where: { id: benutzerId },
data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null }, data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null },
}); });
} }
@@ -122,64 +214,75 @@ const passwortAnbieter = Credentials({
const { email, passwort } = geprueft.data; const { email, passwort } = geprueft.data;
// Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist // Die E-Mail-Suche geht über die Konten, nicht über die Personenakten:
// der Mandant noch nicht bekannt. // seit Phase 10 ist sie dort global eindeutig. Vorher war sie es nur je
const person = await prismaSystem.person.findFirst({ // 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 }, 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. // Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt.
await pruefen(passwort, null); await pruefen(passwort, null);
return null; return null;
} }
if (istGesperrt(person)) { const mitgliedschaften = await mitgliedschaftenVon(konto.id);
await protokolliereAnmeldung({ const gewaehlt = ersteMitgliedschaft(mitgliedschaften);
organisationId: person.organisationId,
personId: person.id, // Ein Konto ohne Mitgliedschaft ist ausgetreten oder noch nicht
name: `${person.vorname} ${person.nachname}`, // zugeordnet. Es kommt bis zur Wehr gar nicht, deshalb gibt es auch
anmeldeArt: AnmeldeArt.LOKAL, // nichts zu protokollieren — das Audit-Log hängt an einer Organisation.
erfolgreich: false, if (!gewaehlt) {
grund: "Konto vorübergehend gesperrt", await pruefen(passwort, null);
});
return 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) { if (!stimmt) {
await fehlversuchVermerken(person.id); await fehlversuchVermerken(konto.id);
await protokolliereAnmeldung({ await melden(false, "Falsches Passwort");
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Falsches Passwort",
});
return null; return null;
} }
await anmeldungVermerken(person.id); const sitzung = await sitzungAus(gewaehlt);
await protokolliereAnmeldung({ if (!sitzung) {
organisationId: person.organisationId, await melden(false, "Personenakte in dieser Wehr nicht nutzbar");
personId: person.id, return null;
name: `${person.vorname} ${person.nachname}`, }
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: true, await anmeldungVermerken(konto.id);
}); await melden(true);
return { return {
id: person.id, id: sitzung.personId ?? konto.id,
name: `${person.vorname} ${person.nachname}`, name: sitzung.name ?? konto.email,
email: person.email ?? undefined, email: konto.email,
organisationId: person.organisationId, organisationId: sitzung.organisationId,
rollen: rollenAus(person.rollen), rollen: sitzung.rollen,
anmeldeArt: "LOKAL" as const, 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: * 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 * 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. * 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<{ async function personAusOidc(profil: OidcProfil): Promise<{
personId: string; personId: string | null;
organisationId: string; organisationId: string;
name: string; name: string;
rollen: Rolle[]; rollen: Rolle[];
} | null> { } | null> {
const email = profil.email?.toLowerCase(); const email = profil.email?.toLowerCase();
const vorhanden = await prismaSystem.person.findFirst({ const vorhanden = await prismaSystem.benutzer.findFirst({
where: { where: {
OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])], OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])],
geloeschtAm: null, geloeschtAm: null,
}, },
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
}); });
const gruppen = gruppenAusProfil(profil); const gruppen = gruppenAusProfil(profil);
if (vorhanden) { 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 }, where: { id: vorhanden.id },
data: { data: {
oidcSubject: profil.sub, oidcSubject: profil.sub,
@@ -424,17 +532,21 @@ async function personAusOidc(profil: OidcProfil): Promise<{
}); });
// Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von // Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von
// Hand vergeben hat, bleiben unangetastet. // Hand vergeben hat, bleiben unangetastet. Und sie wirken nur auf die
await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen); // 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); const sitzung = await sitzungAus(gewaehlt);
if (!aktualisiert) return null; if (!sitzung) return null;
return { return {
personId: aktualisiert.id, personId: sitzung.personId,
organisationId: aktualisiert.organisationId, organisationId: sitzung.organisationId,
name: `${aktualisiert.vorname} ${aktualisiert.nachname}`, name: sitzung.name ?? vorhanden.email,
rollen: rollenAus(aktualisiert.rollen), rollen: sitzung.rollen,
}; };
} }
@@ -458,20 +570,37 @@ async function personAusOidc(profil: OidcProfil): Promise<{
const nachname = const nachname =
profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt"; 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) => const neu = await fuerOrganisation(organisation.id, (db) =>
db.person.create({ db.person.create({
data: { data: {
organisationId: organisation.id, organisationId: organisation.id,
vorname, vorname,
nachname, nachname,
email: email ?? null, email,
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
}, },
}), }),
); );
// 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); await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen);
const mitRollen = await personMitRollen(neu.id); const mitRollen = await personMitRollen(neu.id);
@@ -581,7 +710,7 @@ export const authConfig: NextAuthConfig = {
return true; return true;
}, },
async jwt({ token, user, account, trigger }) { async jwt({ token, user, account, trigger, session }) {
// Erste Anmeldung über einen Credentials-Anbieter. // Erste Anmeldung über einen Credentials-Anbieter.
if (user && "organisationId" in user) { if (user && "organisationId" in user) {
const angemeldet = user as unknown as { const angemeldet = user as unknown as {
@@ -590,44 +719,122 @@ export const authConfig: NextAuthConfig = {
organisationId: string; organisationId: string;
rollen: Rolle[]; rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"]; 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.organisationId = angemeldet.organisationId;
token.rollen = angemeldet.rollen; token.rollen = angemeldet.rollen;
token.anmeldeArt = angemeldet.anmeldeArt; 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(); token.geladenAm = Date.now();
return token; return token;
} }
// Erste Anmeldung über OIDC — hier steht das Profil im account/profile. // Erste Anmeldung über OIDC — hier steht das Profil im account/profile.
if (account?.provider === "authentik" && token.sub) { if (account?.provider === "authentik" && token.sub) {
const person = await prismaSystem.person.findFirst({ const konto = await prismaSystem.benutzer.findFirst({
where: { oidcSubject: token.sub, geloeschtAm: null }, where: { oidcSubject: token.sub, geloeschtAm: null },
include: { rollen: { include: { rolle: true } } },
}); });
if (person) { if (konto) {
token.personId = person.id; const gewaehlt = ersteMitgliedschaft(await mitgliedschaftenVon(konto.id));
token.organisationId = person.organisationId; const sitzung = gewaehlt ? await sitzungAus(gewaehlt) : null;
token.rollen = rollenAus(person.rollen);
token.anmeldeArt = "OIDC"; 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(); token.geladenAm = Date.now();
} }
return token; return token;
} }
// Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet.
const veraltet = const veraltet =
typeof token.geladenAm !== "number" || typeof token.geladenAm !== "number" ||
Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS; Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS;
if (trigger === "update" || (veraltet && typeof token.personId === "string")) { if (
const person = await personMitRollen(token.personId as string); (trigger === "update" || veraltet) &&
if (!person) { typeof token.mitgliedschaftId === "string" &&
// Person deaktiviert oder gelöscht: Sitzung entwerten. 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: [] }; 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(); token.geladenAm = Date.now();
} }
@@ -641,13 +848,30 @@ export const authConfig: NextAuthConfig = {
organisationId: (token.organisationId as string) ?? "", organisationId: (token.organisationId as string) ?? "",
rollen: (token.rollen as Rolle[]) ?? [], rollen: (token.rollen as Rolle[]) ?? [],
anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL", 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; 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, * 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<Sitzung | null> { export async function aktuelleSitzung(): Promise<Sitzung | null> {
const sitzung = await auth(); 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 { 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, organisationId: sitzung.user.organisationId,
name: sitzung.user.name ?? "Unbekannt", name: sitzung.user.name ?? "Unbekannt",
rollen: sitzung.user.rollen ?? [], rollen: sitzung.user.rollen ?? [],
anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL", anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL",
benutzerId: sitzung.user.benutzerId,
mitgliedschaftId: sitzung.user.mitgliedschaftId,
nurLesend: sitzung.user.nurLesend ?? false,
istVerwalter: sitzung.user.istVerwalter ?? false,
}; };
} }
+71 -2
View File
@@ -14,7 +14,16 @@
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss. * 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 = [ export const ROLLEN = [
"SUPERADMIN", "SUPERADMIN",
"KOMMANDANT", "KOMMANDANT",
@@ -25,6 +34,7 @@ export const ROLLEN = [
"AUSBILDER", "AUSBILDER",
"MITGLIED", "MITGLIED",
"KIOSK", "KIOSK",
"AUFSICHT",
] as const; ] as const;
export type Rolle = (typeof ROLLEN)[number]; export type Rolle = (typeof ROLLEN)[number];
@@ -40,9 +50,12 @@ export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
AUSBILDER: "Ausbilder", AUSBILDER: "Ausbilder",
MITGLIED: "Mitglied", MITGLIED: "Mitglied",
KIOSK: "Terminal im Gerätehaus", KIOSK: "Terminal im Gerätehaus",
AUFSICHT: "Kreisebene",
}; };
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = { export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
AUFSICHT:
"Kreisbrandrat oder Kreisbrandinspektion: sieht die betreuten Wehren, ändert nichts.",
SUPERADMIN: SUPERADMIN:
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.", "Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
KOMMANDANT: KOMMANDANT:
@@ -296,6 +309,39 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich // Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person. // zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"], 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<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
/** Was die Anwendung über den gerade Angemeldeten weiß. */ /** Was die Anwendung über den gerade Angemeldeten weiß. */
export interface Sitzung { 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; organisationId: string;
name: string; name: string;
rollen: Rolle[]; rollen: Rolle[];
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */ /** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM"; 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. */ /** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
+69 -17
View File
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc"; import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz"; import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
import { datenbankErreichbar } from "@/lib/db"; import { datenbankErreichbar, prismaSystem } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage"; import { speicherErreichbar } from "@/lib/storage";
import { geraetRouter } from "@/server/routers/stammdaten/geraet"; import { geraetRouter } from "@/server/routers/stammdaten/geraet";
import { fahrzeugRouter } from "@/server/routers/stammdaten/fahrzeug"; 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 { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person"; import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
import { importRouter } from "@/server/routers/stammdaten/import"; import { importRouter } from "@/server/routers/stammdaten/import";
import { wehrverwaltungRouter } from "@/server/routers/verwaltung/wehr";
import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage"; import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage";
import { wartungsterminRouter } from "@/server/routers/wartung/termin"; import { wartungsterminRouter } from "@/server/routers/wartung/termin";
import { pruefungRouter } from "@/server/routers/wartung/pruefung"; import { pruefungRouter } from "@/server/routers/wartung/pruefung";
@@ -59,30 +60,80 @@ const systemRouter = router({
}); });
const sitzungRouter = 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 }) => { ich: angemeldet.query(async ({ ctx }) => {
const person = await ctx.db((db) => // Ohne Akte — die Kreisaufsicht. Dann gibt es hier nichts zu laden, und
db.person.findUnique({ // die Oberfläche zeigt das Konto statt eines Mitglieds.
where: { id: ctx.sitzung.personId }, const person = ctx.sitzung.personId
select: { ? await ctx.db((db) =>
id: true, db.person.findUnique({
vorname: true, where: { id: ctx.sitzung.personId as string },
nachname: true, select: {
email: true, id: true,
dienstgrad: true, vorname: true,
mitgliedsnummer: true, nachname: true,
letzteAnmeldung: true, email: true,
organisation: { select: { id: true, name: true, kurzname: true, slug: 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 { return {
person, person,
konto,
rollen: ctx.sitzung.rollen, rollen: ctx.sitzung.rollen,
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]), rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
rechte: [...rechteVon(ctx.sitzung.rollen)], rechte: [...rechteVon(ctx.sitzung.rollen)],
anmeldeArt: ctx.sitzung.anmeldeArt, 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, system: systemRouter,
sitzung: sitzungRouter, sitzung: sitzungRouter,
organisation: organisationRouter, organisation: organisationRouter,
wehrverwaltung: wehrverwaltungRouter,
audit: auditRouter, audit: auditRouter,
// Phase 1 — Stammdaten // Phase 1 — Stammdaten
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { DruckArt, EreignisArt, TruppStatus } from "@prisma/client"; 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 { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
@@ -390,7 +390,7 @@ export const atemschutzRouter = router({
bezeichnung: input.bezeichnung, bezeichnung: input.bezeichnung,
einsatzId: input.einsatzId ?? null, einsatzId: input.einsatzId ?? null,
uebungId: input.uebungId ?? null, uebungId: input.uebungId ?? null,
ueberwacherId: ctx.sitzung.personId, ueberwacherId: eigeneAkte(ctx.sitzung),
abfrageIntervall: input.abfrageIntervall ?? env.ATEMSCHUTZ_ABFRAGE_INTERVALL, abfrageIntervall: input.abfrageIntervall ?? env.ATEMSCHUTZ_ABFRAGE_INTERVALL,
sicherheitszuschlag: input.sicherheitszuschlag ?? env.ATEMSCHUTZ_ZUSCHLAG, sicherheitszuschlag: input.sicherheitszuschlag ?? env.ATEMSCHUTZ_ZUSCHLAG,
warndruck: input.warndruck ?? env.ATEMSCHUTZ_WARNDRUCK, warndruck: input.warndruck ?? env.ATEMSCHUTZ_WARNDRUCK,
+5 -5
View File
@@ -9,7 +9,7 @@ import {
Prisma, Prisma,
WaescheStatus, WaescheStatus,
} from "@prisma/client"; } 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 { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import type { MandantenClient } from "@/lib/db"; import type { MandantenClient } from "@/lib/db";
@@ -322,7 +322,7 @@ export const kioskRouter = router({
const ergebnisId = await vorgangAnwenden( const ergebnisId = await vorgangAnwenden(
db, db,
ctx.sitzung.organisationId, ctx.sitzung.organisationId,
ctx.sitzung.personId, eigeneAkte(ctx.sitzung),
ctx.sitzung.name, ctx.sitzung.name,
eingabe, eingabe,
); );
@@ -458,12 +458,12 @@ export const kioskRouter = router({
ctx.db(async (db) => { ctx.db(async (db) => {
const [anwesenheit, fahrt, waesche] = await Promise.all([ const [anwesenheit, fahrt, waesche] = await Promise.all([
db.anwesenheit.findFirst({ db.anwesenheit.findFirst({
where: { personId: ctx.sitzung.personId, abgemeldetUm: null }, where: { personId: eigeneAkte(ctx.sitzung), abgemeldetUm: null },
orderBy: { angemeldetUm: "desc" }, orderBy: { angemeldetUm: "desc" },
select: { id: true, anlass: true, angemeldetUm: true }, select: { id: true, anlass: true, angemeldetUm: true },
}), }),
db.fahrt.findFirst({ db.fahrt.findFirst({
where: { fahrerId: ctx.sitzung.personId, rueckkehrUm: null }, where: { fahrerId: eigeneAkte(ctx.sitzung), rueckkehrUm: null },
orderBy: { abfahrtUm: "desc" }, orderBy: { abfahrtUm: "desc" },
select: { select: {
id: true, id: true,
@@ -474,7 +474,7 @@ export const kioskRouter = router({
}, },
}), }),
db.waescheabgabe.count({ db.waescheabgabe.count({
where: { personId: ctx.sitzung.personId, status: { not: WaescheStatus.ZURUECK } }, where: { personId: eigeneAkte(ctx.sitzung), status: { not: WaescheStatus.ZURUECK } },
}), }),
]); ]);
@@ -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 { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
@@ -16,7 +16,7 @@ export const dienstausweisRouter = router({
eigener: angemeldet.query(async ({ ctx }) => { eigener: angemeldet.query(async ({ ctx }) => {
const person = await ctx.db((db) => const person = await ctx.db((db) =>
db.person.findUnique({ db.person.findUnique({
where: { id: ctx.sitzung.personId }, where: { id: eigeneAkte(ctx.sitzung) },
select: { select: {
vorname: true, vorname: true,
nachname: 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 { return {
person, person,
+7 -8
View File
@@ -146,10 +146,11 @@ export const personRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." }); 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 // Bis Phase 9 wurde hier der Passwort-Hash aus dem Ergebnis geschnitten.
// liegt — das Feld wird hier ausdrücklich entfernt. // Das ist nicht mehr nötig: die Anmeldedaten liegen seit Phase 10 am
const { passwortHash: _hash, ...ohneGeheimnis } = person; // `Benutzer`, und eine Personenabfrage kann sie gar nicht mehr
return ohneGeheimnis; // mitbringen. Die Sorgfaltsmaßnahme ist durch eine Struktur ersetzt.
return person;
}); });
}), }),
@@ -177,8 +178,7 @@ export const personRouter = router({
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}); });
const { passwortHash: _hash, ...ohneGeheimnis } = person; return person;
return ohneGeheimnis;
}), }),
), ),
@@ -215,8 +215,7 @@ export const personRouter = router({
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}); });
const { passwortHash: _hash, ...ohneGeheimnis } = nachher; return nachher;
return ohneGeheimnis;
}), }),
), ),
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { EreignisTyp, Kanal, Zustellstand } from "@prisma/client"; 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 { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { import {
@@ -79,7 +79,7 @@ export const benachrichtigungRouter = router({
eigeneKanaele: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) => eigeneKanaele: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
ctx.db((db) => ctx.db((db) =>
db.zustellkanal.findMany({ db.zustellkanal.findMany({
where: { personId: ctx.sitzung.personId }, where: { personId: eigeneAkte(ctx.sitzung) },
orderBy: [{ kanal: "asc" }, { erstelltAm: "asc" }], orderBy: [{ kanal: "asc" }, { erstelltAm: "asc" }],
select: { select: {
id: true, id: true,
@@ -116,7 +116,7 @@ export const benachrichtigungRouter = router({
if (input.kanal === Kanal.MAIL && input.ziel) { if (input.kanal === Kanal.MAIL && input.ziel) {
const person = await ctx.db((db) => const person = await ctx.db((db) =>
db.person.findUnique({ db.person.findUnique({
where: { id: ctx.sitzung.personId }, where: { id: eigeneAkte(ctx.sitzung) },
select: { email: true }, select: { email: true },
}), }),
); );
@@ -136,14 +136,14 @@ export const benachrichtigungRouter = router({
const kanal = await db.zustellkanal.upsert({ const kanal = await db.zustellkanal.upsert({
where: { where: {
personId_kanal_ziel: { personId_kanal_ziel: {
personId: ctx.sitzung.personId, personId: eigeneAkte(ctx.sitzung),
kanal: input.kanal, kanal: input.kanal,
ziel: input.ziel, ziel: input.ziel,
}, },
}, },
create: { create: {
organisationId: ctx.sitzung.organisationId, organisationId: ctx.sitzung.organisationId,
personId: ctx.sitzung.personId, personId: eigeneAkte(ctx.sitzung),
kanal: input.kanal, kanal: input.kanal,
ziel: input.ziel, ziel: input.ziel,
p256dh: input.p256dh ?? null, p256dh: input.p256dh ?? null,
@@ -284,7 +284,7 @@ export const benachrichtigungRouter = router({
db.benachrichtigung.create({ db.benachrichtigung.create({
data: { data: {
organisationId: ctx.sitzung.organisationId, organisationId: ctx.sitzung.organisationId,
personId: ctx.sitzung.personId, personId: eigeneAkte(ctx.sitzung),
ereignis: EreignisTyp.TERMIN_ERINNERUNG, ereignis: EreignisTyp.TERMIN_ERINNERUNG,
kanal: kanal.kanal, kanal: kanal.kanal,
ziel: kanal.ziel, ziel: kanal.ziel,
@@ -313,7 +313,7 @@ export const benachrichtigungRouter = router({
.query(async ({ ctx, input }) => .query(async ({ ctx, input }) =>
ctx.db((db) => ctx.db((db) =>
db.benachrichtigung.findMany({ db.benachrichtigung.findMany({
where: { personId: ctx.sitzung.personId }, where: { personId: eigeneAkte(ctx.sitzung) },
orderBy: { erstelltAm: "desc" }, orderBy: { erstelltAm: "desc" },
take: input?.anzahl ?? 50, take: input?.anzahl ?? 50,
select: { select: {
@@ -487,7 +487,7 @@ export const benachrichtigungRouter = router({
kalenderzugaenge: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) => kalenderzugaenge: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
ctx.db((db) => ctx.db((db) =>
db.kalenderzugang.findMany({ db.kalenderzugang.findMany({
where: { personId: ctx.sitzung.personId }, where: { personId: eigeneAkte(ctx.sitzung) },
orderBy: { erstelltAm: "desc" }, orderBy: { erstelltAm: "desc" },
select: { select: {
id: true, id: true,
@@ -529,7 +529,7 @@ export const benachrichtigungRouter = router({
const eintrag = await db.kalenderzugang.create({ const eintrag = await db.kalenderzugang.create({
data: { data: {
organisationId: ctx.sitzung.organisationId, organisationId: ctx.sitzung.organisationId,
personId: ctx.sitzung.personId, personId: eigeneAkte(ctx.sitzung),
bezeichnung: input.bezeichnung, bezeichnung: input.bezeichnung,
nurEigene: input.nurEigene, nurEigene: input.nurEigene,
tokenHash, tokenHash,
+6 -6
View File
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { EreignisTyp, Serienmuster, Teilnahmestand, TerminArt } from "@prisma/client"; 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 { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { HOECHSTZAHL, serientermine } from "@/lib/termin/serie"; import { HOECHSTZAHL, serientermine } from "@/lib/termin/serie";
@@ -128,7 +128,7 @@ export const terminRouter = router({
...(input?.art ? { art: input.art } : {}), ...(input?.art ? { art: input.art } : {}),
...(input?.mitAbgesagten === false ? { abgesagtAm: null } : {}), ...(input?.mitAbgesagten === false ? { abgesagtAm: null } : {}),
...(input?.nurEigene ...(input?.nurEigene
? { anmeldungen: { some: { personId: ctx.sitzung.personId } } } ? { anmeldungen: { some: { personId: eigeneAkte(ctx.sitzung) } } }
: {}), : {}),
}, },
orderBy: { beginn: rueckwaerts ? "desc" : "asc" }, orderBy: { beginn: rueckwaerts ? "desc" : "asc" },
@@ -137,7 +137,7 @@ export const terminRouter = router({
...listenauswahl, ...listenauswahl,
_count: { select: { anmeldungen: { where: { stand: Teilnahmestand.ZUGESAGT } } } }, _count: { select: { anmeldungen: { where: { stand: Teilnahmestand.ZUGESAGT } } } },
anmeldungen: { anmeldungen: {
where: { personId: ctx.sitzung.personId }, where: { personId: eigeneAkte(ctx.sitzung) },
select: { stand: true }, select: { stand: true },
take: 1, take: 1,
}, },
@@ -540,7 +540,7 @@ export const terminRouter = router({
} }
const bisher = await db.terminanmeldung.findUnique({ 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 }, select: { id: true, stand: true },
}); });
@@ -560,11 +560,11 @@ export const terminRouter = router({
} }
const eintrag = await db.terminanmeldung.upsert({ 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: { create: {
organisationId: ctx.sitzung.organisationId, organisationId: ctx.sitzung.organisationId,
terminId: termin.id, terminId: termin.id,
personId: ctx.sitzung.personId, personId: eigeneAkte(ctx.sitzung),
stand, stand,
grund: input.grund ?? null, grund: input.grund ?? null,
}, },
+360
View File
@@ -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 };
}),
});
+3 -3
View File
@@ -10,7 +10,7 @@ import {
SignaturRolle, SignaturRolle,
WartungsterminStatus, WartungsterminStatus,
} from "@prisma/client"; } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc"; import { router, mitRecht, eigeneAkte } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam"; import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange"; import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
@@ -282,7 +282,7 @@ export const pruefungRouter = router({
if (laufender) return { pruefung: laufender, fortgesetzt: true }; if (laufender) return { pruefung: laufender, fortgesetzt: true };
const person = await db.person.findUnique({ const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId }, where: { id: eigeneAkte(ctx.sitzung) },
select: { vorname: true, nachname: true }, select: { vorname: true, nachname: true },
}); });
@@ -724,7 +724,7 @@ export const pruefungRouter = router({
} }
const person = await db.person.findUnique({ const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId }, where: { id: eigeneAkte(ctx.sitzung) },
select: { vorname: true, nachname: true }, select: { vorname: true, nachname: true },
}); });
+49
View File
@@ -41,6 +41,30 @@ export const createCallerFactory = t.createCallerFactory;
/** Ohne Anmeldung erreichbar. */ /** Ohne Anmeldung erreichbar. */
export const oeffentlich = t.procedure; 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. */ /** Abfrage im Mandantenkontext der Sitzung. */
export type MandantenAbfrage = <T>(fn: (db: MandantenClient) => Promise<T>) => Promise<T>; export type MandantenAbfrage = <T>(fn: (db: MandantenClient) => Promise<T>) => Promise<T>;
@@ -95,3 +119,28 @@ export function mitRecht(recht: Recht) {
return next(); 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();
});
+23 -1
View File
@@ -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 * 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" { declare module "next-auth" {
interface Session { interface Session {
@@ -14,6 +18,14 @@ declare module "next-auth" {
organisationId: string; organisationId: string;
rollen: Rolle[]; rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"]; 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; organisationId?: string;
rollen?: Rolle[]; rollen?: Rolle[];
anmeldeArt?: Sitzung["anmeldeArt"]; 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"]; anmeldeArt?: Sitzung["anmeldeArt"];
/** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */ /** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */
geladenAm?: number; geladenAm?: number;
benutzerId?: string;
mitgliedschaftId?: string;
nurLesend?: boolean;
istVerwalter?: boolean;
} }
} }
+11 -1
View File
@@ -26,7 +26,17 @@ test.describe("Anmeldung", () => {
}) => { }) => {
await page.goto("/anmelden"); 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.getByLabel("Passwort").fill("definitiv-das-falsche-passwort");
await page.getByRole("button", { name: "Anmelden" }).click(); await page.getByRole("button", { name: "Anmelden" }).click();
+241
View File
@@ -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();
});
});
+91 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { RolleSchluessel } from "@prisma/client";
import { import {
ALLE_RECHTE, ALLE_RECHTE,
KeinZugriffFehler, KeinZugriffFehler,
@@ -24,7 +25,10 @@ import {
* Terminal nur das Nötigste kann. * 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 { return {
personId: "person-1", personId: "person-1",
organisationId, 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", () => { describe("Rollenkatalog", () => {
it("hat für jede Rolle eine deutsche Bezeichnung", () => { it("hat für jede Rolle eine deutsche Bezeichnung", () => {
for (const rolle of ROLLEN) { for (const rolle of ROLLEN) {
@@ -195,3 +216,72 @@ describe("alsRolle", () => {
expect(alsRolle(null)).toBeNull(); 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");
});
});
+8 -2
View File
@@ -60,8 +60,14 @@ beforeAll(async () => {
}); });
personId = person.id; personId = person.id;
const seite = await system.wikiseite.create({ // Wie Wehr und Person darüber: idempotent. Bleibt aus einem abgebrochenen
data: { // 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, organisationId,
slug: "testseite", slug: "testseite",
titel: "Testseite", titel: "Testseite",
+280
View File
@@ -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();
}
});
});