Phase 10: Mandantenfaehigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine Kreisebene, die zusieht. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto, und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig. Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit). Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase 0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft. Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht: `personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat, und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen die Kreisaufsicht nichts verloren hat. Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst, minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie neue Leserechte automatisch und neue Schreibrechte nie. Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand zu hinterlassen. Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte ausloesen, bevor eine Seite ueberhaupt geladen war. 581 Unit-Tests und 266 E2E-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
|
||||
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
|
||||
SEED_ADMIN_EMAIL=admin@beispielwehr.de
|
||||
SEED_ADMIN_PASSWORT=Feuerwehr2026
|
||||
# Das Konto der Kreisebene aus dem Beispielbestand (Phase 10): Mitglied in der
|
||||
# eigenen Wehr, Aufsicht in der Nachbarwehr. Vorgabe leitet sich aus der
|
||||
# Wehrkennung ab, also kbi@ff-musterhausen.de.
|
||||
# SEED_KBI_EMAIL=
|
||||
# SEED_KBI_PASSWORT=Feuerwehr2026
|
||||
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
|
||||
AUTH_BCRYPT_ROUNDS=12
|
||||
|
||||
|
||||
@@ -6,6 +6,130 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach
|
||||
|
||||
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [0.11.0] — 2026-09-05
|
||||
|
||||
Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die
|
||||
zusieht — und eine Verwaltung für den, der die Installation betreibt.
|
||||
|
||||
**Das Anmeldekonto ist von der Personenakte gelöst.** Bis hierher war die Akte
|
||||
zugleich das Konto: Passwort-Hash und OIDC-Subject hingen an `person`, und
|
||||
`person` gehört genau einer Wehr. Ein Mensch konnte damit nicht zu zwei Wehren
|
||||
gehören, ohne zwei Passwörter zu haben — und die Anmeldung per E-Mail war
|
||||
mehrdeutig, sobald dieselbe Adresse in zwei Wehren stand, weil die
|
||||
Eindeutigkeit nur je Organisation galt.
|
||||
|
||||
**Das Konto trägt keine Mandantenkennung — und kann keine tragen.** Es ist der
|
||||
Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. Geschützt wird es
|
||||
deshalb wie `Berechtigung` aus Phase 0: über Rechte statt über Row Level
|
||||
Security. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst,
|
||||
sondern seine `Mitgliedschaft` — und die trägt `organisationId` wie jede
|
||||
andere Tabelle.
|
||||
|
||||
**Die Kreisebene sieht und ändert nichts.** Ihre Rechte werden nicht
|
||||
aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und sonst nichts.
|
||||
Eine Liste von Hand wäre bei der nächsten Phase veraltet, und zwar unbemerkt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Benutzer` — das Anmeldekonto. Global eindeutige E-Mail, ein Passwort, ein
|
||||
OIDC-Subject. Ohne `organisationId`, mit Begründung im Schema.
|
||||
- `Mitgliedschaft` — die Zugehörigkeit eines Kontos zu einer Wehr, als
|
||||
`MITGLIED` (mit Personenakte) oder `AUFSICHT` (ohne).
|
||||
- `Person` verliert die Anmeldefelder. Der Passwort-Hash kann damit aus einer
|
||||
Personenabfrage gar nicht mehr herausfallen — die drei Stellen, die ihn
|
||||
bisher vorsichtshalber abgeschnitten haben, sind entfallen.
|
||||
|
||||
**Was die Datenbank durchsetzt** (Migrationen `20260905090000` und
|
||||
`20260905091500`)
|
||||
|
||||
- RLS für `mitgliedschaft` wie für jede andere Tabelle. Eine Wehr sieht damit
|
||||
auch nicht, in welchen anderen ein Konto noch geführt wird.
|
||||
- Trigger `mitgliedschaft_akte_gleiche_wehr` — eine Zuordnung, deren
|
||||
Personenakte in einer anderen Wehr liegt, wird abgelehnt. Ein Fremdschlüssel
|
||||
prüft nur, *dass* es die Akte gibt, nicht *wo* sie liegt; ohne den Trigger
|
||||
ließe sich ein Konto über eine fremde Akte in eine Wehr hängen.
|
||||
- Trigger `benutzer_kennung_unveraenderlich` — E-Mail, OIDC-Subject und das
|
||||
Verwaltermerkmal lassen sich nicht aus einer Wehr heraus ändern.
|
||||
- `REVOKE INSERT, DELETE ON benutzer` für die Anwendungsrolle.
|
||||
- CHECKs: `MITGLIED` braucht eine Akte, `AUFSICHT` darf keine haben; ein Konto
|
||||
braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine.
|
||||
|
||||
**Die Umstellung ist datenerhaltend.** Jede Person mit Anmeldedaten bekommt
|
||||
ein Konto und eine Mitgliedschaft, bevor die alten Spalten fallen. Wer schon
|
||||
mit derselben Adresse in zwei Wehren stand, bekommt **ein** Konto mit **zwei**
|
||||
Mitgliedschaften — genau das, was vorher nicht möglich war. Eine Prüfung am
|
||||
Ende bricht die Migration ab, statt einen halben Zustand zu hinterlassen.
|
||||
|
||||
**Oberfläche**
|
||||
|
||||
- **Wehrwähler** in der Seitenleiste. Er erscheint nur bei mehr als einer Wehr
|
||||
— ein Auswahlfeld mit genau einem Eintrag ist eine Frage ohne
|
||||
Antwortmöglichkeit.
|
||||
- Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute.
|
||||
- Ein dauerhafter Hinweis, wenn man eine Wehr als Kreisaufsicht sieht — nicht
|
||||
als kurz aufblitzende Meldung.
|
||||
- **`/wehren`** für den Verwalter: Wehren anlegen (samt Systemrollen), Konten
|
||||
führen, Zuordnungen vergeben und beenden. Jede Zuordnung wird in der
|
||||
betroffenen Wehr protokolliert, nicht beim Verwalter — sonst hätte der
|
||||
Betreiber einen Weg, sich still in fremde Daten zu setzen.
|
||||
|
||||
**Rechte**
|
||||
|
||||
- Neue Rolle `AUFSICHT` — bewusst **nicht** im Prisma-Enum: sie wird nicht
|
||||
vergeben, sondern ergibt sich aus der Mitgliedschaftsart.
|
||||
- Ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden: `kasse.lesen` (die
|
||||
Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht),
|
||||
`person.export`, `system.auditlog`, `ki.protokoll`.
|
||||
- `istVerwalter` als Merkmal des Kontos, nicht als Recht.
|
||||
|
||||
**Tests**
|
||||
|
||||
- 19 neue Unit-Tests, davon 11 gegen eine echte Datenbank: Doppelmitgliedschaft,
|
||||
der Trigger gegen wehrfremde Akten, die CHECKs und die Trennung der
|
||||
Zuordnungen über RLS.
|
||||
- 13 E2E-Tests: kein Wähler bei einer Wehr, Wechsel in die betreute und
|
||||
zurück, die Mitglieder der anderen Wehr, die verschlossene Kasse, und dass
|
||||
der Administrator die Umstellung überlebt hat.
|
||||
|
||||
**Seed**
|
||||
|
||||
- Eine zweite, kleine Wehr je Mandant und ein Konto, das in der einen Mitglied
|
||||
und in der anderen Kreisaufsicht ist. Ohne sie ließe sich die Phase nicht
|
||||
ausprobieren — und eine Mandantentrennung, die nie zwei Mandanten hatte, ist
|
||||
keine geprüfte.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Eine Sitzung ohne Personenakte ist gültig. Bis hierher galt genau das als
|
||||
„nicht angemeldet"; für die Kreisaufsicht, die in der betreuten Wehr keine
|
||||
Akte hat, hätte jeder Aufruf auf der Anmeldeseite geendet.
|
||||
- `Sitzung.personId` ist nullbar. Der Typechecker hat damit die fünf Stellen
|
||||
gezeigt, an denen eine Aufsichtssitzung nichts verloren hat — Anmeldemarke,
|
||||
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz.
|
||||
- Die Mitgliederansicht zeigt statt eines ungenutzten Zeitstempels, ob jemand
|
||||
überhaupt ein Konto hat. Das ist die nützlichere Auskunft: ohne Konto kommt
|
||||
er nicht in FLORIAN hinein.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Ein Testaufbau war nicht wiederholbar.** `ki-datenbank.test.ts` legte Wehr
|
||||
und Person idempotent an, die Wiki-Seite aber nicht — blieb aus einem
|
||||
abgebrochenen Lauf etwas stehen, scheiterte der ganze Datei-Lauf an einer
|
||||
Eindeutigkeit, die mit dem Geprüften nichts zu tun hat.
|
||||
- **`db:test-reset` ließ die Nachbarwehr stehen** und der Seed hielt daraufhin
|
||||
alles für erledigt — die Mitgliedschaft in der neu angelegten Testwehr
|
||||
entstand nie wieder. Der Reset nimmt sie jetzt mit, und der Seed prüft
|
||||
Nachbarwehr und Kreiskonto getrennt statt mit einem gemeinsamen Ausstieg.
|
||||
- **Der Fehlversuch-Test sperrte das gemeinsame Testkonto.** Fünf Fehlversuche
|
||||
sperren ein Konto für fünf Minuten; bei zwei Projekten und Wiederholungen
|
||||
kam das im vollen Lauf zusammen, und fremde Testdateien fielen mit
|
||||
„Anmeldung fehlgeschlagen“ aus. Er nimmt jetzt eine Adresse, die es nicht
|
||||
gibt — die Meldung muss ohnehin dieselbe sein.
|
||||
|
||||
---
|
||||
|
||||
## [0.10.0] — 2026-09-04
|
||||
|
||||
Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an
|
||||
|
||||
@@ -33,6 +33,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||||
| Phase 8 — Lokale KI | fertig |
|
||||
| Phase 9 — Kassenverwaltung | fertig |
|
||||
| Phase 10 — Mandantenfähigkeit | fertig |
|
||||
|
||||
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
||||
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
||||
@@ -1305,6 +1306,147 @@ läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Mandantenfähigkeit
|
||||
|
||||
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine
|
||||
Kreisebene, die zusieht.
|
||||
|
||||
Datenmodell und Row Level Security tragen seit Phase 0 mehrere Wehren — jede
|
||||
Tabelle hat `organisationId`, jede Abfrage läuft im Mandantenkontext. Was
|
||||
fehlte, war alles darüber: eine Anmeldung, die zu mehr als einer Wehr gehören
|
||||
kann, ein Weg zum Umschalten, und eine Verwaltung für den, der den Server
|
||||
betreibt.
|
||||
|
||||
### Konto und Akte sind nicht dasselbe
|
||||
|
||||
Bis Phase 9 war die Personenakte zugleich das Anmeldekonto: Passwort-Hash und
|
||||
OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. Daraus
|
||||
folgten zwei Dinge, die niemand wollte:
|
||||
|
||||
- Wer in zwei Wehren geführt wurde, brauchte **zwei Passwörter**, die
|
||||
auseinanderlaufen können.
|
||||
- Die Anmeldung per E-Mail war **mehrdeutig**, sobald dieselbe Adresse in zwei
|
||||
Wehren stand — die Eindeutigkeit galt nur je Organisation, und gefunden
|
||||
wurde, was die Datenbank zuerst hergab.
|
||||
|
||||
Seit Phase 10:
|
||||
|
||||
| Tabelle | Was sie ist | Mandant |
|
||||
|---|---|---|
|
||||
| `Benutzer` | das Anmeldekonto — E-Mail, Passwort, OIDC | **keiner** |
|
||||
| `Mitgliedschaft` | Zugehörigkeit eines Kontos zu einer Wehr | `organisationId` |
|
||||
| `Person` | die Akte in einer Wehr — Dienstgrad, Qualifikationen, Einsätze | `organisationId` |
|
||||
|
||||
**`Benutzer` trägt bewusst keine Mandantenkennung** und kann keine tragen: sie
|
||||
ist ja der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift
|
||||
dort deshalb nicht. Geschützt wird sie stattdessen über Rechte — dieselbe
|
||||
Lösung wie bei `Berechtigung` aus Phase 0 und aus demselben Grund: eine
|
||||
Tabelle, die über allen Mandanten steht, gehört nicht in die Hand der
|
||||
Anwendungsrolle. Was ein Konto in einer Wehr darf, entscheidet nicht es
|
||||
selbst, sondern seine `Mitgliedschaft`.
|
||||
|
||||
Ein angenehmer Nebeneffekt: der Passwort-Hash kann aus einer Personenabfrage
|
||||
gar nicht mehr herausfallen. Die drei Stellen, die ihn bisher vorsichtshalber
|
||||
abgeschnitten haben, sind entfallen — eine Sorgfaltsmaßnahme ist durch eine
|
||||
Struktur ersetzt.
|
||||
|
||||
### Die Wehr wechseln
|
||||
|
||||
Der Wähler steht in der Seitenleiste unter dem Namen der Wehr — **aber nur,
|
||||
wenn es etwas zu wählen gibt.** Für die allermeisten gibt es das nie, und ein
|
||||
Auswahlfeld mit genau einem Eintrag ist keine Hilfe, sondern eine Frage ohne
|
||||
Antwortmöglichkeit.
|
||||
|
||||
Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute: wer eine
|
||||
Wehr führt und acht andere beaufsichtigt, will in seiner landen.
|
||||
|
||||
Technisch: die Wehr steht im Sitzungstoken, also wird beim Wechsel ein neues
|
||||
ausgestellt. Was der Browser dabei schickt, ist ein **Wunsch** — geprüft wird
|
||||
serverseitig gegen die Mitgliedschaften des Kontos. Eine fremde Kennung zu
|
||||
nennen bringt nichts, weil es dazu keine Mitgliedschaft gibt.
|
||||
|
||||
### Die Kreisebene
|
||||
|
||||
Kreisbrandrat und Kreisbrandinspektion gehören den betreuten Wehren nicht an.
|
||||
Sie bekommen deshalb dort **keine Personenakte**, tauchen in keiner
|
||||
Mitgliederliste auf und können nichts ändern.
|
||||
|
||||
Ihre Rechte werden nicht aufgezählt, sondern **gefiltert**: alles, was
|
||||
`.lesen` heißt, und sonst nichts. Eine von Hand gepflegte Liste wäre bei der
|
||||
nächsten Phase veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte
|
||||
mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte
|
||||
automatisch und neue Schreibrechte nie.
|
||||
|
||||
Vier Rechte sind ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden:
|
||||
|
||||
| Recht | Warum nicht |
|
||||
|---|---|
|
||||
| `kasse.lesen` | Die Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. |
|
||||
| `person.export` | Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt. |
|
||||
| `system.auditlog` | Wer wann was getan hat, bleibt innerhalb der Wehr. |
|
||||
| `ki.protokoll` | Dasselbe. |
|
||||
|
||||
Der **Atemschutz** steht dagegen bewusst drin: ob ein Träger tauglich ist, ist
|
||||
genau die Frage, für die es eine Kreisebene gibt.
|
||||
|
||||
Wer als Aufsicht unterwegs ist, sieht dauerhaft einen Hinweis darauf — nicht
|
||||
als kurz aufblitzende Meldung, sondern als Zeile über der Ansicht. Man soll
|
||||
nicht erst merken, in welcher Rolle man ist, wenn ein Knopf fehlt.
|
||||
|
||||
### Doppelmitgliedschaft
|
||||
|
||||
Ein Konto, mehrere Zugehörigkeiten. In Bayern der Regelfall bei allem, was
|
||||
über die eigene Wehr hinausgeht: aktiv in A, Ehrenmitglied in B, oder
|
||||
Kommandant der einen und Kreisbrandinspektor für sechs weitere.
|
||||
|
||||
Zwei Zusicherungen setzt die Datenbank durch:
|
||||
|
||||
- **Ein Konto gehört einer Wehr höchstens einmal an.**
|
||||
- **Eine Personenakte hängt an höchstens einem Konto.** Sonst wäre nicht
|
||||
entscheidbar, wer unter ihrem Namen gehandelt hat.
|
||||
|
||||
Und eine dritte, die weniger offensichtlich ist: die Akte muss zu **derselben
|
||||
Wehr** gehören wie die Zuordnung. Ein Fremdschlüssel prüft nur, *dass* es die
|
||||
Akte gibt, nicht *wo* sie liegt — ohne diese Prüfung ließe sich ein Konto über
|
||||
eine Akte aus Wehr A in Wehr B hängen, und die Sitzung stünde dort mit einer
|
||||
Personenkennung da, die niemandem gehört. Ein Trigger lehnt das ab.
|
||||
|
||||
### Wehren und Konten verwalten
|
||||
|
||||
Unter **`/wehren`**, und nur für den, der die Installation betreibt.
|
||||
|
||||
Der Zugang hängt an einem Merkmal des Kontos (`istVerwalter`), **nicht an
|
||||
einem Recht**. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein
|
||||
Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre genau die
|
||||
Hintertür, die diese Phase vermeiden soll.
|
||||
|
||||
Was dort geht:
|
||||
|
||||
- Eine Wehr anlegen — sie bekommt sofort ihre Systemrollen samt Rechten. Ein
|
||||
Konto bekommt sie nicht: eine Wehr anzulegen und jemanden hineinzusetzen
|
||||
sind zwei Entscheidungen, und die zweite trifft meist jemand anders.
|
||||
- Ein Konto anlegen — ohne Wehr. Die kommt über die Zuordnung.
|
||||
- Zuordnen: als Mitglied mit Personenakte oder als Kreisaufsicht ohne.
|
||||
|
||||
**Jede Zuordnung wird in der betroffenen Wehr protokolliert**, nicht beim
|
||||
Verwalter. Sonst hätte der Betreiber einen Weg, sich still in fremde Daten zu
|
||||
setzen.
|
||||
|
||||
**Keine zusätzlichen Umgebungsvariablen.**
|
||||
|
||||
### Was bewusst fehlt
|
||||
|
||||
**Wehrübergreifende Auswertungen.** Eine Zahl über mehrere Wehren hinweg
|
||||
braucht eine abgestimmte Definition — was zählt als Einsatz, was als Übung,
|
||||
wie wird eine Doppelmitgliedschaft gezählt. Ohne die ist eine solche Zahl
|
||||
schlimmer als keine, weil sie benutzt wird. Die Aufsicht sieht jede Wehr
|
||||
einzeln, und das reicht für die Fragen, die tatsächlich gestellt werden.
|
||||
|
||||
**Ein Einladungsverfahren.** Passwörter werden vom Verwalter vergeben und
|
||||
weitergegeben. FLORIAN verschickt keine.
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 9 (Version 0.10.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
---
|
||||
@@ -1986,7 +1986,138 @@ Dezimaltrennzeichen, UTF-8 mit Kennung.
|
||||
|
||||
---
|
||||
|
||||
## 54. Rollen und was sie dürfen
|
||||
## 54. Mehrere Wehren auf einem Server
|
||||
|
||||
Ein FLORIAN kann mehrere Wehren führen — eine Gemeinde mit drei Ortswehren,
|
||||
ein Landkreis mit zwanzig. Die Wehren sehen einander dabei **nicht**. Jede
|
||||
Abfrage ist an eine Wehr gebunden, und zwar in der Datenbank selbst, nicht im
|
||||
Programm.
|
||||
|
||||
### Konto und Personenakte
|
||||
|
||||
Zwei verschiedene Dinge, und der Unterschied ist der Kern dieses Kapitels:
|
||||
|
||||
| | Was es ist |
|
||||
|---|---|
|
||||
| **Konto** | Womit Sie sich anmelden. E-Mail und Passwort. Gehört keiner Wehr. |
|
||||
| **Personenakte** | Ihr Eintrag in einer Wehr: Dienstgrad, Qualifikationen, Einsätze. |
|
||||
| **Zuordnung** | Verbindet beides — je Wehr eine. |
|
||||
|
||||
Wer nur zu einer Wehr gehört, merkt davon nichts: ein Konto, eine Akte, eine
|
||||
Zuordnung, und alles sieht aus wie vorher.
|
||||
|
||||
Wer zu **zwei** Wehren gehört — aktiv in der einen, Ehrenmitglied in der
|
||||
anderen —, hat trotzdem nur **ein** Passwort. Zwei Akten, ein Konto.
|
||||
|
||||
### Die Wehr wechseln
|
||||
|
||||
Steht Ihr Konto bei mehreren Wehren, wird der Name der Wehr in der
|
||||
Seitenleiste zu einem Auswahlfeld. Ein Klick darauf, und alle Wehren stehen
|
||||
untereinander — mit dem Hinweis, ob Sie dort Mitglied oder Kreisaufsicht sind.
|
||||
|
||||
Nach der Anmeldung sind Sie immer in **Ihrer eigenen** Wehr, nicht in einer
|
||||
betreuten.
|
||||
|
||||
Nach dem Wechsel wird alles neu geladen. Was Sie danach sehen, gehört zur
|
||||
neuen Wehr — es gibt keinen Zustand, in dem Daten der einen unter dem Namen
|
||||
der anderen stehen.
|
||||
|
||||
---
|
||||
|
||||
## 55. Kreisbrandrat und Kreisbrandinspektion
|
||||
|
||||
Die Kreisebene bekommt Zugang zu den betreuten Wehren, ohne ihnen anzugehören.
|
||||
|
||||
**Sie sieht und ändert nichts.** Das ist keine Einstellung, die jemand
|
||||
versehentlich anders setzen kann: eine Aufsichtszuordnung bekommt genau die
|
||||
Leserechte und keines der Schreibrechte, und zwar automatisch auch für Module,
|
||||
die es noch gar nicht gibt.
|
||||
|
||||
Wer als Aufsicht in einer Wehr unterwegs ist, sieht das durchgehend als Zeile
|
||||
über der Ansicht.
|
||||
|
||||
### Was die Aufsicht sieht
|
||||
|
||||
Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze, Übungen,
|
||||
Qualifikationen, Termine, Wiki, Karte.
|
||||
|
||||
Der **Atemschutz** ist dabei der wichtigste Punkt: ob ein Träger tauglich ist,
|
||||
ist genau die Frage, für die es eine Kreisebene gibt.
|
||||
|
||||
### Was sie nicht sieht
|
||||
|
||||
| | Warum |
|
||||
|---|---|
|
||||
| **Kasse** | Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. |
|
||||
| **Protokoll** | Wer wann was getan hat, bleibt innerhalb der Wehr. |
|
||||
| **KI-Protokoll** | Dasselbe. |
|
||||
| **Personenexport nach DSGVO** | Das Auskunftsrecht erfüllt die Wehr, die die Daten führt. |
|
||||
|
||||
### Sie taucht in keiner Mitgliederliste auf
|
||||
|
||||
Die Aufsicht hat in der betreuten Wehr **keine Personenakte**. Sie ist also
|
||||
kein Mitglied, erscheint in keiner Liste, in keiner Stärkemeldung und in
|
||||
keiner Anwesenheit — und kann als Urheber einer Änderung auch gar nicht
|
||||
eingetragen werden, weil sie keine macht.
|
||||
|
||||
Ist der Kreisbrandinspektor zugleich Kommandant seiner Heimatwehr, hat er dort
|
||||
ganz normal seine Akte und seine Rollen. Es sind zwei Zuordnungen desselben
|
||||
Kontos, mehr nicht.
|
||||
|
||||
---
|
||||
|
||||
## 56. Wehren und Konten verwalten
|
||||
|
||||
Unter **Wehren und Konten**. Der Menüpunkt erscheint nur für den, der die
|
||||
Installation betreibt — nicht für Kommandanten, auch nicht für
|
||||
Administratoren einer einzelnen Wehr.
|
||||
|
||||
> Dieses Merkmal hebt die Trennung zwischen den Wehren auf. Es gehört an so
|
||||
> wenige Konten wie möglich, im Regelfall an eines.
|
||||
|
||||
### Eine Wehr anlegen
|
||||
|
||||
Vollständiger Name, Kurzname und eine **Kennung**. Die Kennung steht später
|
||||
im Terminal und in Dateipfaden und lässt sich nicht mehr ändern — also mit
|
||||
Bedacht wählen: Kleinbuchstaben, Ziffern, Bindestriche, etwa
|
||||
`ff-musterhausen`.
|
||||
|
||||
Die neue Wehr bekommt sofort ihre Rollen samt Rechten. Ein Konto bekommt sie
|
||||
nicht: das ist der zweite Schritt.
|
||||
|
||||
### Ein Konto anlegen
|
||||
|
||||
E-Mail-Adresse und ein erstes Passwort. Die Adresse gilt über alle Wehren
|
||||
hinweg und ist damit die Anmeldung.
|
||||
|
||||
Das Passwort steht im Klartext da, damit Sie es weitergeben können — FLORIAN
|
||||
verschickt keines. Es gehört nach der ersten Anmeldung geändert.
|
||||
|
||||
Gibt es zu der Adresse schon ein Konto, sagt FLORIAN das und legt **kein
|
||||
zweites** an. Wenn diese Person zu einer weiteren Wehr gehören soll, bekommt
|
||||
sie eine weitere Zuordnung — genau dafür ist die Trennung da.
|
||||
|
||||
### Zuordnen
|
||||
|
||||
Konto wählen, **Wehr zuordnen**, dann:
|
||||
|
||||
- **Mitglied** — zusätzlich die Personenakte in dieser Wehr wählen. Zur
|
||||
Auswahl steht, wer noch kein Konto hat: eine Akte gehört zu höchstens einem,
|
||||
sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat.
|
||||
- **Kreisaufsicht** — keine Akte, nur Leserechte.
|
||||
|
||||
Jede Zuordnung wird **in der betroffenen Wehr** protokolliert. Dort steht also
|
||||
nachlesbar, dass jemand von außen Zugang bekommen hat.
|
||||
|
||||
### Eine Zuordnung beenden
|
||||
|
||||
Über das Papierkorb-Symbol. Die Personenakte in der Wehr bleibt bestehen —
|
||||
entfernt wird nur der Zugang. Wer ausgetreten ist, wird in der Mitgliederliste
|
||||
ausgetragen, nicht hier.
|
||||
|
||||
---
|
||||
|
||||
## 57. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -2002,6 +2133,7 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
|
||||
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
|
||||
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
|
||||
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten, im Wiki lesen |
|
||||
| **Kreisebene** | sieht die betreuten Wehren, ändert nichts — siehe [Kapitel 55](#55-kreisbrandrat-und-kreisbrandinspektion) |
|
||||
| **Terminal** | nur anmelden und erfassen — nie verwalten |
|
||||
|
||||
Zwei Dinge sind bewusst so gebaut:
|
||||
@@ -2020,7 +2152,7 @@ sind nur die Textvorlagen, und die hängen an einem eigenen Recht.
|
||||
|
||||
---
|
||||
|
||||
## 55. Anmeldung über authentik einrichten
|
||||
## 58. Anmeldung über authentik einrichten
|
||||
|
||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||
|
||||
@@ -2073,7 +2205,7 @@ ganze Wehr aus.
|
||||
|
||||
---
|
||||
|
||||
## 56. PIN und Ausweiskarte verwalten
|
||||
## 59. PIN und Ausweiskarte verwalten
|
||||
|
||||
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||
was dahinter liegt.
|
||||
@@ -2096,7 +2228,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 57. Was protokolliert wird
|
||||
## 60. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -2121,7 +2253,7 @@ jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 58. Wenn etwas nicht geht
|
||||
## 61. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
|
||||
@@ -40,7 +40,8 @@ Faktoren, die in die Schätzung eingehen:
|
||||
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
|
||||
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
|
||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** |
|
||||
| | **Summe** | **123–164 PT** | **28–37 h** | **28:30 h** |
|
||||
| 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 h** |
|
||||
| | **Summe** | **127–170 PT** | **29–38,5 h** | **32:50 h** |
|
||||
|
||||
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
||||
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
||||
@@ -432,6 +433,31 @@ der Prüfbericht, der nach dem Festschreiben entsteht, und die
|
||||
DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen
|
||||
scheiterte.
|
||||
|
||||
## Phase 10 — Mandantenfähigkeit
|
||||
|
||||
**Vergleichsaufwand: 4–6 PT** — aus der Kandidatenliste vorgezogen, am
|
||||
05.09.2026 umgesetzt.
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
| `Benutzer` und `Mitgliedschaft`, datenerhaltende Umstellung | 1,5 |
|
||||
| Anmeldung: Konto statt Akte, Wehrwechsel im Sitzungstoken | 1,5 |
|
||||
| Kreisebene mit reinen Leserechten | 1,0 |
|
||||
| Wehrwähler und Verwaltung der Installation | 1,5 |
|
||||
|
||||
Die Schätzung ging von „technisch bereits möglich, Oberfläche fehlt" aus —
|
||||
und das war zur Hälfte falsch. Datenmodell und RLS trugen tatsächlich mehrere
|
||||
Wehren, aber die Anmeldung nicht: Passwort und OIDC-Subject hingen an der
|
||||
Personenakte, und die gehört genau einer Wehr. Das war kein Oberflächenthema,
|
||||
sondern ein Umbau am Fundament.
|
||||
|
||||
**Tatsächlich: 4:20 h.** Getragen hat eine Entscheidung ganz am Anfang: die
|
||||
Sitzung behält ihre Form und entsteht nur anders. Ohne sie wären 61 Dateien
|
||||
mit 414 Fundstellen zu ändern gewesen; so waren es acht. Die Zeit ging
|
||||
stattdessen in die Absicherung — zwei Trigger, vier CHECKs und elf
|
||||
Datenbanktests — und das ist die richtige Verteilung für eine Phase, deren
|
||||
einziger Zweck es ist, dass eine Wehr die andere nicht sieht.
|
||||
|
||||
---
|
||||
|
||||
## Was hier nicht drin ist
|
||||
|
||||
@@ -22,7 +22,109 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
|
||||
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
|
||||
| Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 |
|
||||
| **Summe** | **28:30 h** | **12** | |
|
||||
| Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 |
|
||||
| **Summe** | **32:50 h** | **13** | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-05 · Phase 10 — Mandantenfähigkeit
|
||||
|
||||
**08:10 – 12:30 · 4:20 h · Version 0.11.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Die einzige Phase bisher, die die Anmeldung selbst anfasst.
|
||||
|
||||
| Bereich | Umfang |
|
||||
|---|---|
|
||||
| Datenmodell | `Benutzer`, `Mitgliedschaft`, `MitgliedschaftArt`; `Person` verliert die Anmeldefelder |
|
||||
| Absicherung | RLS für `mitgliedschaft`, zwei Trigger, vier CHECKs, ein REVOKE |
|
||||
| Anmeldung | `lib/auth.ts` neu: Konto statt Akte, Mitgliedschaftswahl, Wehrwechsel im Token |
|
||||
| Rechte | Rolle `AUFSICHT`, gefiltert statt aufgezählt; `istVerwalter` als Kontomerkmal |
|
||||
| Oberfläche | Wehrwähler, Aufsichtshinweis, `/wehren` zur Verwaltung |
|
||||
| Tests | 19 neue Unit-Tests (11 gegen die Datenbank), 13 E2E-Tests |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Sitzung behält ihre Form.** `sitzung.organisationId` und
|
||||
`sitzung.personId` werden an 329 bzw. 85 Stellen in 61 Dateien gelesen. Hätte
|
||||
sich ihre Bedeutung geändert, wäre das ein Umbau durch das halbe Projekt
|
||||
geworden. Stattdessen entsteht die Sitzung nur *anders* — aus der aktiven
|
||||
Mitgliedschaft statt aus der Person. Keine dieser 61 Dateien musste angefasst
|
||||
werden.
|
||||
|
||||
**`personId` ist nullbar geworden.** Das war die eine Ausnahme davon, und zwar
|
||||
absichtlich: eine Aufsichtssitzung hat keine Akte. Der Typechecker hat daraus
|
||||
eine Liste gemacht — genau fünf Stellen verlangen eine Person, und alle fünf
|
||||
sind Dinge, an denen die Kreisaufsicht nichts verloren hat (Anmeldemarke,
|
||||
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz). Ein `?? ""` hätte
|
||||
dieselben fünf Stellen still passieren lassen.
|
||||
|
||||
**`Benutzer` ohne `organisationId`.** Die zweite Ausnahme von der
|
||||
Architekturregel nach `Berechtigung` — und dieselbe Begründung: die Tabelle
|
||||
steht über allen Mandanten, weil sie der Angelpunkt ist, an dem ein Mensch zu
|
||||
mehreren Wehren gehört. Geschützt über Rechte statt RLS.
|
||||
|
||||
**`AUFSICHT` steht nicht im Prisma-Enum.** Alle anderen Rollen werden einem
|
||||
Menschen in einer Wehr zugewiesen. Diese ergibt sich aus der Mitgliedschaftsart.
|
||||
Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an
|
||||
einer anderen verteilen könnte.
|
||||
|
||||
**Ihre Rechte werden gefiltert, nicht aufgezählt.** Alles, was `.lesen` heißt,
|
||||
minus vier ausdrückliche Ausnahmen. Eine Liste von Hand wäre bei Phase 11
|
||||
veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand
|
||||
nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue
|
||||
Schreibrechte nie. Der Test dazu ist entsprechend formuliert: nicht „diese
|
||||
dreizehn Rechte", sondern „kein einziges, das nicht lesen heißt".
|
||||
|
||||
**Die Kasse ist ausgenommen**, obwohl `kasse.lesen` auf `.lesen` endet. Sie
|
||||
gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. Sie wäre nur
|
||||
deshalb dabei gewesen, weil das Recht zufällig richtig heißt — und genau
|
||||
dagegen ist die Ausnahmeliste da.
|
||||
|
||||
**Die Migration ist datenerhaltend, mit Abbruchprüfung.** Konten und
|
||||
Zuordnungen entstehen, *bevor* die alten Spalten fallen; am Ende prüft ein
|
||||
`DO`-Block, dass keine Person mit Anmeldedaten ohne Konto dasteht, und bricht
|
||||
sonst ab. Eine Migration, die die Anmeldung anfasst und auf halbem Weg
|
||||
liegenbleibt, sperrt alle aus.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Nach dem Wehrwechsel landete alles auf der Anmeldeseite | `aktuelleSitzung()` verlangte eine Personenkennung — die Aufsicht hat keine und galt damit als nicht angemeldet | Eine Sitzung ist gültig, wenn Wehr und Konto feststehen; die Akte darf fehlen |
|
||||
| Der Wechsel schien nicht zu greifen | Er griff: das Token trug die neue Wehr. Es war der dritte hängengebliebene Entwicklungsserver dieser Woche — `npm run build` neben laufendem `next dev` macht `.next` unbrauchbar, und der Server antwortet danach nur noch mit 500 | Server beenden, `.next` löschen. Diesmal notiert, damit es nicht ein viertes Mal passiert |
|
||||
| Der Seed brach ab | Er legt die Rollen aus `ROLLEN` an, und dort steht jetzt `AUFSICHT` — die es im Prisma-Enum bewusst nicht gibt | Im Seed gefiltert, mit Begründung |
|
||||
| `unstable_update` nahm den Wechselwunsch nicht an | Es akzeptiert nur `Partial<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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Phase 6 ████████████████████ fertig
|
||||
Phase 7 ████████████████████ fertig 0.8.0
|
||||
Phase 8 ████████████████████ fertig 0.9.0
|
||||
Phase 9 ████████████████████ fertig 0.10.0
|
||||
Phase 10 ████████████████████ fertig 0.11.0
|
||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
||||
```
|
||||
|
||||
@@ -260,6 +261,35 @@ ein Skript oder ein Import würden sonst reichen, um den geprüften Bestand zu
|
||||
**Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf
|
||||
Knopfdruck erzeugen.
|
||||
|
||||
## Phase 10 — Mandantenfähigkeit · fertig
|
||||
|
||||
Am 05.09.2026 ergänzt. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren;
|
||||
was fehlte, war alles darüber.
|
||||
|
||||
- `Benutzer` und `Mitgliedschaft`: das Anmeldekonto ist von der Personenakte
|
||||
gelöst. Ein Mensch, ein Passwort, beliebig viele Wehren.
|
||||
- Wehrwähler in der Seitenleiste — er erscheint nur, wenn es etwas zu wählen
|
||||
gibt.
|
||||
- Die Rolle `AUFSICHT` für die Kreisebene: Kreisbrandrat und
|
||||
Kreisbrandinspektion sehen die betreuten Wehren und ändern nichts.
|
||||
- Verwaltung der Installation unter `/wehren`: Wehren anlegen, Konten führen,
|
||||
Zuordnungen vergeben.
|
||||
|
||||
Die Entscheidung, die alles andere bestimmt: **das Konto trägt keine
|
||||
Mandantenkennung.** Es kann keine tragen — es ist ja der Angelpunkt, an dem
|
||||
ein Mensch zu mehreren Wehren gehört. Geschützt wird es deshalb wie
|
||||
`Berechtigung` über Rechte statt über Row Level Security, und was ein Konto in
|
||||
einer Wehr darf, entscheidet nicht es selbst, sondern seine `Mitgliedschaft` —
|
||||
und die trägt `organisationId` wie jede andere Tabelle.
|
||||
|
||||
**Bewusst nicht dabei:** wehrübergreifende Auswertungen. Eine Zahl über
|
||||
mehrere Wehren hinweg braucht eine abgestimmte Definition, und ohne die ist
|
||||
sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln.
|
||||
|
||||
**Die Wehr kann jetzt:** sich einen Server mit anderen teilen, ohne dass
|
||||
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
|
||||
etwas anfassen zu können.
|
||||
|
||||
## 1.0.0 — Freigabe
|
||||
|
||||
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
||||
@@ -282,6 +312,9 @@ Am 03.09.2026 entschieden:
|
||||
|---|---|
|
||||
| Kassenmodul | Als **Phase 9** ergänzt und mit 0.10.0 umgesetzt. Spezifikation in `Phase-9.md`. |
|
||||
| `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. |
|
||||
| `Benutzer` ohne `organisationId` | Notwendige Ausnahme, seit Phase 10. Die Tabelle ist der Angelpunkt der Doppelmitgliedschaft — mit Mandantenkennung könnte sie das nicht sein. Geschützt über Rechte statt RLS, wie `Berechtigung`: die Anwendungsrolle darf lesen und die Anmeldefelder fortschreiben, mehr nicht. |
|
||||
| `AUFSICHT` nicht im Prisma-Enum | Die Kreisebene ergibt sich aus der Mitgliedschaftsart, nicht aus einer Rollenzuweisung. Vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an einer anderen verteilen könnte. |
|
||||
| `istVerwalter` als Kontomerkmal | Kein Recht. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die Hintertür, die Phase 10 vermeiden soll. |
|
||||
| Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. |
|
||||
|
||||
## Weitere Kandidaten für später
|
||||
@@ -292,10 +325,8 @@ Am 03.09.2026 entschieden:
|
||||
| Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT |
|
||||
| Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT |
|
||||
| Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS |
|
||||
| Mehrere Wehren je Installation | technisch bereits möglich, Oberfläche fehlt | 4–6 PT |
|
||||
|
||||
Die Mandantenfähigkeit ist der Punkt, der ohne Zusatzaufwand am weitesten
|
||||
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist
|
||||
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende
|
||||
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das
|
||||
der nächste sinnvolle Schritt.
|
||||
Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
|
||||
Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
|
||||
gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
|
||||
Definition dessen, was verglichen werden soll.
|
||||
|
||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 745 KiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 764 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 639 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 959 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 359 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"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();
|
||||
@@ -107,6 +107,21 @@ enum FahrzeugArt {
|
||||
}
|
||||
|
||||
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
|
||||
/// Wie jemand zu einer Wehr gehört.
|
||||
///
|
||||
/// MITGLIED ist der Regelfall: das Konto hängt an einer Personenakte in dieser
|
||||
/// Wehr und bringt deren Rollen mit.
|
||||
///
|
||||
/// AUFSICHT ist die Kreisebene — Kreisbrandrat, Kreisbrandinspektion. Sie
|
||||
/// gehört der Wehr nicht an, hat dort also auch keine Personenakte, und darf
|
||||
/// ausschließlich lesen. Ein Kreisbrandinspektor, der zugleich Mitglied seiner
|
||||
/// Heimatwehr ist, hat schlicht zwei Mitgliedschaften: dort MITGLIED, in den
|
||||
/// betreuten Wehren AUFSICHT.
|
||||
enum MitgliedschaftArt {
|
||||
MITGLIED
|
||||
AUFSICHT
|
||||
}
|
||||
|
||||
enum Dienstgrad {
|
||||
FEUERWEHRANWAERTER
|
||||
FEUERWEHRMANN
|
||||
@@ -153,6 +168,7 @@ model Organisation {
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
personen Person[]
|
||||
mitgliedschaften Mitgliedschaft[]
|
||||
rollen Rolle[]
|
||||
personRollen PersonRolle[]
|
||||
rolleRechte RolleBerechtigung[]
|
||||
@@ -280,18 +296,13 @@ model Person {
|
||||
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
|
||||
abteilung String? @db.VarChar(60)
|
||||
|
||||
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
|
||||
passwortHash String? @db.VarChar(120)
|
||||
passwortGeaendertAm DateTime? @db.Timestamptz(3)
|
||||
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
|
||||
oidcSubject String? @db.VarChar(255)
|
||||
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
|
||||
oidcGruppen String[] @default([])
|
||||
|
||||
letzteAnmeldung DateTime? @db.Timestamptz(3)
|
||||
fehlversuche Int @default(0)
|
||||
gesperrtBis DateTime? @db.Timestamptz(3)
|
||||
aktiv Boolean @default(true)
|
||||
/// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier.
|
||||
///
|
||||
/// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird,
|
||||
/// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject
|
||||
/// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können,
|
||||
/// und die Anmeldung per E-Mail wäre mehrdeutig.
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Profilbild. Liegt wie alles andere in MinIO.
|
||||
avatarDateiId String? @db.Uuid
|
||||
@@ -301,6 +312,7 @@ model Person {
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson")
|
||||
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
|
||||
rollen PersonRolle[] @relation("PersonRollenInhaber")
|
||||
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
|
||||
@@ -375,19 +387,104 @@ model Person {
|
||||
zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer")
|
||||
erstellteRechnungen Rechnung[] @relation("RechnungErsteller")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
/// Die E-Mail ist je Wehr eindeutig, nicht global: dieselbe Person kann in
|
||||
/// zwei Wehren eine Akte haben. Angemeldet wird sich nicht damit, sondern
|
||||
/// mit der E-Mail des `Benutzer` — die ist global eindeutig.
|
||||
@@unique([organisationId, email])
|
||||
@@unique([organisationId, oidcSubject])
|
||||
@@unique([organisationId, mitgliedsnummer])
|
||||
@@index([organisationId, nachname, vorname])
|
||||
@@index([organisationId, aktiv])
|
||||
@@index([oidcSubject])
|
||||
@@map("person")
|
||||
}
|
||||
|
||||
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
|
||||
/// Rollen kann der Kommandant ergänzen.
|
||||
/// Ein Anmeldekonto.
|
||||
///
|
||||
/// **Die einzige Tabelle neben `Berechtigung` ohne `organisationId`** — und
|
||||
/// zwar notwendigerweise: sie ist der Angelpunkt, an dem ein Mensch zu
|
||||
/// mehreren Wehren gehören kann. Ein Konto mit Mandantenkennung könnte genau
|
||||
/// das nicht.
|
||||
///
|
||||
/// Sie ist deshalb auch nicht über Row Level Security geschützt, sondern über
|
||||
/// Rechte: die Anwendungsrolle darf sie nur lesen und die Anmeldefelder
|
||||
/// fortschreiben, angelegt und verändert werden Konten über den
|
||||
/// Systemzugang. Was ein Konto in einer Wehr sehen darf, entscheidet nicht es
|
||||
/// selbst, sondern seine `Mitgliedschaft` — und die trägt `organisationId`
|
||||
/// wie jede andere Tabelle.
|
||||
model Benutzer {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
|
||||
/// Global eindeutig. Das ist der Unterschied zur E-Mail an der Personenakte:
|
||||
/// die ist eine Kontaktangabe je Wehr, diese hier ist die Anmeldung.
|
||||
email String @unique @db.VarChar(200)
|
||||
|
||||
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
|
||||
passwortHash String? @db.VarChar(120)
|
||||
passwortGeaendertAm DateTime? @db.Timestamptz(3)
|
||||
/// "sub"-Claim aus authentik.
|
||||
oidcSubject String? @unique @db.VarChar(255)
|
||||
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
|
||||
oidcGruppen String[] @default([])
|
||||
|
||||
letzteAnmeldung DateTime? @db.Timestamptz(3)
|
||||
fehlversuche Int @default(0)
|
||||
gesperrtBis DateTime? @db.Timestamptz(3)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Verwaltet die Installation: Wehren anlegen und in jede hineinschalten.
|
||||
///
|
||||
/// Bewusst ein Merkmal des Kontos und keine Rolle innerhalb einer Wehr. Eine
|
||||
/// Rolle gilt je Organisation — wer Wehren anlegt, steht aber über allen,
|
||||
/// und ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre
|
||||
/// genau die Art Hintertür, die die Mandantentrennung aushöhlt.
|
||||
istVerwalter Boolean @default(false)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
mitgliedschaften Mitgliedschaft[]
|
||||
|
||||
@@index([oidcSubject])
|
||||
@@map("benutzer")
|
||||
}
|
||||
|
||||
/// Die Zugehörigkeit eines Kontos zu einer Wehr.
|
||||
///
|
||||
/// Sie ist das, was eine Sitzung ausmacht: erst die Wahl einer Mitgliedschaft
|
||||
/// entscheidet, welche Wehr in `app.current_organisation` steht und welche
|
||||
/// Rollen gelten. Wer nur eine hat — die allermeisten —, merkt davon nichts.
|
||||
model Mitgliedschaft {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
benutzerId String @db.Uuid
|
||||
|
||||
/// Die Personenakte in dieser Wehr. Bei AUFSICHT leer: die Kreisebene ist
|
||||
/// dort nicht Mitglied und soll auch in keiner Mitgliederliste auftauchen.
|
||||
personId String? @db.Uuid
|
||||
|
||||
art MitgliedschaftArt @default(MITGLIED)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
/// Wer sie eingerichtet hat. Bei der Umstellung aus Phase 10 leer.
|
||||
vergebenVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
person Person? @relation("MitgliedschaftPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Ein Konto gehört einer Wehr höchstens einmal an.
|
||||
@@unique([benutzerId, organisationId])
|
||||
/// Und eine Personenakte hängt an höchstens einem Konto — sonst wäre nicht
|
||||
/// entscheidbar, wer unter ihrem Namen gehandelt hat.
|
||||
@@unique([personId])
|
||||
@@index([organisationId, art])
|
||||
@@map("mitgliedschaft")
|
||||
}
|
||||
|
||||
model Rolle {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
LebensdauerBezug,
|
||||
LebensdauerKategorie,
|
||||
MangelPrioritaet,
|
||||
MitgliedschaftArt,
|
||||
MangelStatus,
|
||||
PrismaClient,
|
||||
PruefErgebnis,
|
||||
@@ -198,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: 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({
|
||||
where: {
|
||||
organisationId_schluessel: {
|
||||
@@ -499,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
|
||||
nachname: "Administrator",
|
||||
email,
|
||||
dienstgrad: Dienstgrad.KEIN,
|
||||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
||||
passwortGeaendertAm: new Date(),
|
||||
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);
|
||||
if (rolleId) {
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -3946,6 +4185,9 @@ async function main(): Promise<void> {
|
||||
// Phase 9 — Kassenverwaltung
|
||||
await kassendatenAnlegen(organisation.id);
|
||||
|
||||
// Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene
|
||||
await kreisebeneAnlegen(organisation.id, katalog);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
||||
|
||||
@@ -288,6 +288,14 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Kassenprüfung mit Differenz und Erläuterung",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 10 — Mandantenfähigkeit
|
||||
{
|
||||
name: "50-wehren",
|
||||
pfad: "/wehren",
|
||||
beschreibung: "Wehren dieser Installation, Konten und ihre Zuordnungen",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -372,6 +380,18 @@ async function fertigGeladen(seite: Page): Promise<void> {
|
||||
.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
.catch(() => undefined);
|
||||
|
||||
/**
|
||||
* Erst warten, dass React überhaupt gelaufen ist.
|
||||
*
|
||||
* Ohne diese Pause war die Bedingung unten in einem Fall erfüllt, in dem sie
|
||||
* nichts bedeutet: „keine Ladeanzeige“ trifft auch zu, *bevor* sie erscheint.
|
||||
* Bei einer Route, die der Entwicklungsserver erst übersetzen muss, kam der
|
||||
* Auslöser genau in diesem Moment — und das Bild zeigte eine leere Seite mit
|
||||
* Überschriften.
|
||||
*/
|
||||
await seite.waitForTimeout(1_500);
|
||||
await seite.waitForLoadState("networkidle");
|
||||
|
||||
try {
|
||||
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
||||
timeout: 15_000,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -79,7 +91,8 @@ async function main(): Promise<void> {
|
||||
await tx.$executeRawUnsafe(`ALTER TABLE "${tabelle}" DISABLE TRIGGER USER`);
|
||||
}
|
||||
|
||||
await tx.organisation.delete({ where: { slug: SLUG } });
|
||||
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG, NACHBAR_SLUG] } } });
|
||||
await tx.benutzer.deleteMany({ where: { email: KBI_EMAIL } });
|
||||
|
||||
for (const tabelle of [
|
||||
"druckmessung",
|
||||
|
||||
@@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
|
||||
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
|
||||
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
|
||||
if (!sitzung.personId) redirect("/");
|
||||
|
||||
const marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
|
||||
const bild = await qrTextAlsDataUri(marke, 420);
|
||||
|
||||
|
||||
@@ -25,9 +25,18 @@ export default async function AusweisSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
|
||||
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
|
||||
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
|
||||
if (!sitzung.personId) redirect("/");
|
||||
|
||||
// In eine eigene Konstante, damit die Einengung auch im Rueckruf gilt:
|
||||
// TypeScript verfolgt sie ueber eine Eigenschaft hinweg nicht.
|
||||
const eigeneAkteId = sitzung.personId;
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: sitzung.personId },
|
||||
where: { id: eigeneAkteId },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
@@ -48,7 +57,7 @@ export default async function AusweisSeite() {
|
||||
return person;
|
||||
});
|
||||
|
||||
const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId);
|
||||
const code = ausweisErzeugen(eigeneAkteId, sitzung.organisationId);
|
||||
const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
|
||||
const bild = await qrTextAlsDataUri(adresse, 420);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { rechteVon } from "@/lib/authz";
|
||||
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||
import { AufsichtHinweis } from "@/components/layout/wehrwaehler";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
import { TrpcProvider } from "@/lib/trpc/provider";
|
||||
|
||||
@@ -31,9 +32,11 @@ export default async function AngemeldetLayout({
|
||||
rollen={sitzung.rollen}
|
||||
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
|
||||
personName={sitzung.name}
|
||||
istVerwalter={sitzung.istVerwalter ?? false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
@@ -48,7 +48,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
||||
eintrittsdatum: true,
|
||||
austrittsdatum: true,
|
||||
aktiv: true,
|
||||
letzteAnmeldung: true,
|
||||
// Seit Phase 10 hängt die Anmeldung am Konto, nicht an der Akte. Ob
|
||||
// ein Mitglied überhaupt eines hat, ist die nützlichere Auskunft als
|
||||
// ein Zeitstempel: ohne Konto kommt es nicht in FLORIAN hinein.
|
||||
mitgliedschaft: {
|
||||
select: {
|
||||
art: true,
|
||||
benutzer: {
|
||||
select: { email: true, letzteAnmeldung: true, aktiv: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
rollen: { include: { rolle: true } },
|
||||
qualifikationen: {
|
||||
where: { geloeschtAm: null },
|
||||
@@ -86,6 +96,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
||||
"Anschrift",
|
||||
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
|
||||
],
|
||||
[
|
||||
"Zugang",
|
||||
person.mitgliedschaft?.benutzer ? (
|
||||
<>
|
||||
{person.mitgliedschaft.benutzer.email}
|
||||
<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 */
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({
|
||||
redirect("/pruefungen");
|
||||
}
|
||||
|
||||
// Eine Prüfung braucht einen Prüfer mit Namen und Akte — sie ist der
|
||||
// Nachweis gegenüber der Unfallkasse. Das Recht oben schließt die
|
||||
// Kreisaufsicht bereits aus; die Zusicherung steht hier trotzdem, weil
|
||||
// sich Rechte verschieben lassen und ein Protokoll ohne Prüfer wertlos ist.
|
||||
if (!sitzung.personId) redirect("/pruefungen");
|
||||
|
||||
const { geraetId, terminId, vorlageId } = await searchParams;
|
||||
|
||||
if (!geraetId) redirect("/pruefungen");
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,19 @@ export async function POST(anfrage: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Wer hochlädt, muss eine Akte in dieser Wehr haben: die Datei bekommt
|
||||
// einen Urheber, und „irgendwer aus der Kreisaufsicht“ ist keiner.
|
||||
if (!sitzung.personId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Die Kreisaufsicht hat in dieser Wehr keine Mitgliedschaft und kann deshalb "
|
||||
+ "keine Dateien hochladen.",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Album,
|
||||
Building2,
|
||||
BellRing,
|
||||
BookMarked,
|
||||
BookOpenCheck,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Wehrwaehler } from "@/components/layout/wehrwaehler";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Recht, Rolle } from "@/lib/authz";
|
||||
|
||||
@@ -52,6 +54,8 @@ interface Eintrag {
|
||||
rechte?: Recht[];
|
||||
/** Ab welcher Phase es die Ansicht wirklich gibt. */
|
||||
abPhase?: number;
|
||||
/** Nur für den, der die Installation verwaltet — nicht über ein Recht. */
|
||||
nurVerwalter?: boolean;
|
||||
}
|
||||
|
||||
const EINTRAEGE: Eintrag[] = [
|
||||
@@ -174,27 +178,50 @@ const EINTRAEGE: Eintrag[] = [
|
||||
rechte: ["system.auditlog"],
|
||||
abPhase: 8,
|
||||
},
|
||||
{
|
||||
// Ganz unten und nur für den Betreiber: der einzige Eintrag, der über die
|
||||
// eigene Wehr hinausreicht.
|
||||
titel: "Wehren und Konten",
|
||||
pfad: "/wehren",
|
||||
symbol: Building2,
|
||||
nurVerwalter: true,
|
||||
abPhase: 10,
|
||||
},
|
||||
];
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 9;
|
||||
const AKTUELLE_PHASE = 10;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
rollen: Rolle[];
|
||||
organisationName: string;
|
||||
personName: string;
|
||||
/**
|
||||
* Verwaltet die Installation.
|
||||
*
|
||||
* Kein Recht, sondern ein Merkmal des Kontos — Rechte gelten je Wehr, und
|
||||
* wer Wehren anlegt, steht über allen. Deshalb kommt es hier als eigene
|
||||
* Angabe an und nicht über `rechte`.
|
||||
*/
|
||||
istVerwalter?: boolean;
|
||||
}
|
||||
|
||||
export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) {
|
||||
export function Seitenleiste({
|
||||
rechte,
|
||||
organisationName,
|
||||
personName,
|
||||
istVerwalter = false,
|
||||
}: SeitenleisteProps) {
|
||||
const pfad = usePathname();
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
|
||||
|
||||
const sichtbar = EINTRAEGE.filter(
|
||||
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)),
|
||||
);
|
||||
const sichtbar = EINTRAEGE.filter((eintrag) => {
|
||||
if (eintrag.nurVerwalter) return istVerwalter;
|
||||
return !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -221,9 +248,7 @@ export function Seitenleiste({ rechte, organisationName, personName }: Seitenlei
|
||||
<Flame className="size-6 text-[var(--primary)]" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
|
||||
<p className="truncate text-xs text-[var(--muted-foreground)]">
|
||||
{organisationName}
|
||||
</p>
|
||||
<Wehrwaehler organisationName={organisationName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>,
|
||||
): Rolle[] {
|
||||
@@ -60,19 +147,24 @@ function rollenAus(
|
||||
}
|
||||
|
||||
/** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */
|
||||
function istGesperrt(person: { gesperrtBis: Date | null }): boolean {
|
||||
return person.gesperrtBis !== null && person.gesperrtBis > new Date();
|
||||
function istGesperrt(konto: { gesperrtBis: Date | null }): boolean {
|
||||
return konto.gesperrtBis !== null && konto.gesperrtBis > new Date();
|
||||
}
|
||||
|
||||
/** Fehlversuch zählen und ab der Grenze sperren. */
|
||||
async function fehlversuchVermerken(personId: string): Promise<void> {
|
||||
const person = await prismaSystem.person.findUnique({
|
||||
where: { id: personId },
|
||||
/**
|
||||
* Fehlversuch zählen und ab der Grenze sperren.
|
||||
*
|
||||
* Am Konto, nicht an der Akte: sonst könnte man dieselbe Anmeldung über eine
|
||||
* zweite Wehr weiterprobieren, in der derselbe Mensch eine zweite Akte hat.
|
||||
*/
|
||||
async function fehlversuchVermerken(benutzerId: string): Promise<void> {
|
||||
const konto = await prismaSystem.benutzer.findUnique({
|
||||
where: { id: benutzerId },
|
||||
select: { fehlversuche: true },
|
||||
});
|
||||
const neu = (person?.fehlversuche ?? 0) + 1;
|
||||
await prismaSystem.person.update({
|
||||
where: { id: personId },
|
||||
const neu = (konto?.fehlversuche ?? 0) + 1;
|
||||
await prismaSystem.benutzer.update({
|
||||
where: { id: benutzerId },
|
||||
data: {
|
||||
fehlversuche: neu,
|
||||
gesperrtBis:
|
||||
@@ -83,9 +175,9 @@ async function fehlversuchVermerken(personId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function anmeldungVermerken(personId: string): Promise<void> {
|
||||
await prismaSystem.person.update({
|
||||
where: { id: personId },
|
||||
async function anmeldungVermerken(benutzerId: string): Promise<void> {
|
||||
await prismaSystem.benutzer.update({
|
||||
where: { id: benutzerId },
|
||||
data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null },
|
||||
});
|
||||
}
|
||||
@@ -122,64 +214,75 @@ const passwortAnbieter = Credentials({
|
||||
|
||||
const { email, passwort } = geprueft.data;
|
||||
|
||||
// Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist
|
||||
// der Mandant noch nicht bekannt.
|
||||
const person = await prismaSystem.person.findFirst({
|
||||
// Die E-Mail-Suche geht über die Konten, nicht über die Personenakten:
|
||||
// seit Phase 10 ist sie dort global eindeutig. Vorher war sie es nur je
|
||||
// Wehr, und wer in zwei Wehren stand, meldete sich in einer zufälligen an.
|
||||
const konto = await prismaSystem.benutzer.findFirst({
|
||||
where: { email: email.toLowerCase(), aktiv: true, geloeschtAm: null },
|
||||
include: {
|
||||
rollen: { include: { rolle: true } },
|
||||
organisation: { select: { id: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!person || !person.organisation.aktiv) {
|
||||
if (!konto) {
|
||||
// Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt.
|
||||
await pruefen(passwort, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (istGesperrt(person)) {
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich: false,
|
||||
grund: "Konto vorübergehend gesperrt",
|
||||
});
|
||||
const mitgliedschaften = await mitgliedschaftenVon(konto.id);
|
||||
const gewaehlt = ersteMitgliedschaft(mitgliedschaften);
|
||||
|
||||
// Ein Konto ohne Mitgliedschaft ist ausgetreten oder noch nicht
|
||||
// zugeordnet. Es kommt bis zur Wehr gar nicht, deshalb gibt es auch
|
||||
// nichts zu protokollieren — das Audit-Log hängt an einer Organisation.
|
||||
if (!gewaehlt) {
|
||||
await pruefen(passwort, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const stimmt = await pruefen(passwort, person.passwortHash);
|
||||
const melden = (erfolgreich: boolean, grund?: string) =>
|
||||
protokolliereAnmeldung({
|
||||
organisationId: gewaehlt.organisationId,
|
||||
personId: gewaehlt.personId,
|
||||
name: gewaehlt.person
|
||||
? `${gewaehlt.person.vorname} ${gewaehlt.person.nachname}`
|
||||
: konto.email,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich,
|
||||
grund,
|
||||
});
|
||||
|
||||
if (istGesperrt(konto)) {
|
||||
await melden(false, "Konto vorübergehend gesperrt");
|
||||
return null;
|
||||
}
|
||||
|
||||
const stimmt = await pruefen(passwort, konto.passwortHash);
|
||||
if (!stimmt) {
|
||||
await fehlversuchVermerken(person.id);
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich: false,
|
||||
grund: "Falsches Passwort",
|
||||
});
|
||||
await fehlversuchVermerken(konto.id);
|
||||
await melden(false, "Falsches Passwort");
|
||||
return null;
|
||||
}
|
||||
|
||||
await anmeldungVermerken(person.id);
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich: true,
|
||||
});
|
||||
const sitzung = await sitzungAus(gewaehlt);
|
||||
if (!sitzung) {
|
||||
await melden(false, "Personenakte in dieser Wehr nicht nutzbar");
|
||||
return null;
|
||||
}
|
||||
|
||||
await anmeldungVermerken(konto.id);
|
||||
await melden(true);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
email: person.email ?? undefined,
|
||||
organisationId: person.organisationId,
|
||||
rollen: rollenAus(person.rollen),
|
||||
id: sitzung.personId ?? konto.id,
|
||||
name: sitzung.name ?? konto.email,
|
||||
email: konto.email,
|
||||
organisationId: sitzung.organisationId,
|
||||
rollen: sitzung.rollen,
|
||||
anmeldeArt: "LOKAL" as const,
|
||||
benutzerId: konto.id,
|
||||
mitgliedschaftId: sitzung.mitgliedschaftId,
|
||||
personId: sitzung.personId,
|
||||
nurLesend: sitzung.nurLesend,
|
||||
istVerwalter: konto.istVerwalter,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -383,36 +486,41 @@ interface OidcProfil {
|
||||
|
||||
/**
|
||||
* Verknüpft ein authentik-Konto mit einer Person. Reihenfolge:
|
||||
* 1. bereits verknüpft (oidcSubject)
|
||||
* 1. bereits verknüpft (oidcSubject am Konto)
|
||||
* 2. E-Mail-Adresse stimmt überein -> verknüpfen
|
||||
* 3. AUTH_OIDC_AUTO_PROVISION -> neue Person anlegen
|
||||
* 3. AUTH_OIDC_AUTO_PROVISION -> neues Konto samt Akte anlegen
|
||||
* Sonst: Anmeldung abgelehnt.
|
||||
*
|
||||
* Seit Phase 10 hängt das OIDC-Subject am Konto, nicht an der Akte. Wer über
|
||||
* authentik kommt und in zwei Wehren geführt wird, ist damit derselbe Mensch
|
||||
* und nicht zwei — vorher entschied die Reihenfolge in der Datenbank, welche
|
||||
* Akte gefunden wurde.
|
||||
*/
|
||||
async function personAusOidc(profil: OidcProfil): Promise<{
|
||||
personId: string;
|
||||
personId: string | null;
|
||||
organisationId: string;
|
||||
name: string;
|
||||
rollen: Rolle[];
|
||||
} | null> {
|
||||
const email = profil.email?.toLowerCase();
|
||||
|
||||
const vorhanden = await prismaSystem.person.findFirst({
|
||||
const vorhanden = await prismaSystem.benutzer.findFirst({
|
||||
where: {
|
||||
OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])],
|
||||
geloeschtAm: null,
|
||||
},
|
||||
include: {
|
||||
rollen: { include: { rolle: true } },
|
||||
organisation: { select: { id: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const gruppen = gruppenAusProfil(profil);
|
||||
|
||||
if (vorhanden) {
|
||||
if (!vorhanden.aktiv || !vorhanden.organisation.aktiv) return null;
|
||||
if (!vorhanden.aktiv) return null;
|
||||
|
||||
await prismaSystem.person.update({
|
||||
const mitgliedschaften = await mitgliedschaftenVon(vorhanden.id);
|
||||
const gewaehlt = ersteMitgliedschaft(mitgliedschaften);
|
||||
if (!gewaehlt) return null;
|
||||
|
||||
await prismaSystem.benutzer.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
oidcSubject: profil.sub,
|
||||
@@ -424,17 +532,21 @@ async function personAusOidc(profil: OidcProfil): Promise<{
|
||||
});
|
||||
|
||||
// Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von
|
||||
// Hand vergeben hat, bleiben unangetastet.
|
||||
await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen);
|
||||
// Hand vergeben hat, bleiben unangetastet. Und sie wirken nur auf die
|
||||
// Wehr, in der die Akte liegt — eine authentik-Gruppe darf nicht über
|
||||
// Mandantengrenzen hinweg Rollen verteilen.
|
||||
if (gewaehlt.personId) {
|
||||
await gruppenRollenVorbelegen(gewaehlt.organisationId, gewaehlt.personId, gruppen);
|
||||
}
|
||||
|
||||
const aktualisiert = await personMitRollen(vorhanden.id);
|
||||
if (!aktualisiert) return null;
|
||||
const sitzung = await sitzungAus(gewaehlt);
|
||||
if (!sitzung) return null;
|
||||
|
||||
return {
|
||||
personId: aktualisiert.id,
|
||||
organisationId: aktualisiert.organisationId,
|
||||
name: `${aktualisiert.vorname} ${aktualisiert.nachname}`,
|
||||
rollen: rollenAus(aktualisiert.rollen),
|
||||
personId: sitzung.personId,
|
||||
organisationId: sitzung.organisationId,
|
||||
name: sitzung.name ?? vorhanden.email,
|
||||
rollen: sitzung.rollen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -458,20 +570,37 @@ async function personAusOidc(profil: OidcProfil): Promise<{
|
||||
const nachname =
|
||||
profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt";
|
||||
|
||||
// Ohne E-Mail kein Konto: sie ist die Kennung, unter der es geführt wird.
|
||||
if (!email) {
|
||||
console.error("[auth] OIDC-Profil ohne E-Mail — Konto kann nicht angelegt werden.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const neu = await fuerOrganisation(organisation.id, (db) =>
|
||||
db.person.create({
|
||||
data: {
|
||||
organisationId: organisation.id,
|
||||
vorname,
|
||||
nachname,
|
||||
email: email ?? null,
|
||||
oidcSubject: profil.sub,
|
||||
oidcGruppen: gruppen,
|
||||
letzteAnmeldung: new Date(),
|
||||
email,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Konto und Mitgliedschaft über den Systemzugang: `benutzer` steht über
|
||||
// allen Mandanten, und die Anwendungsrolle darf dort nichts anlegen.
|
||||
await prismaSystem.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
oidcSubject: profil.sub,
|
||||
oidcGruppen: gruppen,
|
||||
letzteAnmeldung: new Date(),
|
||||
mitgliedschaften: {
|
||||
create: { organisationId: organisation.id, personId: neu.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen);
|
||||
|
||||
const mitRollen = await personMitRollen(neu.id);
|
||||
@@ -581,7 +710,7 @@ export const authConfig: NextAuthConfig = {
|
||||
return true;
|
||||
},
|
||||
|
||||
async jwt({ token, user, account, trigger }) {
|
||||
async jwt({ token, user, account, trigger, session }) {
|
||||
// Erste Anmeldung über einen Credentials-Anbieter.
|
||||
if (user && "organisationId" in user) {
|
||||
const angemeldet = user as unknown as {
|
||||
@@ -590,44 +719,122 @@ export const authConfig: NextAuthConfig = {
|
||||
organisationId: string;
|
||||
rollen: Rolle[];
|
||||
anmeldeArt: Sitzung["anmeldeArt"];
|
||||
benutzerId?: string;
|
||||
mitgliedschaftId?: string;
|
||||
personId?: string | null;
|
||||
nurLesend?: boolean;
|
||||
istVerwalter?: boolean;
|
||||
};
|
||||
token.personId = angemeldet.id;
|
||||
// `id` trägt bei einer Aufsichtssitzung die Kontokennung, weil es
|
||||
// keine Akte gibt. `personId` ist deshalb das verlässliche Feld.
|
||||
token.personId = angemeldet.personId ?? angemeldet.id;
|
||||
token.organisationId = angemeldet.organisationId;
|
||||
token.rollen = angemeldet.rollen;
|
||||
token.anmeldeArt = angemeldet.anmeldeArt;
|
||||
token.benutzerId = angemeldet.benutzerId;
|
||||
token.mitgliedschaftId = angemeldet.mitgliedschaftId;
|
||||
token.nurLesend = angemeldet.nurLesend ?? false;
|
||||
token.istVerwalter = angemeldet.istVerwalter ?? false;
|
||||
token.geladenAm = Date.now();
|
||||
return token;
|
||||
}
|
||||
|
||||
// Erste Anmeldung über OIDC — hier steht das Profil im account/profile.
|
||||
if (account?.provider === "authentik" && token.sub) {
|
||||
const person = await prismaSystem.person.findFirst({
|
||||
const konto = await prismaSystem.benutzer.findFirst({
|
||||
where: { oidcSubject: token.sub, geloeschtAm: null },
|
||||
include: { rollen: { include: { rolle: true } } },
|
||||
});
|
||||
if (person) {
|
||||
token.personId = person.id;
|
||||
token.organisationId = person.organisationId;
|
||||
token.rollen = rollenAus(person.rollen);
|
||||
token.anmeldeArt = "OIDC";
|
||||
if (konto) {
|
||||
const gewaehlt = ersteMitgliedschaft(await mitgliedschaftenVon(konto.id));
|
||||
const sitzung = gewaehlt ? await sitzungAus(gewaehlt) : null;
|
||||
|
||||
if (sitzung) {
|
||||
token.personId = sitzung.personId ?? undefined;
|
||||
token.organisationId = sitzung.organisationId;
|
||||
token.rollen = sitzung.rollen;
|
||||
token.anmeldeArt = "OIDC";
|
||||
token.benutzerId = konto.id;
|
||||
token.mitgliedschaftId = sitzung.mitgliedschaftId;
|
||||
token.nurLesend = sitzung.nurLesend;
|
||||
token.istVerwalter = konto.istVerwalter;
|
||||
token.geladenAm = Date.now();
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachladen.
|
||||
*
|
||||
* Zwei Anlässe: eine Rollenänderung soll nicht bis zum nächsten Login
|
||||
* warten, und ein Wehrwechsel muss sofort greifen. Beim Wechsel setzt
|
||||
* die Oberfläche `wehrWechselAuf` und stösst ein `update` an — die
|
||||
* Prüfung, ob die Mitgliedschaft dem Konto überhaupt gehört, findet
|
||||
* hier statt und nicht dort. Alles andere wäre eine Einladung, sich
|
||||
* über einen selbst gewählten Wert in eine fremde Wehr zu schreiben.
|
||||
*/
|
||||
/**
|
||||
* Der Wunsch, in eine andere Wehr zu wechseln.
|
||||
*
|
||||
* Er kommt aus der Server Action und ist eine Behauptung: geprueft wird
|
||||
* hier, gegen die Mitgliedschaften des angemeldeten Kontos. Wer eine
|
||||
* fremde Kennung schickt, bekommt seine bisherige Sitzung zurueck.
|
||||
*/
|
||||
const wunsch = session as { user?: { mitgliedschaftId?: unknown } } | undefined;
|
||||
const gewuenschteWehr =
|
||||
trigger === "update" && typeof wunsch?.user?.mitgliedschaftId === "string"
|
||||
? wunsch.user.mitgliedschaftId
|
||||
: null;
|
||||
|
||||
if (gewuenschteWehr && typeof token.benutzerId === "string") {
|
||||
const ziel = (await mitgliedschaftenVon(token.benutzerId)).find(
|
||||
(eintrag) => eintrag.id === gewuenschteWehr,
|
||||
);
|
||||
|
||||
if (!ziel) {
|
||||
console.warn("[auth] Wehrwechsel auf eine fremde Mitgliedschaft abgelehnt.");
|
||||
return token;
|
||||
}
|
||||
|
||||
const sitzung = await sitzungAus(ziel);
|
||||
if (sitzung) {
|
||||
token.personId = sitzung.personId ?? undefined;
|
||||
token.organisationId = sitzung.organisationId;
|
||||
token.rollen = sitzung.rollen;
|
||||
token.mitgliedschaftId = sitzung.mitgliedschaftId;
|
||||
token.nurLesend = sitzung.nurLesend;
|
||||
token.geladenAm = Date.now();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet.
|
||||
const veraltet =
|
||||
typeof token.geladenAm !== "number" ||
|
||||
Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS;
|
||||
|
||||
if (trigger === "update" || (veraltet && typeof token.personId === "string")) {
|
||||
const person = await personMitRollen(token.personId as string);
|
||||
if (!person) {
|
||||
// Person deaktiviert oder gelöscht: Sitzung entwerten.
|
||||
if (
|
||||
(trigger === "update" || veraltet) &&
|
||||
typeof token.mitgliedschaftId === "string" &&
|
||||
typeof token.benutzerId === "string"
|
||||
) {
|
||||
const aktuell = (await mitgliedschaftenVon(token.benutzerId)).find(
|
||||
(eintrag) => eintrag.id === token.mitgliedschaftId,
|
||||
);
|
||||
|
||||
// Mitgliedschaft beendet, Konto oder Wehr abgeschaltet: entwerten.
|
||||
if (!aktuell) {
|
||||
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
|
||||
}
|
||||
token.rollen = rollenAus(person.rollen);
|
||||
token.organisationId = person.organisationId;
|
||||
|
||||
const sitzung = await sitzungAus(aktuell);
|
||||
if (!sitzung) {
|
||||
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
|
||||
}
|
||||
|
||||
token.personId = sitzung.personId ?? undefined;
|
||||
token.organisationId = sitzung.organisationId;
|
||||
token.rollen = sitzung.rollen;
|
||||
token.nurLesend = sitzung.nurLesend;
|
||||
token.geladenAm = Date.now();
|
||||
}
|
||||
|
||||
@@ -641,13 +848,30 @@ export const authConfig: NextAuthConfig = {
|
||||
organisationId: (token.organisationId as string) ?? "",
|
||||
rollen: (token.rollen as Rolle[]) ?? [],
|
||||
anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL",
|
||||
benutzerId: token.benutzerId as string | undefined,
|
||||
mitgliedschaftId: token.mitgliedschaftId as string | undefined,
|
||||
nurLesend: (token.nurLesend as boolean | undefined) ?? false,
|
||||
istVerwalter: (token.istVerwalter as boolean | undefined) ?? false,
|
||||
};
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
|
||||
export const {
|
||||
handlers,
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
/**
|
||||
* Stellt das Sitzungstoken neu aus.
|
||||
*
|
||||
* Der einzige Weg, die aktive Wehr zu wechseln: sie steht im Token, also
|
||||
* muss ein neues her. Ohne `SessionProvider` im Browser läuft das über eine
|
||||
* Server Action — siehe `src/app/(app)/wehr-wechseln.ts`.
|
||||
*/
|
||||
unstable_update: sitzungNeuAusstellen,
|
||||
} = NextAuth(authConfig);
|
||||
|
||||
/**
|
||||
* Die Sitzung, wie der Rest der Anwendung sie erwartet. Gibt null zurück,
|
||||
@@ -655,13 +879,30 @@ export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
|
||||
*/
|
||||
export async function aktuelleSitzung(): Promise<Sitzung | null> {
|
||||
const sitzung = await auth();
|
||||
if (!sitzung?.user?.id || !sitzung.user.organisationId) return null;
|
||||
if (!sitzung?.user?.organisationId) return null;
|
||||
|
||||
/**
|
||||
* Eine Sitzung ohne Personenakte ist gueltig — seit Phase 10.
|
||||
*
|
||||
* Die Kreisaufsicht gehoert der Wehr nicht an und hat dort keine Akte. Bis
|
||||
* hierher galt genau das als „nicht angemeldet“, und die Folge war, dass
|
||||
* jeder Aufruf nach dem Wechsel auf der Anmeldeseite endete. Was eine
|
||||
* Sitzung ausmacht, ist die Wehr und das Konto — nicht die Akte.
|
||||
*/
|
||||
if (!sitzung.user.id && !sitzung.user.benutzerId) return null;
|
||||
|
||||
return {
|
||||
personId: sitzung.user.id,
|
||||
// Bei einer Aufsichtssitzung gibt es keine Akte in dieser Wehr. Dann
|
||||
// steht hier null, und jede Stelle, die einen Urheber festhalten will,
|
||||
// muss sich dazu verhalten — statt eine fremde Kennung einzutragen.
|
||||
personId: sitzung.user.nurLesend ? null : sitzung.user.id,
|
||||
organisationId: sitzung.user.organisationId,
|
||||
name: sitzung.user.name ?? "Unbekannt",
|
||||
rollen: sitzung.user.rollen ?? [],
|
||||
anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL",
|
||||
benutzerId: sitzung.user.benutzerId,
|
||||
mitgliedschaftId: sitzung.user.mitgliedschaftId,
|
||||
nurLesend: sitzung.user.nurLesend ?? false,
|
||||
istVerwalter: sitzung.user.istVerwalter ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,16 @@
|
||||
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss.
|
||||
*/
|
||||
|
||||
/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */
|
||||
/**
|
||||
* Rollen wie im Prisma-Schema (enum RolleSchluessel) — mit einer Ausnahme.
|
||||
*
|
||||
* `AUFSICHT` steht dort **nicht**, und das ist Absicht. Alle anderen Rollen
|
||||
* werden einem Menschen in einer Wehr zugewiesen und liegen als
|
||||
* `PersonRolle` in der Datenbank. AUFSICHT wird nicht zugewiesen: sie ergibt
|
||||
* sich daraus, dass ein Konto zu einer Wehr als `MitgliedschaftArt.AUFSICHT`
|
||||
* gehört. Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr
|
||||
* Rechte an einer anderen verteilen könnte.
|
||||
*/
|
||||
export const ROLLEN = [
|
||||
"SUPERADMIN",
|
||||
"KOMMANDANT",
|
||||
@@ -25,6 +34,7 @@ export const ROLLEN = [
|
||||
"AUSBILDER",
|
||||
"MITGLIED",
|
||||
"KIOSK",
|
||||
"AUFSICHT",
|
||||
] as const;
|
||||
|
||||
export type Rolle = (typeof ROLLEN)[number];
|
||||
@@ -40,9 +50,12 @@ export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
|
||||
AUSBILDER: "Ausbilder",
|
||||
MITGLIED: "Mitglied",
|
||||
KIOSK: "Terminal im Gerätehaus",
|
||||
AUFSICHT: "Kreisebene",
|
||||
};
|
||||
|
||||
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
|
||||
AUFSICHT:
|
||||
"Kreisbrandrat oder Kreisbrandinspektion: sieht die betreuten Wehren, ändert nichts.",
|
||||
SUPERADMIN:
|
||||
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
|
||||
KOMMANDANT:
|
||||
@@ -296,6 +309,39 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
||||
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
|
||||
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
|
||||
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"],
|
||||
|
||||
/**
|
||||
* Die Kreisebene.
|
||||
*
|
||||
* Nicht aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und
|
||||
* sonst nichts. Eine Liste von Hand wäre bei der nächsten Phase veraltet,
|
||||
* und zwar unbemerkt — ein neues Modul brächte Rechte mit, die hier
|
||||
* niemand nachträgt. So bekommt die Aufsicht neue Leserechte automatisch
|
||||
* und neue Schreibrechte nie.
|
||||
*
|
||||
* Bewusst NICHT dabei, obwohl es auf `.lesen` endet:
|
||||
*
|
||||
* person.export das Auskunftsrecht nach DSGVO ist Sache der Wehr,
|
||||
* die die Daten führt, nicht der Aufsicht.
|
||||
* system.auditlog wer wann was getan hat, bleibt innerhalb der Wehr.
|
||||
* ki.protokoll dasselbe.
|
||||
* kasse.lesen die Kasse gehört dem Verein oder der Kommune, nicht
|
||||
* der Kreisaufsicht. Sie wäre nur deshalb hier
|
||||
* gelandet, weil das Recht zufällig auf „.lesen“
|
||||
* endet — und genau das soll die Ausnahmeliste
|
||||
* verhindern.
|
||||
*
|
||||
* Der Atemschutz steht dagegen bewusst drin: ob ein Träger tauglich ist,
|
||||
* ist genau die Frage, für die es eine Kreisebene gibt.
|
||||
*/
|
||||
AUFSICHT: ALLE_RECHTE.filter(
|
||||
(recht) =>
|
||||
recht.endsWith(".lesen") &&
|
||||
recht !== "person.export" &&
|
||||
recht !== "system.auditlog" &&
|
||||
recht !== "ki.protokoll" &&
|
||||
recht !== "kasse.lesen",
|
||||
),
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -304,12 +350,35 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
||||
|
||||
/** Was die Anwendung über den gerade Angemeldeten weiß. */
|
||||
export interface Sitzung {
|
||||
personId: string;
|
||||
/**
|
||||
* Die Personenakte in der aktiven Wehr.
|
||||
*
|
||||
* Seit Phase 10 kann sie fehlen: eine Sitzung der Kreisebene (AUFSICHT)
|
||||
* gehört der Wehr nicht an und hat dort keine Akte. Genau deshalb ist das
|
||||
* Feld nullbar und nicht bloß leer — jede Stelle, die einen Urheber
|
||||
* festhalten will, muss sich dazu verhalten, statt einen Namen zu erfinden.
|
||||
*/
|
||||
personId: string | null;
|
||||
organisationId: string;
|
||||
name: string;
|
||||
rollen: Rolle[];
|
||||
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
|
||||
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM";
|
||||
|
||||
/** Das Anmeldekonto. Bleibt beim Wechsel der Wehr dasselbe. */
|
||||
benutzerId?: string;
|
||||
/** Die gewählte Mitgliedschaft — sie bestimmt Wehr und Rollen. */
|
||||
mitgliedschaftId?: string;
|
||||
/**
|
||||
* Aufsicht statt Mitgliedschaft: die Kreisebene sieht, ändert aber nichts.
|
||||
*
|
||||
* Die Rollen sind bei einer solchen Sitzung ohnehin auf die Leserechte
|
||||
* beschränkt; dieses Merkmal macht es zusätzlich sichtbar — für die
|
||||
* Oberfläche, die dann keine Knöpfe anbietet, die zu nichts führen.
|
||||
*/
|
||||
nurLesend?: boolean;
|
||||
/** Verwaltet die Installation: Wehren anlegen, in jede hineinschalten. */
|
||||
istVerwalter?: boolean;
|
||||
}
|
||||
|
||||
/** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
|
||||
import { datenbankErreichbar } from "@/lib/db";
|
||||
import { datenbankErreichbar, prismaSystem } from "@/lib/db";
|
||||
import { speicherErreichbar } from "@/lib/storage";
|
||||
import { geraetRouter } from "@/server/routers/stammdaten/geraet";
|
||||
import { fahrzeugRouter } from "@/server/routers/stammdaten/fahrzeug";
|
||||
@@ -9,6 +9,7 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o
|
||||
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
|
||||
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
|
||||
import { importRouter } from "@/server/routers/stammdaten/import";
|
||||
import { wehrverwaltungRouter } from "@/server/routers/verwaltung/wehr";
|
||||
import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage";
|
||||
import { wartungsterminRouter } from "@/server/routers/wartung/termin";
|
||||
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
|
||||
@@ -59,30 +60,80 @@ const systemRouter = router({
|
||||
});
|
||||
|
||||
const sitzungRouter = router({
|
||||
/** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */
|
||||
/**
|
||||
* Wer bin ich, in welcher Wehr bin ich gerade, was darf ich — und welche
|
||||
* Wehren stehen mir sonst noch offen?
|
||||
*
|
||||
* Der letzte Teil ist neu in Phase 10 und speist den Wehrwähler. Er kommt
|
||||
* bewusst aus derselben Abfrage: eine eigene für die Mitgliedschaften wäre
|
||||
* ein zweiter Weg zu derselben Auskunft, und zwei Wege bedeuten früher oder
|
||||
* später zwei Antworten.
|
||||
*/
|
||||
ich: angemeldet.query(async ({ ctx }) => {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: {
|
||||
id: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
email: true,
|
||||
dienstgrad: true,
|
||||
mitgliedsnummer: true,
|
||||
letzteAnmeldung: true,
|
||||
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Ohne Akte — die Kreisaufsicht. Dann gibt es hier nichts zu laden, und
|
||||
// die Oberfläche zeigt das Konto statt eines Mitglieds.
|
||||
const person = ctx.sitzung.personId
|
||||
? await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId as string },
|
||||
select: {
|
||||
id: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
email: true,
|
||||
dienstgrad: true,
|
||||
mitgliedsnummer: true,
|
||||
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Die Wehren dieses Kontos.
|
||||
*
|
||||
* Über den Systemzugang, und nur hier: die Frage lautet ja gerade „wo
|
||||
* gehöre ich überall hin", und die lässt sich im Mandantenkontext einer
|
||||
* einzelnen Wehr nicht beantworten. Zurückgegeben wird deshalb auch nur
|
||||
* das Nötigste — Name der Wehr und Art der Zugehörigkeit, nichts aus
|
||||
* ihren Daten.
|
||||
*/
|
||||
const wehren = ctx.sitzung.benutzerId
|
||||
? await prismaSystem.mitgliedschaft.findMany({
|
||||
where: {
|
||||
benutzerId: ctx.sitzung.benutzerId,
|
||||
aktiv: true,
|
||||
organisation: { aktiv: true, geloeschtAm: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
art: true,
|
||||
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
|
||||
},
|
||||
orderBy: [{ art: "asc" }, { organisation: { name: "asc" } }],
|
||||
})
|
||||
: [];
|
||||
|
||||
const konto = ctx.sitzung.benutzerId
|
||||
? await prismaSystem.benutzer.findUnique({
|
||||
where: { id: ctx.sitzung.benutzerId },
|
||||
select: { id: true, email: true, letzteAnmeldung: true, istVerwalter: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
person,
|
||||
konto,
|
||||
rollen: ctx.sitzung.rollen,
|
||||
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
|
||||
rechte: [...rechteVon(ctx.sitzung.rollen)],
|
||||
anmeldeArt: ctx.sitzung.anmeldeArt,
|
||||
/** Alle Wehren dieses Kontos, für den Wehrwähler. */
|
||||
wehren,
|
||||
/** Die gerade aktive Mitgliedschaft. */
|
||||
mitgliedschaftId: ctx.sitzung.mitgliedschaftId ?? null,
|
||||
nurLesend: ctx.sitzung.nurLesend ?? false,
|
||||
istVerwalter: ctx.sitzung.istVerwalter ?? false,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -152,6 +203,7 @@ export const appRouter = router({
|
||||
system: systemRouter,
|
||||
sitzung: sitzungRouter,
|
||||
organisation: organisationRouter,
|
||||
wehrverwaltung: wehrverwaltungRouter,
|
||||
audit: auditRouter,
|
||||
|
||||
// Phase 1 — Stammdaten
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { DruckArt, EreignisArt, TruppStatus } from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { env } from "@/lib/env";
|
||||
@@ -390,7 +390,7 @@ export const atemschutzRouter = router({
|
||||
bezeichnung: input.bezeichnung,
|
||||
einsatzId: input.einsatzId ?? null,
|
||||
uebungId: input.uebungId ?? null,
|
||||
ueberwacherId: ctx.sitzung.personId,
|
||||
ueberwacherId: eigeneAkte(ctx.sitzung),
|
||||
abfrageIntervall: input.abfrageIntervall ?? env.ATEMSCHUTZ_ABFRAGE_INTERVALL,
|
||||
sicherheitszuschlag: input.sicherheitszuschlag ?? env.ATEMSCHUTZ_ZUSCHLAG,
|
||||
warndruck: input.warndruck ?? env.ATEMSCHUTZ_WARNDRUCK,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Prisma,
|
||||
WaescheStatus,
|
||||
} from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import type { MandantenClient } from "@/lib/db";
|
||||
@@ -322,7 +322,7 @@ export const kioskRouter = router({
|
||||
const ergebnisId = await vorgangAnwenden(
|
||||
db,
|
||||
ctx.sitzung.organisationId,
|
||||
ctx.sitzung.personId,
|
||||
eigeneAkte(ctx.sitzung),
|
||||
ctx.sitzung.name,
|
||||
eingabe,
|
||||
);
|
||||
@@ -458,12 +458,12 @@ export const kioskRouter = router({
|
||||
ctx.db(async (db) => {
|
||||
const [anwesenheit, fahrt, waesche] = await Promise.all([
|
||||
db.anwesenheit.findFirst({
|
||||
where: { personId: ctx.sitzung.personId, abgemeldetUm: null },
|
||||
where: { personId: eigeneAkte(ctx.sitzung), abgemeldetUm: null },
|
||||
orderBy: { angemeldetUm: "desc" },
|
||||
select: { id: true, anlass: true, angemeldetUm: true },
|
||||
}),
|
||||
db.fahrt.findFirst({
|
||||
where: { fahrerId: ctx.sitzung.personId, rueckkehrUm: null },
|
||||
where: { fahrerId: eigeneAkte(ctx.sitzung), rueckkehrUm: null },
|
||||
orderBy: { abfahrtUm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -474,7 +474,7 @@ export const kioskRouter = router({
|
||||
},
|
||||
}),
|
||||
db.waescheabgabe.count({
|
||||
where: { personId: ctx.sitzung.personId, status: { not: WaescheStatus.ZURUECK } },
|
||||
where: { personId: eigeneAkte(ctx.sitzung), status: { not: WaescheStatus.ZURUECK } },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { router, angemeldet } from "@/server/trpc";
|
||||
import { router, angemeldet, eigeneAkte } from "@/server/trpc";
|
||||
import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
@@ -16,7 +16,7 @@ export const dienstausweisRouter = router({
|
||||
eigener: angemeldet.query(async ({ ctx }) => {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
where: { id: eigeneAkte(ctx.sitzung) },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
@@ -26,7 +26,7 @@ export const dienstausweisRouter = router({
|
||||
}),
|
||||
);
|
||||
|
||||
const code = ausweisErzeugen(ctx.sitzung.personId, ctx.sitzung.organisationId);
|
||||
const code = ausweisErzeugen(eigeneAkte(ctx.sitzung), ctx.sitzung.organisationId);
|
||||
|
||||
return {
|
||||
person,
|
||||
|
||||
@@ -146,10 +146,11 @@ export const personRouter = router({
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
|
||||
}
|
||||
|
||||
// Der Hash gehört auch dann nicht nach draußen, wenn er nur im Speicher
|
||||
// liegt — das Feld wird hier ausdrücklich entfernt.
|
||||
const { passwortHash: _hash, ...ohneGeheimnis } = person;
|
||||
return ohneGeheimnis;
|
||||
// Bis Phase 9 wurde hier der Passwort-Hash aus dem Ergebnis geschnitten.
|
||||
// Das ist nicht mehr nötig: die Anmeldedaten liegen seit Phase 10 am
|
||||
// `Benutzer`, und eine Personenabfrage kann sie gar nicht mehr
|
||||
// mitbringen. Die Sorgfaltsmaßnahme ist durch eine Struktur ersetzt.
|
||||
return person;
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -177,8 +178,7 @@ export const personRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
const { passwortHash: _hash, ...ohneGeheimnis } = person;
|
||||
return ohneGeheimnis;
|
||||
return person;
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -215,8 +215,7 @@ export const personRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
const { passwortHash: _hash, ...ohneGeheimnis } = nachher;
|
||||
return ohneGeheimnis;
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { EreignisTyp, Kanal, Zustellstand } from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import {
|
||||
@@ -79,7 +79,7 @@ export const benachrichtigungRouter = router({
|
||||
eigeneKanaele: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.zustellkanal.findMany({
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
where: { personId: eigeneAkte(ctx.sitzung) },
|
||||
orderBy: [{ kanal: "asc" }, { erstelltAm: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
@@ -116,7 +116,7 @@ export const benachrichtigungRouter = router({
|
||||
if (input.kanal === Kanal.MAIL && input.ziel) {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
where: { id: eigeneAkte(ctx.sitzung) },
|
||||
select: { email: true },
|
||||
}),
|
||||
);
|
||||
@@ -136,14 +136,14 @@ export const benachrichtigungRouter = router({
|
||||
const kanal = await db.zustellkanal.upsert({
|
||||
where: {
|
||||
personId_kanal_ziel: {
|
||||
personId: ctx.sitzung.personId,
|
||||
personId: eigeneAkte(ctx.sitzung),
|
||||
kanal: input.kanal,
|
||||
ziel: input.ziel,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: ctx.sitzung.personId,
|
||||
personId: eigeneAkte(ctx.sitzung),
|
||||
kanal: input.kanal,
|
||||
ziel: input.ziel,
|
||||
p256dh: input.p256dh ?? null,
|
||||
@@ -284,7 +284,7 @@ export const benachrichtigungRouter = router({
|
||||
db.benachrichtigung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: ctx.sitzung.personId,
|
||||
personId: eigeneAkte(ctx.sitzung),
|
||||
ereignis: EreignisTyp.TERMIN_ERINNERUNG,
|
||||
kanal: kanal.kanal,
|
||||
ziel: kanal.ziel,
|
||||
@@ -313,7 +313,7 @@ export const benachrichtigungRouter = router({
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.benachrichtigung.findMany({
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
where: { personId: eigeneAkte(ctx.sitzung) },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: input?.anzahl ?? 50,
|
||||
select: {
|
||||
@@ -487,7 +487,7 @@ export const benachrichtigungRouter = router({
|
||||
kalenderzugaenge: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.kalenderzugang.findMany({
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
where: { personId: eigeneAkte(ctx.sitzung) },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -529,7 +529,7 @@ export const benachrichtigungRouter = router({
|
||||
const eintrag = await db.kalenderzugang.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: ctx.sitzung.personId,
|
||||
personId: eigeneAkte(ctx.sitzung),
|
||||
bezeichnung: input.bezeichnung,
|
||||
nurEigene: input.nurEigene,
|
||||
tokenHash,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EreignisTyp, Serienmuster, Teilnahmestand, TerminArt } from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { router, angemeldet, mitRecht, eigeneAkte } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { HOECHSTZAHL, serientermine } from "@/lib/termin/serie";
|
||||
@@ -128,7 +128,7 @@ export const terminRouter = router({
|
||||
...(input?.art ? { art: input.art } : {}),
|
||||
...(input?.mitAbgesagten === false ? { abgesagtAm: null } : {}),
|
||||
...(input?.nurEigene
|
||||
? { anmeldungen: { some: { personId: ctx.sitzung.personId } } }
|
||||
? { anmeldungen: { some: { personId: eigeneAkte(ctx.sitzung) } } }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { beginn: rueckwaerts ? "desc" : "asc" },
|
||||
@@ -137,7 +137,7 @@ export const terminRouter = router({
|
||||
...listenauswahl,
|
||||
_count: { select: { anmeldungen: { where: { stand: Teilnahmestand.ZUGESAGT } } } },
|
||||
anmeldungen: {
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
where: { personId: eigeneAkte(ctx.sitzung) },
|
||||
select: { stand: true },
|
||||
take: 1,
|
||||
},
|
||||
@@ -540,7 +540,7 @@ export const terminRouter = router({
|
||||
}
|
||||
|
||||
const bisher = await db.terminanmeldung.findUnique({
|
||||
where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } },
|
||||
where: { terminId_personId: { terminId: termin.id, personId: eigeneAkte(ctx.sitzung) } },
|
||||
select: { id: true, stand: true },
|
||||
});
|
||||
|
||||
@@ -560,11 +560,11 @@ export const terminRouter = router({
|
||||
}
|
||||
|
||||
const eintrag = await db.terminanmeldung.upsert({
|
||||
where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } },
|
||||
where: { terminId_personId: { terminId: termin.id, personId: eigeneAkte(ctx.sitzung) } },
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
terminId: termin.id,
|
||||
personId: ctx.sitzung.personId,
|
||||
personId: eigeneAkte(ctx.sitzung),
|
||||
stand,
|
||||
grund: input.grund ?? null,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SignaturRolle,
|
||||
WartungsterminStatus,
|
||||
} from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { router, mitRecht, eigeneAkte } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
|
||||
@@ -282,7 +282,7 @@ export const pruefungRouter = router({
|
||||
if (laufender) return { pruefung: laufender, fortgesetzt: true };
|
||||
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
where: { id: eigeneAkte(ctx.sitzung) },
|
||||
select: { vorname: true, nachname: true },
|
||||
});
|
||||
|
||||
@@ -724,7 +724,7 @@ export const pruefungRouter = router({
|
||||
}
|
||||
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
where: { id: eigeneAkte(ctx.sitzung) },
|
||||
select: { vorname: true, nachname: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,30 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
/** Ohne Anmeldung erreichbar. */
|
||||
export const oeffentlich = t.procedure;
|
||||
|
||||
/**
|
||||
* Die eigene Personenakte in der aktiven Wehr.
|
||||
*
|
||||
* Fuer alles, was „meine Termine“, „meine Benachrichtigungen“ oder „mein
|
||||
* Ausweis“ bedeutet. Seit Phase 10 kann sie fehlen: eine Aufsichtssitzung
|
||||
* der Kreisebene gehört der Wehr nicht an und hat dort keine Akte.
|
||||
*
|
||||
* Dass diese Funktion wirft statt null zurückzugeben, ist Absicht. Ein
|
||||
* stilles null würde in einer Abfrage landen, die dann alle Datensätze der
|
||||
* Wehr liefert statt keinen — aus „meine Termine“ würde „alle Termine“,
|
||||
* und niemand würde es bemerken.
|
||||
*/
|
||||
export function eigeneAkte(sitzung: Sitzung): string {
|
||||
if (!sitzung.personId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Diese Ansicht zeigt eigene Daten. Die Kreisaufsicht hat in dieser Wehr keine "
|
||||
+ "Mitgliedschaft und damit auch keine eigenen Daten.",
|
||||
});
|
||||
}
|
||||
return sitzung.personId;
|
||||
}
|
||||
|
||||
/** Abfrage im Mandantenkontext der Sitzung. */
|
||||
export type MandantenAbfrage = <T>(fn: (db: MandantenClient) => Promise<T>) => Promise<T>;
|
||||
|
||||
@@ -95,3 +119,28 @@ export function mitRecht(recht: Recht) {
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prozedur, die nur der Installationsverwalter aufrufen darf.
|
||||
*
|
||||
* Das ist die einzige Stelle, an der über Mandantengrenzen hinweg gearbeitet
|
||||
* wird: Wehren anlegen, Konten führen, Mitgliedschaften vergeben. Sie hängt
|
||||
* bewusst nicht an einem Recht aus `lib/authz.ts`, denn Rechte gelten je Wehr
|
||||
* — und wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben
|
||||
* wird und in Wehr B wirkt, wäre genau die Hintertür, die diese Phase
|
||||
* vermeiden soll.
|
||||
*
|
||||
* Die Prozeduren dahinter arbeiten deshalb auch nicht über `ctx.db`, sondern
|
||||
* über den Systemzugang: es gibt bei ihnen keine „aktuelle Organisation".
|
||||
*/
|
||||
export const nurVerwalter = angemeldet.use(async ({ ctx, next }) => {
|
||||
if (!ctx.sitzung.istVerwalter) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Diesen Bereich verwaltet, wer die Installation betreibt. Innerhalb einer Wehr "
|
||||
+ "lässt er sich nicht freischalten.",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Rolle, Sitzung } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Erweitert die Typen von Auth.js um das, was FLORIAN zusätzlich in der
|
||||
* Sitzung führt: Person, Wehr, Rollen und die Art der Anmeldung.
|
||||
* Sitzung führt: Konto, Mitgliedschaft, Wehr, Rollen und die Art der
|
||||
* Anmeldung.
|
||||
*
|
||||
* Seit Phase 10 sind Konto und Wehr getrennt. Das Konto bleibt über einen
|
||||
* Wehrwechsel hinweg dasselbe; alles andere wird beim Wechsel neu gesetzt.
|
||||
*/
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
@@ -14,6 +18,14 @@ declare module "next-auth" {
|
||||
organisationId: string;
|
||||
rollen: Rolle[];
|
||||
anmeldeArt: Sitzung["anmeldeArt"];
|
||||
/** Das Anmeldekonto. Überdauert den Wehrwechsel. */
|
||||
benutzerId?: string;
|
||||
/** Die aktive Mitgliedschaft — sie bestimmt Wehr und Rollen. */
|
||||
mitgliedschaftId?: string;
|
||||
/** Aufsichtssitzung der Kreisebene: sieht, ändert nichts. */
|
||||
nurLesend?: boolean;
|
||||
/** Verwaltet die Installation: Wehren anlegen, in jede hineinschalten. */
|
||||
istVerwalter?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +33,11 @@ declare module "next-auth" {
|
||||
organisationId?: string;
|
||||
rollen?: Rolle[];
|
||||
anmeldeArt?: Sitzung["anmeldeArt"];
|
||||
benutzerId?: string;
|
||||
mitgliedschaftId?: string;
|
||||
personId?: string | null;
|
||||
nurLesend?: boolean;
|
||||
istVerwalter?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +49,11 @@ declare module "next-auth/jwt" {
|
||||
anmeldeArt?: Sitzung["anmeldeArt"];
|
||||
/** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */
|
||||
geladenAm?: number;
|
||||
|
||||
benutzerId?: string;
|
||||
mitgliedschaftId?: string;
|
||||
nurLesend?: boolean;
|
||||
istVerwalter?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,17 @@ test.describe("Anmeldung", () => {
|
||||
}) => {
|
||||
await page.goto("/anmelden");
|
||||
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
// Bewusst eine Adresse, die es nicht gibt, statt der des Administrators.
|
||||
//
|
||||
// Ein Fehlversuch zählt auf das Konto, und nach fünf davon ist es fünf
|
||||
// Minuten gesperrt. Bei zwei Projekten und Wiederholungen kamen im vollen
|
||||
// Lauf genug zusammen, um andere Testdateien mitzureißen — sie fielen
|
||||
// dann mit „Anmeldung fehlgeschlagen“ aus, ohne dass es etwas mit dem zu
|
||||
// tun hatte, was sie prüfen.
|
||||
//
|
||||
// Die Meldung muss ohnehin dieselbe sein: sie darf nicht verraten, ob es
|
||||
// die Adresse gibt. Genau das prüft dieser Test mit.
|
||||
await page.getByLabel("E-Mail-Adresse").fill("gibt-es-nicht@teststelle.de");
|
||||
await page.getByLabel("Passwort").fill("definitiv-das-falsche-passwort");
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RolleSchluessel } from "@prisma/client";
|
||||
import {
|
||||
ALLE_RECHTE,
|
||||
KeinZugriffFehler,
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
* Terminal nur das Nötigste kann.
|
||||
*/
|
||||
|
||||
function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung {
|
||||
function sitzung(
|
||||
rollen: Sitzung["rollen"],
|
||||
organisationId = "org-1",
|
||||
): Sitzung & { personId: string } {
|
||||
return {
|
||||
personId: "person-1",
|
||||
organisationId,
|
||||
@@ -34,6 +38,23 @@ function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Sitzung der Kreisaufsicht: kein Konto in dieser Wehr, keine Akte.
|
||||
*
|
||||
* Seit Phase 10 gibt es diesen Zustand, und er ist der einzige, in dem
|
||||
* `personId` fehlt.
|
||||
*/
|
||||
function aufsicht(organisationId = "org-2"): Sitzung {
|
||||
return {
|
||||
personId: null,
|
||||
organisationId,
|
||||
name: "Kreisbrandinspektion",
|
||||
rollen: ["AUFSICHT"],
|
||||
anmeldeArt: "LOKAL",
|
||||
nurLesend: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Rollenkatalog", () => {
|
||||
it("hat für jede Rolle eine deutsche Bezeichnung", () => {
|
||||
for (const rolle of ROLLEN) {
|
||||
@@ -195,3 +216,72 @@ describe("alsRolle", () => {
|
||||
expect(alsRolle(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kreisaufsicht", () => {
|
||||
/**
|
||||
* Der wichtigste Test dieser Datei.
|
||||
*
|
||||
* Die Kreisebene sieht Wehren, denen sie nicht angehört. Bekäme sie dort
|
||||
* versehentlich ein Schreibrecht, wäre das kein Bedienfehler, sondern ein
|
||||
* Loch in der Mandantentrennung — und zwar eines, das niemandem auffällt,
|
||||
* weil in der Oberfläche alles normal aussieht.
|
||||
*
|
||||
* Die Zusicherung ist deshalb bewusst allgemein formuliert: nicht "diese
|
||||
* zwölf Rechte", sondern "kein einziges, das nicht lesen heißt". So gilt
|
||||
* sie auch für Module, die es noch gar nicht gibt.
|
||||
*/
|
||||
it("hat ausschließlich Leserechte — auch für künftige Module", () => {
|
||||
const rechte = [...rechteVon(["AUFSICHT"])];
|
||||
|
||||
expect(rechte.length).toBeGreaterThan(0);
|
||||
expect(rechte.filter((recht) => !recht.endsWith(".lesen"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("sieht die Kasse nicht", () => {
|
||||
// Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht — und
|
||||
// wäre nur deshalb dabei, weil das Recht zufällig auf ".lesen" endet.
|
||||
expect(hatRecht(aufsicht(), "kasse.lesen")).toBe(false);
|
||||
});
|
||||
|
||||
it("sieht weder Audit-Log noch KI-Protokoll", () => {
|
||||
// Wer wann was getan hat, bleibt innerhalb der Wehr.
|
||||
expect(hatRecht(aufsicht(), "system.auditlog")).toBe(false);
|
||||
expect(hatRecht(aufsicht(), "ki.protokoll")).toBe(false);
|
||||
});
|
||||
|
||||
it("kann keine Personendaten exportieren", () => {
|
||||
// Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt.
|
||||
expect(hatRecht(aufsicht(), "person.export")).toBe(false);
|
||||
});
|
||||
|
||||
it("sieht den Atemschutz — dafür gibt es sie", () => {
|
||||
expect(hatRecht(aufsicht(), "atemschutz.lesen")).toBe(true);
|
||||
expect(hatRecht(aufsicht(), "atemschutz.bearbeiten")).toBe(false);
|
||||
expect(hatRecht(aufsicht(), "atemschutz.ueberwachung")).toBe(false);
|
||||
});
|
||||
|
||||
it("hat in dieser Wehr keine eigene Akte", () => {
|
||||
// Daran hängt, dass sie in keiner Mitgliederliste auftaucht und als
|
||||
// Urheber keiner Änderung eingetragen werden kann.
|
||||
expect(aufsicht().personId).toBeNull();
|
||||
expect(aufsicht().nurLesend).toBe(true);
|
||||
});
|
||||
|
||||
it("sieht fremde Personen nur über person.lesen, nie über die eigene Akte", () => {
|
||||
// `darfPersonSehen` lässt jeden die eigenen Daten sehen. Ohne Akte darf
|
||||
// dieser Weg nicht versehentlich greifen: sonst entschiede ein Vergleich
|
||||
// von null mit null darüber, wer wen sieht.
|
||||
const kreis = aufsicht();
|
||||
expect(darfPersonSehen(kreis, "irgendeine-person")).toBe(true);
|
||||
expect(hatRecht(kreis, "person.lesen")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rollenkatalog und Mandantentrennung", () => {
|
||||
it("führt AUFSICHT nicht als vergebbare Rolle in der Datenbank", () => {
|
||||
// Sie ergibt sich aus der Mitgliedschaftsart, nicht aus einer Zuweisung.
|
||||
// Wäre sie vergebbar, könnte jemand innerhalb einer Wehr Rechte an einer
|
||||
// anderen verteilen.
|
||||
expect(RolleSchluessel).not.toHaveProperty("AUFSICHT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,8 +60,14 @@ beforeAll(async () => {
|
||||
});
|
||||
personId = person.id;
|
||||
|
||||
const seite = await system.wikiseite.create({
|
||||
data: {
|
||||
// Wie Wehr und Person darüber: idempotent. Bleibt aus einem abgebrochenen
|
||||
// Lauf etwas stehen, scheiterte ein bloßes `create` hier an der
|
||||
// Eindeutigkeit — und der ganze Datei-Lauf mit ihm, aus einem Grund, der
|
||||
// mit dem Geprüften nichts zu tun hat.
|
||||
const seite = await system.wikiseite.upsert({
|
||||
where: { organisationId_slug: { organisationId, slug: "testseite" } },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId,
|
||||
slug: "testseite",
|
||||
titel: "Testseite",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||