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