diff --git a/CHANGELOG.md b/CHANGELOG.md index 3031996..f9b6643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Geändert +- **Wer sich anmeldet und in keiner Rollengruppe steht, bekommt nicht mehr automatisch `LESER`.** + Bisher hieß das: Lesezugriff auf **alle** Läufe des Hauses — auch für einen ausgeschiedenen + Techniker, solange niemand sein Konto deaktivierte. Jetzt versucht Laufzettel, ihn über seinen UPN + einer Person im Verzeichnis zuzuordnen: klappt das, bekommt er die Selbstauskunft; klappt es + nicht, wird die Anmeldung mit einer Begründung abgewiesen. + **Diese Änderung greift nur, wenn Rollengruppen konfiguriert sind** (`OIDC_GROUP_*`) — ohne sie + bleibt alles wie vorher, denn ohne abgebildete Gruppen kann die Anwendung nicht unterscheiden, ob + jemand Techniker oder Mitarbeiter ist. Wer das alte Verhalten für bestimmte Leute braucht, trägt + sie in die Leser-Gruppe ein. Der lokale Notfallzugang hängt nicht an den Gruppen (#035) - **Die Abarbeitungsansicht aktualisiert sich jetzt sofort statt alle zehn Sekunden.** Hakt ein Kollege einen Schritt ab, schreibt einen Kommentar, hängt ein Foto an oder ändert den Titel, erscheint das beim anderen in weniger als einer halben Sekunde — gemessen 485 ms, vorher bis zu @@ -45,6 +54,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Behoben +- **`staerksteRolle` konnte mehr Rechte liefern als übergeben.** `LESER` war Startwert und damit + zugleich Untergrenze: eine Rolle unterhalb davon kam nie heraus. Solange alle Rollen mindestens + `LESER` waren, fiel das nicht auf — mit der neuen Rolle `MITARBEITER` ergab + `staerksteRolle(["MITARBEITER"])` plötzlich `LESER`. Gefunden von einem Test, der seit #007 + unverändert dasteht (#035) - **nginx hätte ein 10-MB-Foto abgewiesen.** `docs/betrieb/reverse-proxy.md` empfahl `client_max_body_size 8m`, während `ANHANG_MAX_MB` 10 MB erlaubt: ein Handyfoto am oberen Rand wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der @@ -53,6 +67,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **Mitarbeiter-Selbstauskunft.** „Was ist eigentlich mit meinem Notebook passiert?“ musste bisher + ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geräten jetzt selbst + ansehen, unter **Meine Geräte** — mit der neuen Rolle `MITARBEITER`, zugeordnet über den **UPN** + beim Anmelden. Ein mehrdeutiger UPN wird abgewiesen und protokolliert: ein Zufallstreffer + entschiede nach Sortierung darüber, wessen Geräte jemand sieht. + **Was er sieht, sind Titel, Zeitraum und Status — und sonst nichts.** Keine Schritte, keine + Notizen, keine Kommentare, keine Anhänge: in einem Lauf stehen Arbeitsnotizen über Menschen und + Befehle mit Platzhaltern für Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das + hält nicht die Seite fest, sondern das Lesemodell. + Kapitel 09 der Anleitung richtet sich erstmals **an Mitarbeiter** und nicht an die IT (#035) - **Benachrichtigungen per Mail und Webhook.** Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Übersicht öffnete; ein überfälliger überhaupt nicht. Vier Anlässe gehen jetzt hinaus — **zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je diff --git a/CLAUDE.md b/CLAUDE.md index 1be11d3..0246b7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt: | — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` | | — (ergänzt) | `Zustellung` | | | -Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` · +Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` · `LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` · `SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` · `SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) · @@ -79,6 +79,9 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen. - **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. - **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. + **`lesen` hat nicht jede Rolle** — `MITARBEITER` darf es seit #035 nicht, und `selbstauskunft` ist + dessen Ersatz und kein Zusatz. Jeder Navigationseintrag nennt deshalb sein Recht; ein Eintrag + ohne `recht` ist die Ausnahme. Rolle und Aktiv-Status werden pro Anfrage **aus der Datenbank** gelesen, nicht aus dem Token. **Jede Seite prüft selbst** — die Hülle unter `src/app/(app)/layout.tsx` ist das zweite Netz, nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben Layouts nicht neu. diff --git a/docs/anleitung/09-selbstauskunft.md b/docs/anleitung/09-selbstauskunft.md new file mode 100644 index 0000000..c63b21d --- /dev/null +++ b/docs/anleitung/09-selbstauskunft.md @@ -0,0 +1,69 @@ +# Meine Geräte + +> **Dieses Kapitel ist für dich, wenn du kein IT-Mitarbeiter bist.** Alle anderen Kapitel richten +> sich an die Leute, die Laufzettel bedienen — dieses hier an die, über die darin etwas steht. + +Wenn die IT an deinem Rechner gearbeitet hat, hält sie das in Laufzettel fest: was gemacht wurde, +wann und mit welchem Gerät. Unter **Meine Geräte** siehst du den Teil davon, der dich betrifft. + +## Anmelden + +Du meldest dich mit demselben Konto an wie überall sonst im Haus — über den Knopf **Mit dem Anbieter +anmelden**. Ein eigenes Passwort für Laufzettel gibt es nicht. + +Danach landest du direkt auf **Meine Geräte**. Mehr gibt es für dich nicht zu sehen, und das ist +keine Einschränkung aus Versehen: alles andere in Laufzettel sind Arbeitsunterlagen der IT. + +## Was dort steht + +Für jedes Gerät, das auf dich läuft: + +- **Name und Typ** — etwa „NB-042, Notebook“ +- **Seriennummer und Aufkleber-Code** — hilfreich, wenn du bei der IT anrufst +- **Die Arbeiten daran**, neueste zuerst: Titel, Zeitraum und ob sie fertig sind + +Darunter, falls es das gibt, ein Abschnitt **Zu mir persönlich**: Arbeiten, die an keinem Gerät +hängen — etwa das Einrichten deines Kontos, als du angefangen hast. + +Und ganz oben **Meine Daten**: dein Name, deine Funktion, deine Abteilung und deine Mailadresse, so +wie das Verzeichnis sie führt. Stimmt da etwas nicht, sag es der IT — ändern lässt es sich hier +nicht, denn diese Angaben kommen aus dem Verzeichnis des Hauses. + +## Was dort nicht steht, und warum + +Du siehst **Titel, Zeitraum und Status** einer Arbeit. Du siehst **nicht**: + +- die einzelnen Schritte +- Notizen der Techniker +- Kommentare +- angehängte Fotos + +Das ist Absicht und keine Geheimniskrämerei. In einem Lauf stehen Arbeitsnotizen, die während der +Arbeit entstehen, und Befehle mit Platzhaltern für Zugangsdaten. Beides ist nicht dafür gedacht, +gelesen zu werden — weder von dir noch von sonst jemandem außerhalb der IT. + +**Wenn du wissen willst, was genau gemacht wurde, frag die IT.** Dann antwortet ein Mensch, der den +Zusammenhang kennt, statt einer Liste, die man missverstehen kann. + +## Ein archiviertes Gerät steht weiter da + +Geräte, die nicht mehr in Benutzung sind, bleiben in der Liste — mit dem Vermerk **nicht mehr in +Benutzung**. „Was war eigentlich mit meinem alten Notebook?“ ist genau die Frage, um die es hier +geht, und das alte Notebook ist archiviert. + +## Die Liste ist leer + +Dann hat die IT zu dir nichts festgehalten. Kein Fehler — es heißt nur, dass auf dich gerade kein +Gerät läuft und keine Arbeit zu dir vermerkt ist. + +## „Dein Konto ist keiner Person im Verzeichnis zugeordnet“ + +Diese Meldung bei der Anmeldung heißt: Laufzettel konnte dein Konto keiner Person zuordnen. Meist +liegt es daran, dass dein Anmeldename im Verzeichnis anders geschrieben steht als beim +Anmeldeanbieter. Das kann nur die IT auflösen — melde dich dort. + +## Keine Mail, keine Benachrichtigung + +Laufzettel schickt dir nichts. Die Benachrichtigungen, die es gibt, gehen an die IT: wenn eine +Arbeit jemandem zugewiesen wird, überfällig ist oder fertig. Du erfährst von einer Arbeit an deinem +Gerät auf dem üblichen Weg — der Techniker sagt es dir. diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index 6e3439f..fe03898 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -29,6 +29,14 @@ nie an ein zweites Gerät vergeben — er ist die einzige Angabe am Gerät, die - [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen - [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden +**Mitarbeiter** — du bedienst Laufzettel nicht, sondern willst nur wissen, was mit deinem Gerät +passiert ist: + +- [09 Meine Geräte](09-selbstauskunft.md) — anmelden, was dort steht und was nicht + +Dieses eine Kapitel ist **an Mitarbeiter gerichtet** und nicht an die IT. Wer die Selbstauskunft im +Haus freigibt, kann es weitergeben, ohne den Rest der Anleitung mitzuschicken. + ## Zwei Betriebsmodi Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und diff --git a/docs/architektur.md b/docs/architektur.md index 6b558d3..b819827 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -265,6 +265,41 @@ Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, *keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. +### Warum ein Mitarbeiter keine Akteneinsicht bekommt + +Ein `Mitarbeiter` ist kein `Benutzer`. Die vier Rollen bis #035 waren für Leute, die **arbeiten**; +ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Mit der Selbstauskunft kommt die fünfte +Rolle `MITARBEITER` dazu, und sie ist die einzige, die **nicht** `lesen` darf. + +**`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz.** Darum hat auch ein `ADMIN` dieses +Recht nicht: er sieht über `lesen` ohnehin alles, und ein zweiter Weg zu denselben Daten wäre eine +zweite Stelle, an der jemand die Einschränkung auf die eigene Person vergisst. Wer beides braucht, +braucht zwei Konten — und genau das hält die Datenbank fest: der CHECK +`Benutzer_mitarbeiter_nur_bei_rolle` verlangt, dass Rolle und Zuordnung zusammengehören, in beide +Richtungen. + +**Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.** +`src/server/queries/selbstauskunft.ts` liefert Titel, Status, Zeitraum und Gerät — keine Schritte, +keine Notizen, keine Kommentare, keine Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen +(„Nutzerin war unkooperativ“) und Befehle mit Platzhaltern für Zugangsdaten; beides ist nicht dafür +gedacht, gelesen zu werden. Eine Seite, die filtert, wäre eine Seite, die es vergessen kann. + +Geprüft wird das an der **Antwort** und nicht an der Seite: `tests/selbstauskunft.test.ts` hängt an +einen Lauf eine Notiz, einen Kommentar, einen Anhang und einen Befehlsschritt mit +`{{generiertes_passwort}}` und prüft, dass nichts davon durchkommt. Dazu ein struktureller Test, der +das `select` selbst liest — denn eine Gegenprobe zeigte, dass der erste Test grün blieb, als das +`select` versuchsweise Notizen mitholte: gerettet hatte ihn die Abbildungsfunktion, nicht die +Abfrage. + +**Zugeordnet wird über den UPN, und mehrdeutig ist ein Fehler.** Zwei Kunden können dieselbe Person +führen; ein `findFirst` entschiede dann nach Sortierung darüber, wessen Geräte jemand sieht. Lieber +kein Zugang als der falsche — die Anmeldung wird abgewiesen und der Fall protokolliert. + +**Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die +Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist; dort bleibt alles wie vor +#035. Das ist keine Vorsicht, sondern Notwendigkeit: ein Update, das jeden aussperrt, dem die +Gruppen fehlen, wäre ein kaputtes Update. + ## 5e. Der Aufkleber ist die Schnittstelle Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert diff --git a/docs/betrieb/authentik.md b/docs/betrieb/authentik.md index 7f3006f..2fbadb3 100644 --- a/docs/betrieb/authentik.md +++ b/docs/betrieb/authentik.md @@ -58,9 +58,16 @@ Lege in authentik vier Gruppen an und trage die Mitglieder ein: | `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen | | `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen | | `lz-leser` | `LESER` | nur lesen | +| *(keine davon)* | `MITARBEITER` | **nur die eigenen Geräte**, siehe unten | Ist jemand in mehreren Gruppen, gewinnt die stärkere Rolle. +> **Die fünfte Zeile hat keine Gruppe, und das ist Absicht.** Wer sich anmeldet und in keiner der +> vier Gruppen steht, ist für Laufzettel ein Mitarbeiter des Hauses und bekommt die +> Selbstauskunft — vorausgesetzt, er lässt sich einer Person im Verzeichnis zuordnen. Eine eigene +> Gruppe dafür anzulegen hieße, jeden Mitarbeiter einzeln einzutragen; „alle übrigen“ ist genau die +> Menge, die gemeint ist. + ## 5. Laufzettel konfigurieren In der `.env`: @@ -93,11 +100,47 @@ docker compose up -d - Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning) - Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift ab der nächsten Anmeldung -- Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER` +- Ist der Benutzer in keiner der vier Gruppen, versucht Laufzettel, ihn über seinen **UPN** einer + Person im Verzeichnis zuzuordnen. Klappt das, bekommt er `MITARBEITER` und sieht nur seine + eigenen Geräte; klappt es nicht, wird die Anmeldung **abgewiesen** — mit einer Begründung auf der + Anmeldeseite +- **Bis Version 0.2.0 bekam er stattdessen `LESER`**, also Lesezugriff auf alle Läufe des Hauses. + Wer das Verhalten von damals braucht, trägt die betreffenden Leute in `lz-leser` ein - Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen eines QR-Codes), landet danach genau dort -## 7. Notfallzugang +## 7. Selbstauskunft für Mitarbeiter + +Ein Mitarbeiter, der in keiner Rollengruppe steht, sieht unter **Meine Geräte** die Arbeiten an +seinen eigenen Geräten — Titel, Zeitraum, Status. **Keine Schritte, keine Notizen, keine Kommentare, +keine Anhänge**; warum, steht in `docs/architektur.md` unter „Warum ein Mitarbeiter keine Akteneinsicht bekommt“. + +Zugeordnet wird über den **UPN**. Laufzettel liest ihn in dieser Reihenfolge: + +1. der Anspruch `upn` (so heißt er bei Entra ID) +2. `preferred_username` (authentik und Keycloak schicken dort den Anmeldenamen) +3. die Mailadresse + +Verglichen wird mit `Mitarbeiter.upn`, unabhängig von Groß- und Kleinschreibung. Damit das greift, +muss der UPN in authentik derselbe sein wie der im Active Directory — bei einem AD als Quelle von +authentik ist er das meist von selbst. + +**Zwei Fälle, die bewusst nicht durchgehen:** + +| Fall | Was passiert | +|---|---| +| Zu dem UPN gibt es **keine aktive** Person | Anmeldung abgewiesen, mit Begründung auf der Anmeldeseite | +| Zu dem UPN gibt es **mehr als eine** Person | Anmeldung abgewiesen und im Protokoll festgehalten | + +Der zweite Fall kommt im Modus **Dienstleistung** vor, wenn zwei Kunden dieselbe Person führen. Ein +Zufallstreffer wäre dort schlimmer als kein Zugang: er entschiede nach Sortierung darüber, wessen +Geräte jemand sieht. + +**Abschalten lässt sich das Ganze**, indem du keine Rollengruppen konfigurierst — dann greift die +Weiche nicht, und alles bleibt wie vor 0.2.0. Oder indem du jeden, der hinein soll, in `lz-leser` +einträgst. + +## 8. Notfallzugang Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login: @@ -112,7 +155,7 @@ Passwörter liegen als argon2-Hash in der Datenbank. Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED` im Normalbetrieb auf `false` und nur im Notfall umgestellt. -## 8. Fehlersuche +## 9. Fehlersuche | Symptom | Ursache | |---|---| diff --git a/docs/devlog.md b/docs/devlog.md index bf5e3d1..2eb5362 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -33,12 +33,13 @@ als Unterpunkte mit Aufgabe und Dauer. | #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min | | #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min | | #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min | -| | **Summe** | **53 h 35 min** | +| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min | +| | **Summe** | **56 h 25 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | -| v0.2.0 | 18 h 15 min | in Arbeit | +| v0.2.0 | 21 h 05 min | in Arbeit | --- @@ -1302,3 +1303,70 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal eigenen Benachrichtigungen abschalten kann. - **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen. - Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host. + +--- + +## 2026-10-06 · Sitzung 20 · 06:00–08:50 + +**Issue:** #035 (M22) +**Version:** 0.2.0 (unveröffentlicht) + +### Erledigt + +- **Die fünfte Rolle `MITARBEITER`** mit genau einem Recht: `selbstauskunft`. Zugeordnet beim + Anmelden über den UPN, festgehalten in `Benutzer.mitarbeiterId`. +- **`/meine-geraete`**: meine Geräte, je Gerät die Arbeiten chronologisch, dazu meine Stammdaten aus + dem Verzeichnis. Titel, Zeitraum, Status — nichts weiter. +- **Drei PRs von gestern sind auf `main`** (#59, #60, #61); die beiden Folgezweige mussten nach dem + ersten Squash rebased werden. +- 1341 Vitest-Tests (vorher 1302), davon 25 neue. Im gebauten Stand mit einem echten + MITARBEITER-Konto durchgeklickt. + +### Entscheidungen + +- **`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz** — deshalb hat auch ein ADMIN es + nicht. Ein zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die + Einschränkung auf die eigene Person vergisst. +- **Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.** Eine Seite, die filtert, ist + eine Seite, die es vergessen kann. +- **Kein zusammengesetzter Fremdschlüssel**, obwohl die Aufgabe ihn verlangte: ein `Benutzer` trägt + keine `kundeId`. Stattdessen ein CHECK, dass Rolle und Zuordnung zusammengehören, und ein + eindeutiger Index, dass eine Person höchstens ein Konto hat. +- **Ein mehrdeutiger UPN wird abgewiesen**, nicht geraten. Zwei Kunden können dieselbe Person + führen; ein `findFirst` entschiede nach Sortierung darüber, wessen Geräte jemand sieht. +- **Die Weiche greift nur mit konfigurierten Rollengruppen.** Ein Update, das jeden aussperrt, dem + die Gruppen fehlen, wäre ein kaputtes Update. +- **Nicht `/kein-zugriff` bei gescheiterter Anmeldung**, wie das Issue vorsah: diese Seite verlangt + eine Sitzung, die es dann nicht gibt — das wäre eine Schleife. Die Begründung steht auf der + Anmeldeseite, mit zwei eigenen Codes. + +### Probleme + +- **Eine Gegenprobe blieb grün, und das war der eigentliche Befund.** Der Test „sieht keine Notizen“ + prüft die Antwort, und die ist durch `laufBauen` sicher. Als ich im `select` versuchsweise + `ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der Kopfkommentar behauptet, das + `select` hole es „gar nicht erst“. **Eine Zusage ohne eigenen Test ist keine Zusage**, auch wenn + ein Nachbartest sie zufällig mitträgt. Jetzt liest ein Test die Datei selbst. +- **Ein Test von #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als Startwert und + damit als Untergrenze; mit einer Rolle darunter lieferte sie **mehr** Rechte als übergeben. Drei + Jahre Projektzeit hätte das niemand gemerkt, weil es keine Rolle unter `LESER` gab. +- **Zwei Bedienfehler beim Durchklicken**, beide dieselbe Ursache: das Standardziel nach der + Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür fehlt + dir die Berechtigung“ — und der Knopf dort führte zurück auf `/`, also im Kreis. Ohne Klickprobe + wäre das bis zum ersten echten Nutzer unentdeckt geblieben: **alle Tests waren grün.** +- **Die Navigation hätte sechs tote Links angeboten.** `recht` stand nur am Admin-Bereich, weil + bisher jede Rolle `lesen` durfte. +- **Ein falscher Satz in `docs/betrieb/authentik.md`:** „Ist der Benutzer in keiner der vier + Gruppen, bekommt er `LESER`.“ Richtig war er bis zu dieser Sitzung. +- **Drei eigene Testfehler**, keine Produktfehler: Tag-Codes müssen dem Alphabet ohne `0`, `1`, `I` + und `O` folgen; die Kundenbindungstests gaben nur eine Kundengruppe mit und hingen damit an der + neuen Weiche; und ein Einfüge-Skript meldete „ok“, ohne geschrieben zu haben — seitdem prüfe ich + nach dem Einfügen mit `grep`. + +### Nächster Schritt + +- **Phase 2 ist bis auf die beiden großen Verzeichnis-Themen fertig:** **M15 (#028)** + Microsoft-Graph-Quelle und **M16 (#029)** SCIM-Endpunkt. +- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht — + M17 bis M22 liegen ohne fremden Blick auf `main`. +- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host. diff --git a/docs/issues/035-selbstauskunft.md b/docs/issues/035-selbstauskunft.md index b6d83d8..04f9f64 100644 --- a/docs/issues/035-selbstauskunft.md +++ b/docs/issues/035-selbstauskunft.md @@ -1,10 +1,10 @@ # #035 M22 – Mitarbeiter-Selbstauskunft -**Status:** offen +**Status:** erledigt **Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend, frontend -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 2 h 50 min ## Ziel @@ -20,42 +20,95 @@ Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen. ## Zu entscheiden -- [ ] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das +- [x] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das Haus hat seine Leute ohnehin im Identitätsanbieter; wer sich anmeldet und keiner Rollengruppe angehört, wird über `upn` einer `Mitarbeiter`-Zeile zugeordnet und bekommt genau ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest. Die Alternative — ein Link mit Token je Person — braucht keine Anmeldung, aber jeder weitergeleitete Link ist dann ein Datenleck, und widerrufen müsste man je Person. -- [ ] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die +- [x] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die Schritte, nicht die Notizen, nicht die Kommentare, nicht die Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen und Zugangsdaten-Platzhalter; eine Selbstauskunft ist keine Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die Anleitung, nicht nur in den Code. -- [ ] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt, +- [x] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt, bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht. ## Aufgaben -- [ ] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`. +- [x] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`. **Jede** Rollenprüfung in `tests/rollen.test.ts` wächst um eine Spalte — die Matrix ist ausgeschrieben und nicht berechnet, genau damit das auffällt -- [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel -- [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein +- [x] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel +- [x] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein Fehler und kein Zufallstreffer -- [ ] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status -- [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt +- [x] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status +- [x] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt seine Sicht selbst (CLAUDE.md) -- [ ] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau -- [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker +- [x] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau +- [x] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker ## Akzeptanzkriterien -- [ ] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen -- [ ] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort, +- [x] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen +- [x] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort, nicht über die Seite -- [ ] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts` +- [x] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts` erkennt eine ungeprüfte Aktion ohnehin) -- [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung -- [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert -- [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht -- [ ] Am Handy kein horizontales Scrollen +- [x] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung +- [x] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert +- [x] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht +- [x] Am Handy kein horizontales Scrollen + +## Beim Umsetzen dazugekommen + +- **Kein zusammengesetzter Fremdschlüssel, und zwar aus einem Grund.** Die Aufgabe verlangte + `Benutzer.mitarbeiterId` „mit Mandantengrenze als zusammengesetzter Fremdschlüssel“. Das geht + nicht: ein `Benutzer` trägt keine `kundeId`, gegen die man zusammensetzen könnte — ein + Administrator sieht alle Kunden. Stattdessen hält die Datenbank das fest, was hier wirklich zählt: + ein **CHECK**, dass Rolle und Zuordnung zusammengehören (in beide Richtungen), und ein + **eindeutiger Index**, dass eine Person höchstens ein Konto hat. Die Zusage „sieht nur seine + eigenen Läufe“ hängt an der einen Kennung, und stärker als ein Fremdschlüssel darauf wird sie + nicht. +- **Nicht `/kein-zugriff`, sondern die Anmeldeseite.** Das Kriterium sah vor, dass eine Anmeldung + ohne passende Person auf `/kein-zugriff` führt. Diese Seite verlangt aber eine **Sitzung**, und + bei gescheiterter Anmeldung gibt es keine — sie würde auf die Anmeldeseite zurückleiten, also im + Kreis. Die Begründung steht deshalb dort, mit zwei eigenen Fehlercodes (`keine-person`, + `upn-mehrdeutig`). Der Geist des Kriteriums ist damit erfüllt: keine leere Seite, die wie ein + Fehler aussieht. +- **Ein bestehender Test seit #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als + Startwert, und das war zugleich eine **Untergrenze**: `staerksteRolle(["MITARBEITER"])` ergab + `LESER`, also **mehr** Rechte als übergeben. Solange alle Rollen mindestens `LESER` waren, fiel + das nie auf. Der Test „gibt bei nur diese Rolle zurück“ war sofort rot. +- **Jeder Navigationseintrag nennt jetzt sein Recht.** Bis hierher stand `recht` nur am + Admin-Bereich — das genügte, weil jede Rolle `lesen` durfte. Mit `MITARBEITER` stimmt das nicht + mehr, und ohne diese Änderung hätte die Seitenleiste ihm sechs Links angeboten, die alle auf + `/kein-zugriff` führen. +- **Zwei Bedienfehler beim Durchklicken gefunden**, beide dieselbe Ursache: das Standardziel nach + der Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür + fehlt dir die Berechtigung“ — und der Knopf auf jener Seite führte zurück auf `/`, also im Kreis. + Beides behebt `startseite(rolle)` in `src/server/auth/rollen.ts`, an einer Stelle und mit Test. +- **Eine Gegenprobe blieb grün, und das war der Befund.** Der Test „sieht keine Notizen“ prüft die + **Antwort**, und die ist sicher: `laufBauen` setzt sie aus den erlaubten Feldern zusammen. Als ich + im `select` versuchsweise `ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der + Kopfkommentar der Datei behauptet, das `select` hole es „gar nicht erst“. Diese Behauptung hat + jetzt ihren eigenen Test, der die Datei selbst liest. Ein `select`, das Notizen mitliest, zieht + sie durch Protokolle und Fehlerberichte. +- **`selbstauskunft` hat auch der ADMIN nicht.** Es ist ein **Ersatz** für `lesen`, kein Zusatz: ein + zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die Einschränkung auf die + eigene Person vergisst. Wer beides braucht, braucht zwei Konten — und der CHECK verlangt das. +- **Der Rang von `MITARBEITER` liegt unter `LESER`.** Der Rang entscheidet, wer gewinnt, wenn jemand + in mehreren abgebildeten Gruppen steht. Stünde er darüber, verlöre ein Techniker seine Rechte, + weil er auch Mitarbeiter des Hauses ist. +- **Ein struktureller Test, dass keine Server Action `selbstauskunft` verlangt.** Die Zusage „kann + nichts schreiben“ steht nicht in einer Prüfung, sondern in der **Abwesenheit** einer Aktion mit + diesem Recht — und die muss jemand bewachen. +- **Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die + Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist. Ein Update, das jeden + aussperrt, dem die Gruppen fehlen, wäre ein kaputtes Update — mit eigenem Test. +- **Die authentik-Doku enthielt jetzt einen falschen Satz:** „Ist der Benutzer in keiner der vier + Gruppen, bekommt er `LESER`.“ Richtiggestellt, mit einem eigenen Abschnitt zur Selbstauskunft und + dem Hinweis, wie man das alte Verhalten behält. +- **Das Anleitungskapitel 09 ist an Mitarbeiter gerichtet**, nicht an die IT — das erste Kapitel + dieser Anleitung, das sich an jemanden außerhalb der IT wendet. Es lässt sich weitergeben, ohne + den Rest mitzuschicken, und sagt ausdrücklich, **warum** man die Schritte nicht sieht. diff --git a/docs/issues/README.md b/docs/issues/README.md index 38d2f34..0db2997 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -39,5 +39,5 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min | | #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min | | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min | -| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – | -| | **Summe** | | | **229 h** | **53 h 35 min** | +| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min | +| | **Summe** | | | **229 h** | **56 h 25 min** | diff --git a/prisma/migrations/20261006090000_selbstauskunft/migration.sql b/prisma/migrations/20261006090000_selbstauskunft/migration.sql new file mode 100644 index 0000000..0d02110 --- /dev/null +++ b/prisma/migrations/20261006090000_selbstauskunft/migration.sql @@ -0,0 +1,86 @@ +-- --------------------------------------------------------------------------- +-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die +-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes +-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt +-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese +-- Loeschbefehle sind bewusst gestrichen. +-- +-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind – +-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf. +-- --------------------------------------------------------------------------- + +-- Mitarbeiter-Selbstauskunft (#035). + +/* + * Die fuenfte Rolle. + * + * `ALTER TYPE ... ADD VALUE` laeuft in PostgreSQL seit 12 auch innerhalb einer + * Transaktion — Prisma fuehrt jede Migration in einer. Der neue Wert kommt + * **ans Ende**: die Reihenfolge eines Enums ist seine Sortierreihenfolge, und + * ein `MITARBEITER` zwischen `ADMIN` und `REDAKTEUR` wuerde jede Liste, die nach + * Rolle sortiert, still umstellen. + */ +ALTER TYPE "Rolle" ADD VALUE 'MITARBEITER'; + +ALTER TABLE "Benutzer" ADD COLUMN "mitarbeiterId" TEXT; + +/* + * **Eindeutig**: eine Person hat hoechstens ein Konto, mit dem sie sich selbst + * ansieht. Ohne das koennten zwei Anmeldungen auf dieselbe Person zeigen, und + * ein entzogener Zugang waere nicht entzogen, solange das zweite Konto bleibt. + * + * `NULL` gilt in Postgres als verschieden, die vielen Konten ohne Zuordnung + * stoeren hier also nicht. + */ +CREATE UNIQUE INDEX "Benutzer_mitarbeiterId_key" ON "Benutzer"("mitarbeiterId"); + +ALTER TABLE "Benutzer" + ADD CONSTRAINT "Benutzer_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId") + REFERENCES "Mitarbeiter"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +/* + * **Die eigentliche Zusage dieses Meilensteins, in der Datenbank.** + * + * Die Rolle `MITARBEITER` und die Zuordnung gehoeren zusammen, in beide + * Richtungen: + * + * - Ein `MITARBEITER` **ohne** `mitarbeiterId` haette ein Recht und niemanden, + * auf den es sich bezieht. Die Lesemodelle schraenken auf diese Kennung ein; + * fehlte sie, waere die Einschraenkung leer — und „leer“ heisst in SQL je nach + * Formulierung „nichts“ oder „alles“. Das ist genau die Art Zweideutigkeit, + * die hier nicht entstehen darf. + * - Eine `mitarbeiterId` an einem `ADMIN` waere die Umkehrung: ein Konto, das + * alles sieht und zusaetzlich als eine bestimmte Person gilt. Wer das braucht, + * braucht zwei Konten. + * + * `SET NULL` am Fremdschluessel oben wuerde diese Bedingung verletzen, wenn ein + * Mitarbeiter geloescht wird — deshalb der Trigger darunter, der die Rolle + * mitzieht. Geloescht wird ein Mitarbeiter ohnehin nie (CLAUDE.md: nur + * `aktiv = false`), aber eine Regel, die sich auf „kommt nicht vor“ verlaesst, + * ist keine Regel. + */ +ALTER TABLE "Benutzer" + ADD CONSTRAINT "Benutzer_mitarbeiter_nur_bei_rolle" + CHECK (("rolle" = 'MITARBEITER') = ("mitarbeiterId" IS NOT NULL)); + +/* + * Verliert ein Konto seine Person, verliert es auch die Rolle. + * + * Ohne das schlaegt das `ON DELETE SET NULL` des Fremdschluessels am CHECK + * darueber fehl, und das Loeschen eines Mitarbeiters waere unmoeglich statt + * folgerichtig. `LESER` und nicht `aktiv = false`: das Konto soll sich weiter + * anmelden koennen, nur nichts Fremdes sehen — und wer sich anmeldet, bekommt + * bei der naechsten OIDC-Anmeldung ohnehin eine neue Rolle zugewiesen. + */ +CREATE OR REPLACE FUNCTION laufzettel_konto_ohne_person() RETURNS trigger AS $$ +BEGIN + UPDATE "Benutzer" + SET "rolle" = 'LESER', "mitarbeiterId" = NULL + WHERE "mitarbeiterId" = OLD."id"; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "Mitarbeiter_konto_mitziehen" + BEFORE DELETE ON "Mitarbeiter" + FOR EACH ROW EXECUTE FUNCTION laufzettel_konto_ohne_person(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56d8dbb..5647755 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,12 @@ enum Rolle { REDAKTEUR TECHNIKER LESER + /// Selbstauskunft (#035): sieht die Laeufe zu **seinen** Geraeten und zu + /// seiner Person, und nichts sonst. Keine Schritte, keine Notizen, keine + /// Kommentare, keine Anhaenge — eine Selbstauskunft ist keine Akteneinsicht. + /// Haengt an `Benutzer.mitarbeiterId`; ohne Zuordnung gibt es die Rolle nicht + /// (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`). + MITARBEITER } enum Betriebsmodus { @@ -254,6 +260,15 @@ model Benutzer { /// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein /// Dienstkonto will keine Post. meldungenAktiv Boolean @default(true) + /// Die Person im Verzeichnis, als die dieses Konto sich selbst sehen darf + /// (#035). Gesetzt **genau dann**, wenn die Rolle `MITARBEITER` ist. + /// + /// Hier steht **kein** zusammengesetzter Fremdschluessel, anders als sonst bei + /// jedem kundenbezogenen Verweis: ein `Benutzer` traegt keine `kundeId`, gegen + /// die man zusammensetzen koennte — ein Administrator sieht alle Kunden. Die + /// Zusage „sieht nur seine eigenen Laeufe“ haengt an dieser einen Kennung, und + /// staerker als ein Fremdschluessel darauf wird sie nicht. + mitarbeiterId String? @unique erstelltAm DateTime @default(now()) geaendertAm DateTime @default(now()) @updatedAt @@ -268,6 +283,7 @@ model Benutzer { veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") anhaenge Anhang[] zustellungen Zustellung[] + mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull) @@index([aktiv]) @@index([rolle]) @@ -376,6 +392,9 @@ model Mitarbeiter { untergebene Mitarbeiter[] @relation("Vorgesetzter") geraete Geraet[] laeufe Lauf[] + /// Das Konto, mit dem diese Person sich selbst ansehen darf (#035). + /// Hoechstens eines — `Benutzer.mitarbeiterId` ist eindeutig. + konto Benutzer? /// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell /// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht. diff --git a/src/app/(app)/kein-zugriff/page.tsx b/src/app/(app)/kein-zugriff/page.tsx index 1602af1..bb9069e 100644 --- a/src/app/(app)/kein-zugriff/page.tsx +++ b/src/app/(app)/kein-zugriff/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { ShieldAlert } from "lucide-react"; import { ROLLEN_TEXT } from "@/lib/rollen-text"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { startseite } from "@/server/auth/rollen"; /** * „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018). @@ -30,6 +31,7 @@ export default async function KeinZugriffSeite({ // Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein // Text, den jemand anderes auf diese Seite schreibt. const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined; + const einstieg = startseite(benutzer.rolle); return (
@@ -56,11 +58,16 @@ export default async function KeinZugriffSeite({ Anmeldung übernommen.

+ {/* + * Der Knopf fuehrt zum **eigenen** Einstieg und nicht stur auf `/`. Fuer + * einen `MITARBEITER` war das eine Schleife: `/` verlangt `lesen`, also + * landete er wieder hier (#035). + */} - Zur Übersicht + {einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"}
); diff --git a/src/app/(app)/meine-geraete/page.tsx b/src/app/(app)/meine-geraete/page.tsx new file mode 100644 index 0000000..b8c3f2c --- /dev/null +++ b/src/app/(app)/meine-geraete/page.tsx @@ -0,0 +1,221 @@ +import { Info, Laptop } from "lucide-react"; +import { LaufStatus } from "@/generated/prisma/enums"; +import { GERAETE_TYP_TEXT } from "@/lib/geraete-text"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { + meineDaten, + meineGeraete, + meineLaeufeOhneGeraet, + type MeinLauf, +} from "@/server/queries/selbstauskunft"; + +/** + * Selbstauskunft: meine Geräte und was mit ihnen geschehen ist (#035). + * + * **Diese Seite zeigt keine Schritte, Notizen, Kommentare oder Anhänge** — und + * zwar nicht, weil sie sie weglässt, sondern weil das Lesemodell sie nicht + * liefert. Die Einschränkung sitzt in `src/server/queries/selbstauskunft.ts`; + * eine Seite, die filtert, wäre eine Seite, die es vergessen kann (CLAUDE.md). + * + * Jede Seite prüft selbst — die Hülle ist das zweite Netz, nicht das erste. + */ + +const STATUS_TEXT: Record = { + OFFEN: "offen", + IN_ARBEIT: "in Arbeit", + ABGESCHLOSSEN: "abgeschlossen", + ARCHIVIERT: "abgeschlossen", +}; + +/* + * `ARCHIVIERT` heisst hier „abgeschlossen“, nicht „archiviert“. + * + * Der Unterschied ist eine Frage der Aktenfuehrung und interessiert den + * Mitarbeiter nicht — fuer ihn ist beides „fertig“. „Archiviert“ klaenge + * ausserdem, als waere etwas weggelegt worden. + */ + +const STATUS_FARBE: Record = { + OFFEN: "text-nebentext", + IN_ARBEIT: "text-faellig", + ABGESCHLOSSEN: "text-erledigt", + ARCHIVIERT: "text-erledigt", +}; + +function datum(wert: Date): string { + return wert.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "Europe/Berlin", + }); +} + +/** Der Zeitraum eines Laufs, so kurz wie möglich. */ +function zeitraum(lauf: MeinLauf): string { + const beginn = lauf.begonnenAm ?? lauf.erstelltAm; + if (!lauf.abgeschlossenAm) return `seit ${datum(beginn)}`; + const anfang = datum(beginn); + const ende = datum(lauf.abgeschlossenAm); + return anfang === ende ? anfang : `${anfang} – ${ende}`; +} + +function LaufZeile({ lauf }: { lauf: MeinLauf }) { + return ( +
  • + {lauf.titel} + {zeitraum(lauf)} + {STATUS_TEXT[lauf.status]} +
  • + ); +} + +export default async function MeineGeraeteSeite() { + const { benutzer } = await sitzungErforderlich("selbstauskunft", "/meine-geraete"); + + /* + * Ohne Zuordnung gibt es die Rolle nicht — das hält die Datenbank fest + * (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`). Der Fall kann hier also nicht + * eintreten; die Prüfung steht trotzdem da, weil ein `!` an dieser Stelle eine + * Behauptung wäre und kein Beweis. + */ + if (!benutzer.mitarbeiterId) { + return ( + <> +

    Meine Geräte

    +

    + Dieses Konto ist keiner Person im Verzeichnis zugeordnet. Ein Administrator muss das einrichten — + bis dahin gibt es hier nichts zu zeigen. +

    + + ); + } + + const [geraete, ohneGeraet, daten] = await Promise.all([ + meineGeraete(benutzer.mitarbeiterId), + meineLaeufeOhneGeraet(benutzer.mitarbeiterId), + meineDaten(benutzer.mitarbeiterId), + ]); + + const nichts = geraete.length === 0 && ohneGeraet.length === 0; + + return ( + <> +

    Meine Geräte

    +

    + Die Geräte, die auf {daten?.anzeigename ?? "dich"} laufen, und was die IT daran gemacht hat. +

    + +

    +

    + + {daten ? ( +
    +

    Meine Daten

    +

    + So steht es im Verzeichnis. Stimmt etwas nicht, meldet das die IT dem Personalbüro — ändern lässt + es sich hier nicht. +

    +
    +
    Name
    +
    {daten.anzeigename}
    + {daten.titel ? ( + <> +
    Funktion
    +
    {daten.titel}
    + + ) : null} + {daten.abteilung ? ( + <> +
    Abteilung
    +
    {daten.abteilung}
    + + ) : null} + {daten.mail ? ( + <> +
    E-Mail
    +
    {daten.mail}
    + + ) : null} +
    +
    + ) : null} + + {nichts ? ( +

    + Auf dich läuft gerade kein Gerät, und es ist auch keine Arbeit zu dir vermerkt. Das ist kein Fehler + — es heißt nur, dass die IT nichts zu dir festgehalten hat. +

    + ) : null} + + {geraete.length > 0 ? ( +
      + {geraete.map((geraet) => ( +
    • +
      +
      + +
      + {geraet.seriennummer ? ( +
      +
      Seriennummer:
      +
      {geraet.seriennummer}
      +
      + ) : null} +
      +
      Aufkleber:
      +
      {geraet.tagCode}
      +
      +
      + + {geraet.laeufe.length === 0 ? ( +

      + Zu diesem Gerät ist noch keine Arbeit festgehalten. +

      + ) : ( +
        + {geraet.laeufe.map((lauf) => ( + + ))} +
      + )} +
    • + ))} +
    + ) : null} + + {ohneGeraet.length > 0 ? ( +
    +

    Zu mir persönlich

    +

    + Arbeiten, die nicht an einem Gerät hängen — etwa das Einrichten deines Kontos. +

    +
      + {ohneGeraet.map((lauf) => ( + + ))} +
    +
    + ) : null} + + ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index e67d558..b48edb4 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react"; import { LaufStatus } from "@/generated/prisma/enums"; import { fristVerstrichen } from "@/lib/fristen"; -import { darf } from "@/server/auth/rollen"; +import { darf, startseite } from "@/server/auth/rollen"; +import { redirect } from "next/navigation"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { UEBERSICHT_JE_BLOCK, @@ -223,6 +224,17 @@ function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) { } export default async function Uebersichtsseite() { + /* + * **Erst nur die Anmeldung, dann das Recht.** Wer hier kein `lesen` hat, aber + * einen eigenen Einstieg (seit #035 der `MITARBEITER`), wird dorthin + * geschickt — statt auf „Dafuer fehlt dir die Berechtigung“ zu landen, obwohl + * er gerade nur die Anwendung geoeffnet hat. + */ + const angemeldeter = await sitzungErforderlich(undefined, "/"); + const einstieg = startseite(angemeldeter.benutzer.rolle); + if (einstieg !== "/") redirect(einstieg); + + // Und jetzt das Recht, mit derselben Wirkung wie vorher. const { benutzer } = await sitzungErforderlich("lesen", "/"); const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten"); diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx index d0094ac..3a53f1c 100644 --- a/src/app/anmelden/page.tsx +++ b/src/app/anmelden/page.tsx @@ -22,6 +22,23 @@ const MELDUNGEN = new Map( "oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.", "oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.", "oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.", + /* + * Zwei eigene Codes fuer die Selbstauskunft (#035). Nicht `oidc-rueckruf`: + * „fehlgeschlagen“ laesst jemanden es noch dreimal versuchen, obwohl sich + * ohne Zutun eines Administrators nichts aendert. + * + * **Nicht auf `/kein-zugriff`**, obwohl das Issue es so vorsah: diese Seite + * verlangt eine Sitzung, und bei gescheiterter Anmeldung gibt es keine — sie + * wuerde auf die Anmeldeseite zurueckleiten, also im Kreis. Die Begruendung + * gehoert deshalb hierhin, und genau das ist der Punkt des Kriteriums: keine + * leere Seite, die wie ein Fehler aussieht. + */ + "keine-person": + "Dein Konto ist keiner Person im Verzeichnis zugeordnet und gehört keiner Rollengruppe an. " + + "Damit gibt es nichts, was angezeigt werden könnte. Bitte wende dich an die IT.", + "upn-mehrdeutig": + "Dein Anmeldename gehört zu mehr als einer Person im Verzeichnis. Das muss die IT auflösen — " + + "welche Person gemeint ist, lässt sich hier nicht raten.", }), ); diff --git a/src/app/api/auth/oidc/rueckruf/route.ts b/src/app/api/auth/oidc/rueckruf/route.ts index 0e46107..4d08049 100644 --- a/src/app/api/auth/oidc/rueckruf/route.ts +++ b/src/app/api/auth/oidc/rueckruf/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { oeffentlicheAdresse } from "@/lib/adressen"; import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung"; -import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer"; +import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer"; import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc"; /** @@ -35,8 +35,27 @@ export async function GET(anfrage: NextRequest): Promise { `OIDC-Rueckruf fehlgeschlagen:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), ); + /* + * Ein `AnmeldeFehler` ist etwas anderes als ein kaputter OIDC-Fluss: am + * Anbieter hat alles funktioniert, nur darf oder kann dieses Konto hier + * nichts sehen (#035). „Die Anmeldung ist fehlgeschlagen“ liesse jemanden es + * dreimal versuchen, obwohl sich ohne Zutun eines Administrators nichts + * aendert — deshalb ein eigener Code mit einer Begruendung, die weiterhilft. + * + * Unterschieden wird am **Grund**, nicht an der Meldung: der Text wird hier + * nie durchgereicht, sonst stuende eine Dienstmeldung in einer Adresse. + */ + const schluessel = + fehler instanceof AnmeldeFehler + ? fehler.grund === "upn-mehrdeutig" + ? "upn-mehrdeutig" + : fehler.grund === "keine-person" + ? "keine-person" + : "oidc-rueckruf" + : "oidc-rueckruf"; + const antwort = NextResponse.redirect( - oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin), + oeffentlicheAdresse(`/anmelden?fehler=${schluessel}`, anfrage.nextUrl.origin), ); antwort.cookies.delete(OIDC_FLUSS_COOKIE); return antwort; diff --git a/src/components/huelle.tsx b/src/components/huelle.tsx index ac634fc..f4430fc 100644 --- a/src/components/huelle.tsx +++ b/src/components/huelle.tsx @@ -4,6 +4,7 @@ import { Building2, FileText, LayoutDashboard, + Laptop, ListChecks, LogOut, Menu, @@ -33,6 +34,7 @@ const SYMBOLE: Record = { Building2, Archive, Settings, + Laptop, }; function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) { @@ -184,6 +186,15 @@ export function Huelle({ aktiv?: string; benutzer?: AngemeldeterBenutzer; }) { + /* + * Scannen setzt voraus, dass man mit dem Gefundenen etwas tun darf. Ein + * `MITARBEITER` darf das nicht (#035) — ihm den Knopf zu zeigen hiesse, ihn + * auf eine Seite zu schicken, die ihn abweist. `lesen` ist dabei das richtige + * Recht und nicht `geraeteBearbeiten`: ein LESER darf das gescannte Geraet + * ansehen, nur nichts aendern. + */ + const darfScannen = benutzer ? darf(benutzer.rolle, "lesen") : false; + return (
    {/* Handy: Kopfzeile mit Menue */} @@ -191,7 +202,7 @@ export function Huelle({
    - + {darfScannen ? : null}
    - + {darfScannen ? : null} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index f455ea5..95211d9 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -30,15 +30,26 @@ export type NavEintrag = { readonly ausIssue?: number; }; +/** + * **Jeder Eintrag nennt sein Recht.** + * + * Bis #035 stand `recht` nur am Admin-Bereich, und das genuegte: jede Rolle + * durfte `lesen`, also war alles andere fuer jeden sichtbar. Mit `MITARBEITER` + * stimmt das nicht mehr — diese Rolle darf **nicht** `lesen`, und ohne das Recht + * an jedem Eintrag haette die Seitenleiste ihr sechs Links angeboten, die alle + * auf `/kein-zugriff` fuehren. Ein Eintrag ohne `recht` ist seitdem die Ausnahme + * und nicht die Regel. + */ export const NAVIGATION: readonly NavEintrag[] = [ - { pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true }, - { pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true }, - { pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true }, + { pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", recht: "lesen", verfuegbar: true }, + { pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", recht: "lesen", verfuegbar: true }, + { pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", recht: "lesen", verfuegbar: true }, { pfad: "/mitarbeiter", beschriftung: "Mitarbeiter & Geräte", beschriftungDienstleistung: "Personen & Geräte", symbol: "Users", + recht: "lesen", verfuegbar: true, }, { @@ -46,10 +57,23 @@ export const NAVIGATION: readonly NavEintrag[] = [ beschriftung: "Kunden", nurModus: "DIENSTLEISTUNG", symbol: "Building2", + recht: "lesen", verfuegbar: true, }, - { pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: true }, + { pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", recht: "lesen", verfuegbar: true }, { pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", verfuegbar: true }, + /* + * Die eine Seite der Selbstauskunft (#035). Sie steht am Ende, weil die + * Reihenfolge fuer alle anderen Rollen gilt — und fuer einen `MITARBEITER` ist + * sie der einzige Eintrag, da ist die Position gleichgueltig. + */ + { + pfad: "/meine-geraete", + beschriftung: "Meine Geräte", + symbol: "Laptop", + recht: "selbstauskunft", + verfuegbar: true, + }, ]; /** diff --git a/src/lib/rollen-text.ts b/src/lib/rollen-text.ts index 4c45ce2..c68d88c 100644 --- a/src/lib/rollen-text.ts +++ b/src/lib/rollen-text.ts @@ -13,6 +13,7 @@ export const ROLLEN_TEXT: Record = { [Rolle.REDAKTEUR]: "Redakteur", [Rolle.TECHNIKER]: "Techniker", [Rolle.LESER]: "Leser", + [Rolle.MITARBEITER]: "Mitarbeiter (Selbstauskunft)", }; /** @@ -24,4 +25,11 @@ export const ROLLEN_RANG: Record = { [Rolle.REDAKTEUR]: 30, [Rolle.TECHNIKER]: 20, [Rolle.LESER]: 10, + /* + * **Unter LESER, und zwar deutlich.** Ein MITARBEITER sieht weniger als jede + * andere Rolle: nur sich selbst. Der Rang entscheidet in `staerksteRolle`, wer + * gewinnt, wenn jemand in mehreren abgebildeten Gruppen steht — und dort darf + * die Selbstauskunft nie eine echte Rolle verdraengen. + */ + [Rolle.MITARBEITER]: 5, }; diff --git a/src/server/auth/benutzer.ts b/src/server/auth/benutzer.ts index 8aa761f..20c78e7 100644 --- a/src/server/auth/benutzer.ts +++ b/src/server/auth/benutzer.ts @@ -5,7 +5,7 @@ import type { Rolle } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { kundenkuerzelAusGruppen, - rolleAusGruppen, + rolleAusGruppenGenau, rollenZuordnungAktiv, type OidcAnspruecke, } from "@/server/auth/oidc"; @@ -17,10 +17,23 @@ import { * Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen. */ +/** + * Gruende, die der Aufrufer **unterscheiden** muss. + * + * Nicht am Meldungstext festmachen: der ist fuer Menschen und aendert sich. Die + * beiden Gruende aus #035 fuehren auf eigene Hinweise an der Anmeldeseite, denn + * „fehlgeschlagen“ liesse jemanden es dreimal versuchen, obwohl sich ohne Zutun + * eines Administrators nichts aendert. + */ +export type AnmeldeGrund = "keine-person" | "upn-mehrdeutig"; + export class AnmeldeFehler extends Error { - constructor(nachricht: string) { + readonly grund: AnmeldeGrund | undefined; + + constructor(nachricht: string, grund?: AnmeldeGrund) { super(nachricht); this.name = "AnmeldeFehler"; + this.grund = grund; } } @@ -102,6 +115,112 @@ export async function passwortHashen(passwort: string): Promise { * Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass * jemand hier nachpflegen muss. */ +/** + * Die Person im Verzeichnis zu einem UPN – oder ein Fehler, wenn es mehrere sind. + * + * Verglichen wird **ohne** Ruecksicht auf Gross- und Kleinschreibung: ein UPN + * ist technisch nicht case-sensitiv, und ein Anbieter schickt ihn nicht immer so, + * wie er im Verzeichnis steht. + * + * **Mehrdeutig ist ein Fehler, kein Zufallstreffer.** Zwei Kunden koennen + * dieselbe Person fuehren (dieselbe Firma, zwei Mandanten), und ein `findFirst` + * entschiede dann nach Sortierung darueber, wessen Geraete jemand sieht. Lieber + * kein Zugang als der falsche. + */ +async function personZuUpn( + upn: string, +): Promise< + | { readonly treffer: "einer"; readonly id: string; readonly kundeId: string } + | { readonly treffer: "keiner" } + | { readonly treffer: "mehrere"; readonly anzahl: number } +> { + const gesucht = upn.trim(); + if (!gesucht) return { treffer: "keiner" }; + + const passende = await prisma.mitarbeiter.findMany({ + where: { upn: { equals: gesucht, mode: "insensitive" }, aktiv: true }, + select: { id: true, kundeId: true }, + take: 2, + }); + + const erster = passende[0]; + if (!erster) return { treffer: "keiner" }; + if (passende.length > 1) return { treffer: "mehrere", anzahl: passende.length }; + return { treffer: "einer", id: erster.id, kundeId: erster.kundeId }; +} + +/** + * Welche Rolle bekommt diese Anmeldung, und auf welche Person zeigt sie? + * + * Drei Faelle, in dieser Reihenfolge: + * + * 1. **Keine Gruppen-Zuordnung eingerichtet** — alles bleibt wie bisher. Die + * Selbstauskunft greift dann **gar nicht**: ohne abgebildete Gruppen kann die + * Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist, und + * ein Update duerfte niemandem seinen Zugang nehmen. + * 2. **Eine Gruppe hat getroffen** — diese Rolle, und keine Person. Wer eine + * echte Rolle hat, sieht ueber `lesen` ohnehin alles. + * 3. **Zuordnung aktiv, aber keine Gruppe getroffen** — der Fall, um den es in + * #035 geht: ueber den UPN wird eine Person gesucht. Gibt es sie, wird daraus + * ein `MITARBEITER`; gibt es sie nicht, gibt es **keinen Zugang**. + * + * Fall 3 ist eine Verschaerfung gegenueber dem Stand vor #035, wo dieselbe Lage + * `LESER` ergab — also Lesezugriff auf alle Laeufe des Hauses. Das war fuer eine + * Person ohne jede Rollengruppe zu viel, und genau deshalb steht es im Changelog + * als Aenderung und nicht als Ergaenzung. + */ +async function rolleUndPersonBestimmen( + anspruecke: OidcAnspruecke, + vorhanden: Benutzer | null, +): Promise<{ readonly rolle: Rolle; readonly mitarbeiterId: string | null }> { + if (!rollenZuordnungAktiv()) { + // Wie bisher – und eine bestehende Zuordnung bleibt, wie sie ist. + return { + rolle: vorhanden?.rolle ?? "LESER", + mitarbeiterId: vorhanden?.mitarbeiterId ?? null, + }; + } + + const ausGruppen = rolleAusGruppenGenau(anspruecke.gruppen); + if (ausGruppen) return { rolle: ausGruppen, mitarbeiterId: null }; + + const person = await personZuUpn(anspruecke.upn); + + if (person.treffer === "mehrere") { + /* + * Protokolliert, **bevor** geworfen wird: wer das in der Oberflaeche sieht, + * soll nicht raten muessen, warum sich jemand nicht anmelden kann. Ohne + * Benutzerbezug, denn das Konto gibt es an dieser Stelle vielleicht noch + * nicht — der Eintrag erscheint im Protokoll als Systemlauf. + */ + await prisma.protokoll.create({ + data: { + entitaet: "Benutzer", + entitaetId: vorhanden?.id ?? anspruecke.sub, + aktion: "Anmeldung abgewiesen: UPN mehrdeutig", + nachher: { upn: anspruecke.upn, treffer: person.anzahl }, + }, + }); + throw new AnmeldeFehler( + `Der Anmeldename ${anspruecke.upn} gehoert zu mehr als einer Person im Verzeichnis. ` + + "Ein Administrator muss das aufloesen – welche Person gemeint ist, laesst sich hier nicht raten.", + "upn-mehrdeutig", + ); + } + + if (person.treffer === "keiner") { + throw new AnmeldeFehler( + `Zu dem Anmeldenamen ${anspruecke.upn} gibt es keine aktive Person im Verzeichnis, und ` + + "dieses Konto gehoert keiner Rollengruppe an. Damit gibt es nichts, was angezeigt werden " + + "koennte – ein Administrator muss entweder eine Rolle zuweisen oder die Person im " + + "Verzeichnis pruefen.", + "keine-person", + ); + } + + return { rolle: "MITARBEITER", mitarbeiterId: person.id }; +} + export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise { // Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die // Kennung beim Anbieter nicht. @@ -133,9 +252,8 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); } - // Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst - // waere jede Anmeldung eine stille Herabstufung auf LESER. - const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER"); + const zuweisung = await rolleUndPersonBestimmen(anspruecke, vorhanden); + const { rolle, mitarbeiterId } = zuweisung; const benutzer = vorhanden ? await prisma.benutzer.update({ @@ -145,6 +263,7 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr email: anspruecke.email, name: anspruecke.name, rolle, + mitarbeiterId, letzteAnmeldungAm: new Date(), }, }) @@ -154,6 +273,7 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr email: anspruecke.email, name: anspruecke.name, rolle, + mitarbeiterId, aktiv: true, letzteAnmeldungAm: new Date(), }, diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts index df864ea..1517afe 100644 --- a/src/server/auth/oidc.ts +++ b/src/server/auth/oidc.ts @@ -1,5 +1,5 @@ import * as oidc from "openid-client"; -import type { Rolle } from "@/generated/prisma/enums"; +import { Rolle } from "@/generated/prisma/enums"; import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; import { staerksteRolle } from "@/server/auth/rollen"; @@ -139,6 +139,16 @@ export type OidcAnspruecke = { readonly emailBestaetigt: boolean; readonly name: string; readonly gruppen: readonly string[]; + /** + * Der UPN, an dem die Selbstauskunft haengt (#035). + * + * Reihenfolge mit Absicht: `upn` (so heisst der Anspruch bei Entra ID), + * sonst `preferred_username` (authentik und Keycloak schicken dort den + * Anmeldenamen), sonst die Mailadresse. Die Mailadresse zuletzt, weil sie im + * AD oft **nicht** der UPN ist — `vorname.nachname@firma.de` gegen + * `vnachname@firma.local`. + */ + readonly upn: string; /** Ziel, das vor der Anmeldung angefordert wurde. */ readonly ziel: string; }; @@ -199,6 +209,7 @@ export async function anmeldungAbschliessen( emailBestaetigt: anspruecke["email_verified"] === true, name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email, gruppen: gruppenAus(anspruecke["groups"]), + upn: textAus(anspruecke["upn"]) ?? textAus(anspruecke["preferred_username"]) ?? email, ziel: fluss["ziel"] ?? "/", }; } @@ -223,7 +234,15 @@ export function rollenZuordnungAktiv(): boolean { ); } -export function rolleAusGruppen(gruppen: readonly string[]): Rolle { +/** + * Die abgebildete Rolle – oder `undefined`, wenn **keine** Gruppe getroffen hat. + * + * Die Unterscheidung ist der Angelpunkt der Selbstauskunft (#035): „keine + * Gruppe getroffen“ und „die Gruppe fuer LESER getroffen“ sahen bis dahin + * gleich aus, weil beides `LESER` ergab. Erst wer sie trennt, kann aus dem + * ersten Fall eine Selbstauskunft machen, ohne dem zweiten etwas zu nehmen. + */ +export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefined { const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [ [umgebung("OIDC_GROUP_ADMIN"), "ADMIN"], [umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"], @@ -234,7 +253,11 @@ export function rolleAusGruppen(gruppen: readonly string[]): Rolle { const getroffen = zuordnung .filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe)) .map(([, rolle]) => rolle); - return staerksteRolle(getroffen); + return getroffen.length === 0 ? undefined : staerksteRolle(getroffen); +} + +export function rolleAusGruppen(gruppen: readonly string[]): Rolle { + return rolleAusGruppenGenau(gruppen) ?? Rolle.LESER; } /** diff --git a/src/server/auth/rollen.ts b/src/server/auth/rollen.ts index d5f3548..398a709 100644 --- a/src/server/auth/rollen.ts +++ b/src/server/auth/rollen.ts @@ -20,8 +20,24 @@ import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text"; * des Cookies. */ export const RECHTE = { - /** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */ + /** + * Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. + * + * **Nicht mehr „jede Rolle“** (#035): `MITARBEITER` fehlt hier, und das ist + * der ganze Punkt dieser Rolle. Wer sich selbst auskunftsberechtigt anmeldet, + * darf nicht die Laeufe des Hauses lesen — dafuer gibt es `selbstauskunft`. + */ lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"], + /** + * Die eigenen Geraete und die Laeufe dazu ansehen (#035). + * + * **Nur `MITARBEITER`.** Die anderen Rollen sehen ueber `lesen` ohnehin alles; + * sie hier mit aufzunehmen waere ein zweiter Weg zu denselben Daten und damit + * eine zweite Stelle, an der jemand die Einschraenkung auf die eigene Person + * vergessen kann. Ein Techniker, der seine eigenen Geraete sehen will, nimmt + * die gewoehnliche Geraeteliste. + */ + selbstauskunft: ["MITARBEITER"], /** Laeufe starten, abarbeiten, abschliessen und archivieren. */ laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], /** Geraete und deren Stammdaten pflegen, Tags registrieren. */ @@ -57,18 +73,59 @@ export function darf(rolle: Rolle, recht: Recht): boolean { * Die staerkste der uebergebenen Rollen. * * Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die - * staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer - * sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen - * und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren. + * staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. + * + * **Das gilt weiter fuer eine eingerichtete Zuordnung ohne Treffer in dieser + * Funktion.** Ob aus „keine Gruppe getroffen“ eine Selbstauskunft wird, entscheidet + * seit #035 der Login ueber `rolleAusGruppenGenau` — hier bleibt der Standard + * `LESER`, damit ein Aufrufer ohne diese Unterscheidung nie versehentlich mehr + * bekommt als vorher. */ export function staerksteRolle(rollen: readonly Rolle[]): Rolle { - let staerkste: Rolle = Rolle.LESER; + /* + * `undefined` als Startwert, nicht `LESER`. + * + * Mit `LESER` als Startwert war der Wert zugleich **Untergrenze**: eine + * uebergebene Rolle unterhalb davon kam nie heraus. Solange alle Rollen + * mindestens `LESER` waren, fiel das nicht auf — mit `MITARBEITER` (#035) gab + * `staerksteRolle(["MITARBEITER"])` ploetzlich `LESER`, also **mehr** Rechte + * als uebergeben. Der Test „gibt bei nur diese Rolle zurueck“ stand + * seit #007 da und hat es sofort gezeigt. + * + * Der Rueckfall auf `LESER` gilt jetzt nur noch fuer die **leere** Liste, und + * genau so steht es auch im Kommentar darueber. + */ + let staerkste: Rolle | undefined; for (const rolle of rollen) { - if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) { + if (staerkste === undefined || ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) { staerkste = rolle; } } - return staerkste; + return staerkste ?? Rolle.LESER; +} + +/** + * Wo diese Rolle nach der Anmeldung landet (#035). + * + * **Gefunden beim Durchklicken:** ein `MITARBEITER` meldete sich an und sah + * „Dafuer fehlt dir die Berechtigung“ — das Standardziel ist `/`, und die + * Uebersicht verlangt `lesen`. Genau die leere Seite, die wie ein Fehler + * aussieht und die #035 vermeiden will. + * + * Eine reine Funktion und nicht ein `if` in der Seite: dieselbe Antwort braucht + * die Uebersicht (zum Umleiten), `/kein-zugriff` (fuer seinen Knopf — der auf + * `/` zeigte und damit im Kreis) und spaeter jede weitere Rolle mit eigenem + * Einstieg. + */ +export function startseite(rolle: Rolle): string { + if (darf(rolle, "lesen")) return "/"; + if (darf(rolle, "selbstauskunft")) return "/meine-geraete"; + /* + * Kein Recht fuer irgendeinen Einstieg. Gibt es heute nicht — jede Rolle hat + * entweder `lesen` oder `selbstauskunft` —, und `/` ist hier die ehrlichere + * Antwort als ein Wurf: dort erklaert die Seite, was los ist. + */ + return "/"; } // Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne diff --git a/src/server/queries/selbstauskunft.ts b/src/server/queries/selbstauskunft.ts new file mode 100644 index 0000000..93bed0b --- /dev/null +++ b/src/server/queries/selbstauskunft.ts @@ -0,0 +1,162 @@ +import { LaufStatus, type GeraeteTyp } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; + +/** + * Was ein Mitarbeiter über sich selbst sehen darf (#035). + * + * **Die wichtigste Entscheidung dieses Meilensteins steht hier im Code und nicht + * in der Seite:** eine Selbstauskunft ist **keine Akteneinsicht**. Zurück kommen + * Titel, Status, Zeitraum und das Gerät eines Laufs — und sonst nichts. + * + * Nicht dabei, und zwar ausdrücklich: + * + * - **Schritte.** In ihnen stehen Befehle mit Platzhaltern für Zugangsdaten. + * - **Notizen.** „Gerät kam beschädigt zurück, Nutzer unkooperativ“ ist eine + * Arbeitsnotiz über einen Menschen, nicht eine Auskunft an ihn. + * - **Kommentare.** Dasselbe, nur länger. + * - **Anhänge.** Ein Foto vom Typenschild ist harmlos, eines vom Schreibtisch + * nicht — und welches es ist, weiß hier niemand. + * + * Der Typ `MeinLauf` trägt deshalb nichts davon, und das `select` unten holt es + * gar nicht erst. Wer es ändern wollte, müsste beides anfassen und stünde vor + * diesem Kommentar. + * + * **Jede Abfrage schränkt selbst ein** (CLAUDE.md): die `mitarbeiterId` kommt + * als Parameter, und ohne sie gibt es keine Abfrage. Eine Seite, die filtert, + * wäre eine Seite, die es vergessen kann. + */ + +export type MeinLauf = { + readonly id: string; + readonly titel: string; + readonly status: LaufStatus; + readonly vorlagenName: string; + readonly begonnenAm: Date | null; + readonly abgeschlossenAm: Date | null; + readonly erstelltAm: Date; +}; + +export type MeinGeraet = { + readonly id: string; + readonly bezeichnung: string; + readonly typ: GeraeteTyp; + readonly tagCode: string; + readonly seriennummer: string | null; + readonly archiviert: boolean; + readonly laeufe: readonly MeinLauf[]; +}; + +/** Nur die Felder, die eine Selbstauskunft tragen darf. */ +const LAUF_AUSWAHL = { + id: true, + titel: true, + status: true, + gestartetAm: true, + abgeschlossenAm: true, + erstelltAm: true, + vorlagenVersion: { select: { vorlage: { select: { name: true } } } }, +} as const; + +type RoherLauf = { + readonly id: string; + readonly titel: string; + readonly status: LaufStatus; + readonly gestartetAm: Date | null; + readonly abgeschlossenAm: Date | null; + readonly erstelltAm: Date; + readonly vorlagenVersion: { readonly vorlage: { readonly name: string } }; +}; + +function laufBauen(zeile: RoherLauf): MeinLauf { + return { + id: zeile.id, + titel: zeile.titel, + status: zeile.status, + vorlagenName: zeile.vorlagenVersion.vorlage.name, + begonnenAm: zeile.gestartetAm, + abgeschlossenAm: zeile.abgeschlossenAm, + erstelltAm: zeile.erstelltAm, + }; +} + +/** Der Anzeigename eines Geraets, wie ihn auch die Geraeteliste bildet. */ +function geraetName(geraet: { + readonly hostname: string | null; + readonly bezeichnung: string | null; + readonly tagCode: string; +}): string { + return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode; +} + +/** + * Meine Geräte, je Gerät die Läufe chronologisch. + * + * Archivierte Geräte bleiben dabei: „was ist mit meinem alten Notebook + * passiert?“ ist genau die Frage, um die es geht, und das alte Notebook ist + * archiviert. + */ +export async function meineGeraete(mitarbeiterId: string): Promise { + const zeilen = await prisma.geraet.findMany({ + where: { mitarbeiterId }, + orderBy: [{ archiviert: "asc" }, { erstelltAm: "desc" }], + select: { + id: true, + hostname: true, + bezeichnung: true, + tagCode: true, + typ: true, + seriennummer: true, + archiviert: true, + laeufe: { select: LAUF_AUSWAHL, orderBy: { erstelltAm: "desc" } }, + }, + }); + + return zeilen.map((zeile) => ({ + id: zeile.id, + bezeichnung: geraetName(zeile), + typ: zeile.typ, + tagCode: zeile.tagCode, + seriennummer: zeile.seriennummer, + archiviert: zeile.archiviert, + laeufe: zeile.laeufe.map(laufBauen), + })); +} + +/** + * Läufe, die an **mir** hängen und nicht an einem Gerät. + * + * Das Onboarding ist der Fall: es läuft auf die Person, nicht auf ein Notebook. + * Läufe mit Gerät stehen schon unter dem Gerät — hier noch einmal wären sie + * doppelt. + */ +export async function meineLaeufeOhneGeraet(mitarbeiterId: string): Promise { + const zeilen = await prisma.lauf.findMany({ + where: { mitarbeiterId, geraetId: null }, + orderBy: { erstelltAm: "desc" }, + select: LAUF_AUSWAHL, + }); + return zeilen.map(laufBauen); +} + +export type MeineDaten = { + readonly anzeigename: string; + readonly abteilung: string | null; + readonly titel: string | null; + readonly mail: string | null; +}; + +/** + * Die eigenen Stammdaten, wie das Verzeichnis sie führt. + * + * Dabei, weil die häufigste Rückfrage nach einem Abgleich „steht meine Abteilung + * richtig drin?“ ist — und weil jemand sehen soll, auf welche Person dieses + * Konto zeigt. `undefined`, wenn die Zeile verschwunden ist; die Seite macht + * daraus einen Hinweis und keine leere Fläche. + */ +export async function meineDaten(mitarbeiterId: string): Promise { + const zeile = await prisma.mitarbeiter.findUnique({ + where: { id: mitarbeiterId }, + select: { anzeigename: true, abteilung: true, titel: true, mail: true }, + }); + return zeile ?? undefined; +} diff --git a/tests/benutzer.test.ts b/tests/benutzer.test.ts index 48d66d8..d2891bc 100644 --- a/tests/benutzer.test.ts +++ b/tests/benutzer.test.ts @@ -22,13 +22,26 @@ import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; const UMGEBUNG = { ...process.env }; +/** + * Standard-Anspruechge **mit** einer Rollengruppe. + * + * Seit #035 ist „angemeldet, aber in keiner Rollengruppe“ kein stilles `LESER` + * mehr, sondern fuehrt in die Selbstauskunft — und ohne passende Person im + * Verzeichnis wird die Anmeldung abgewiesen. Die Tests hier pruefen + * JIT-Provisioning, Protokoll und Kundenbindung; sie sollen nicht nebenbei an + * dieser Weiche haengen. Wer die Weiche selbst prueft, findet das in + * `tests/selbstauskunft.test.ts`. + */ function anspruecke(ueberschreiben: Partial = {}): OidcAnspruecke { return { sub: "anbieter-sub-1", email: "maria.huber@firma.example", emailBestaetigt: true, name: "Maria Huber", - gruppen: [], + gruppen: ["lz-techniker"], + // Der UPN haengt an der Selbstauskunft (#035); im AD ist er oft nicht die + // Mailadresse, deshalb hier bewusst ein anderer Wert. + upn: "mhuber@firma.local", ziel: "/", ...ueberschreiben, }; @@ -136,9 +149,23 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => { const befoerdert = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); expect(befoerdert.rolle).toBe("ADMIN"); - // Und wieder zurueck: eine entzogene Gruppe entzieht die Rolle. - const entzogen = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] })); - expect(entzogen.rolle).toBe("LESER"); + /* + * Und wieder zurueck. **Seit #035 ist das kein `LESER` mehr:** wem die + * Rollengruppe entzogen wird und wer zu keiner Person im Verzeichnis + * passt, kommt nicht mehr herein. + * + * Das ist strenger als vorher und Absicht. Vorher behielt ein + * ausgeschiedener Techniker Lesezugriff auf **alle** Laeufe des Hauses, + * solange niemand sein Konto deaktivierte — und genau das vergisst man. + * Der Notfallzugang dagegen bleibt: `AUTH_LOCAL_ENABLED` haengt nicht an + * den Gruppen. + */ + await expect(benutzerAusOidcUebernehmen(anspruecke({ gruppen: [] }))).rejects.toThrow(AnmeldeFehler); + + // Die Rolle in der Datenbank bleibt dabei, was sie war – abgewiesen heisst + // abgewiesen, nicht herabgestuft. + const unveraendert = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: erst.id } }); + expect(unveraendert.rolle).toBe("ADMIN"); }); it("protokolliert jede Rollenaenderung aus den Gruppen", async () => { @@ -195,12 +222,22 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => { }); describe("Kundenbindung", () => { + /* + * **Rollengruppe und Kundengruppe sind zwei Dinge** (#035). Diese Tests gaben + * vorher nur `lz-kunde-MUS` mit und bekamen dadurch stillschweigend `LESER`. + * Seit #035 fuehrt „keine Rollengruppe“ in die Selbstauskunft — eine + * Kundengruppe allein sagt eben nichts darueber, was jemand duerfen soll. + * Wer lesen soll, gehoert in die Leser-Gruppe; dafuer gibt es + * `OIDC_GROUP_VIEWER`. + */ it("bindet den Benutzer an die Kunden aus seinen Gruppen", async () => { process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); await testPrisma.kunde.create({ data: { name: "Baeckerei Reiter", kuerzel: "REI" } }); - const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] })); + const benutzer = await benutzerAusOidcUebernehmen( + anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }), + ); const bindungen = await testPrisma.benutzerKunde.findMany({ where: { benutzerId: benutzer.id }, }); @@ -215,8 +252,10 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => { data: { name: "Baeckerei Reiter", kuerzel: "REI" }, }); - const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] })); - await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-REI"] })); + const benutzer = await benutzerAusOidcUebernehmen( + anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }), + ); + await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-leser", "lz-kunde-REI"] })); const bindungen = await testPrisma.benutzerKunde.findMany({ where: { benutzerId: benutzer.id }, @@ -227,7 +266,9 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => { it("bindet ohne Praefix niemanden ein", async () => { await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } }); - const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-kunde-MUS"] })); + const benutzer = await benutzerAusOidcUebernehmen( + anspruecke({ gruppen: ["lz-leser", "lz-kunde-MUS"] }), + ); expect(await testPrisma.benutzerKunde.count({ where: { benutzerId: benutzer.id } })).toBe(0); }); }); diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts index 64572ff..0383103 100644 --- a/tests/datenbankregeln.test.ts +++ b/tests/datenbankregeln.test.ts @@ -63,6 +63,12 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { ).toEqual([ "BausteinSchritt_unveraenderlich", "BausteinVersion_status_einbahn", + /* + * Zieht die Rolle mit, wenn eine Person geloescht wird (#035). Ohne ihn + * scheiterte das `ON DELETE SET NULL` des Fremdschluessels am CHECK, und + * das Loeschen eines Mitarbeiters waere unmoeglich statt folgerichtig. + */ + "Mitarbeiter_konto_mitziehen", "VorlagenAbschnitt_baustein_veroeffentlicht", "VorlagenAbschnitt_unveraenderlich", "VorlagenSchritt_unveraenderlich", @@ -116,6 +122,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { expect(vorhanden).toEqual([ "Anhang_groesse_positiv", "Anhang_pruefsumme_form", + /* + * Die Rolle MITARBEITER und ihre Zuordnung gehoeren zusammen, in beide + * Richtungen (#035). Ohne das waere ein MITARBEITER ohne Person moeglich — + * ein Recht, das sich auf niemanden bezieht, und eine Einschraenkung, die + * je nach Formulierung „nichts“ oder „alles“ bedeutet. + */ + "Benutzer_mitarbeiter_nur_bei_rolle", "Einstellung_einzelne_zeile", "Einstellung_smtp_passwort_verschluesselt", "Einstellung_smtp_port_bereich", diff --git a/tests/rollen.test.ts b/tests/rollen.test.ts index 79050fb..9629eaa 100644 --- a/tests/rollen.test.ts +++ b/tests/rollen.test.ts @@ -11,13 +11,20 @@ const ALLE_RECHTE = Object.keys(RECHTE) as Recht[]; * Test, der die Tabelle nur gegen sich selbst haelt, wuerde jede Aenderung mitmachen. */ const MATRIX: Record> = { - lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true }, - laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false }, - geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false }, - mitarbeiterBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false }, - vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false }, - archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false }, - verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false }, + lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true, MITARBEITER: false }, + selbstauskunft: { ADMIN: false, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: true }, + laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false }, + geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false, MITARBEITER: false }, + mitarbeiterBearbeiten: { + ADMIN: true, + REDAKTEUR: true, + TECHNIKER: true, + LESER: false, + MITARBEITER: false, + }, + vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false }, + archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false }, + verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false }, }; describe("Rechtematrix", () => { @@ -26,10 +33,12 @@ describe("Rechtematrix", () => { ); it("prueft jede Rolle gegen jedes Recht", () => { - // Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sieben Rechte. - expect(ALLE_ROLLEN).toHaveLength(4); - expect(ALLE_RECHTE).toHaveLength(7); - expect(faelle).toHaveLength(28); + // Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen, + // acht Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle + // oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035). + expect(ALLE_ROLLEN).toHaveLength(5); + expect(ALLE_RECHTE).toHaveLength(8); + expect(faelle).toHaveLength(40); }); it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => { @@ -62,10 +71,27 @@ describe("Rollen im Einzelnen", () => { expect(darf("REDAKTEUR", "archivVerwalten")).toBe(false); }); - it("gibt ADMIN alles", () => { - for (const recht of ALLE_RECHTE) { + it("gibt ADMIN alles – ausser der Selbstauskunft", () => { + for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) { expect(darf("ADMIN", recht), `ADMIN darf ${recht}`).toBe(true); } + /* + * Die eine Ausnahme, und sie ist Absicht (#035): `selbstauskunft` ist kein + * zusaetzliches Recht, sondern ein **Ersatz** fuer `lesen`. Ein ADMIN sieht + * ueber `lesen` ohnehin alles; ihm die Selbstauskunft zu geben waere ein + * zweiter Weg zu denselben Daten — und damit eine zweite Stelle, an der + * jemand die Einschraenkung auf die eigene Person vergessen kann. + */ + expect(darf("ADMIN", "selbstauskunft")).toBe(false); + }); + + it("laesst MITARBEITER genau eines und sonst nichts", () => { + expect(darf("MITARBEITER", "selbstauskunft")).toBe(true); + for (const recht of ALLE_RECHTE.filter((r) => r !== "selbstauskunft")) { + expect(darf("MITARBEITER", recht), `MITARBEITER darf ${recht} nicht`).toBe(false); + } + // Vor allem nicht `lesen`: das waere die Akteneinsicht, die es nicht sein soll. + expect(darf("MITARBEITER", "lesen")).toBe(false); }); it("behaelt das Zurueckholen aus dem Archiv beim ADMIN", () => { @@ -103,14 +129,21 @@ describe("Aufbau der Tabelle", () => { expect(Object.keys(ROLLEN_RANG).sort()).toStrictEqual([...ALLE_ROLLEN].sort()); }); - it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und LESER unten", () => { + it("gibt jeder Rolle einen eigenen Rang, ADMIN oben und MITARBEITER unten", () => { const raenge = ALLE_ROLLEN.map((rolle) => ROLLEN_RANG[rolle]); expect(new Set(raenge).size).toBe(ALLE_ROLLEN.length); expect(Math.max(...raenge)).toBe(ROLLEN_RANG.ADMIN); - expect(Math.min(...raenge)).toBe(ROLLEN_RANG.LESER); + expect(Math.min(...raenge)).toBe(ROLLEN_RANG.MITARBEITER); expect(ROLLEN_RANG.ADMIN).toBeGreaterThan(ROLLEN_RANG.REDAKTEUR); expect(ROLLEN_RANG.REDAKTEUR).toBeGreaterThan(ROLLEN_RANG.TECHNIKER); expect(ROLLEN_RANG.TECHNIKER).toBeGreaterThan(ROLLEN_RANG.LESER); + /* + * **Unter LESER.** Der Rang entscheidet in `staerksteRolle`, wer gewinnt, + * wenn jemand in mehreren abgebildeten Gruppen steht. Stuende MITARBEITER + * darueber, verdraengte eine Selbstauskunft-Gruppe die echte Rolle — und ein + * Techniker verlöre seine Rechte, weil er auch Mitarbeiter des Hauses ist. + */ + expect(ROLLEN_RANG.LESER).toBeGreaterThan(ROLLEN_RANG.MITARBEITER); }); }); diff --git a/tests/selbstauskunft.test.ts b/tests/selbstauskunft.test.ts new file mode 100644 index 0000000..6aa0ea3 --- /dev/null +++ b/tests/selbstauskunft.test.ts @@ -0,0 +1,533 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums"; +import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer"; +import type { OidcAnspruecke } from "@/server/auth/oidc"; +import { darf, startseite } from "@/server/auth/rollen"; +import { navigationFuer } from "@/lib/navigation"; +import { meineDaten, meineGeraete, meineLaeufeOhneGeraet } from "@/server/queries/selbstauskunft"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Mitarbeiter-Selbstauskunft (#035). + * + * Die wichtigste Zusage dieses Meilensteins ist eine **Verneinung**: eine + * Selbstauskunft ist keine Akteneinsicht. Geprueft wird sie hier nicht an der + * Seite, sondern an der **Antwort** des Lesemodells — eine Seite laesst sich + * umgehen, ein Lesemodell nicht. + */ + +const UMGEBUNG = { ...process.env }; + +function anspruecke(ueberschreiben: Partial = {}): OidcAnspruecke { + return { + sub: "anbieter-sub-mitarbeiter", + email: "maria.huber@firma.example", + emailBestaetigt: true, + name: "Maria Huber", + gruppen: [], + upn: "mhuber@firma.local", + ziel: "/", + ...ueberschreiben, + }; +} + +async function aufbau() { + const kunde = await testPrisma.kunde.create({ + data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, + }); + await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); + + const maria = await testPrisma.mitarbeiter.create({ + data: { + kundeId: kunde.id, + vorname: "Maria", + nachname: "Huber", + anzeigename: "Maria Huber", + upn: "mhuber@firma.local", + mail: "maria.huber@firma.example", + abteilung: "Buchhaltung", + titel: "Sachbearbeiterin", + }, + }); + const kollege = await testPrisma.mitarbeiter.create({ + data: { + kundeId: kunde.id, + vorname: "Ali", + nachname: "Demir", + anzeigename: "Ali Demir", + upn: "ademir@firma.local", + }, + }); + + const meinNotebook = await testPrisma.geraet.create({ + data: { kundeId: kunde.id, mitarbeiterId: maria.id, tagCode: "LZ-MEANBC", hostname: "NB-042" }, + }); + const fremdesNotebook = await testPrisma.geraet.create({ + data: { kundeId: kunde.id, mitarbeiterId: kollege.id, tagCode: "LZ-FREMDA", hostname: "NB-099" }, + }); + + const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); + const version = await testPrisma.vorlagenVersion.create({ + data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, + }); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Konto", position: 0 }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { + abschnittId: abschnitt.id, + titel: "Konto anlegen", + typ: "BEFEHL", + position: 0, + konfig: { + shell: "powershell", + befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}"', + }, + }, + }); + await testPrisma.vorlagenVersion.update({ + where: { id: version.id }, + data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, + }); + + const admin = await testPrisma.benutzer.create({ + data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, + }); + + /* Ein Lauf an **meinem** Geraet, mit Notiz, Kommentar und Anhang daran. */ + const meinLauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + kundeId: kunde.id, + titel: "PC Neuinstallation – NB-042", + geraetId: meinNotebook.id, + mitarbeiterId: maria.id, + status: LaufStatus.ABGESCHLOSSEN, + gestartetAm: new Date("2026-09-01T08:00:00Z"), + abgeschlossenAm: new Date("2026-09-02T15:00:00Z"), + }, + }); + const ergebnis = await testPrisma.schrittergebnis.create({ + data: { + laufId: meinLauf.id, + vorlagenSchrittId: schritt.id, + zustand: "ERLEDIGT", + notiz: "Nutzerin war unkooperativ, Gerät kam beschädigt zurück", + wert: "irgendein Wert", + aktualisiertVonId: admin.id, + }, + }); + await testPrisma.kommentar.create({ + data: { laufId: meinLauf.id, benutzerId: admin.id, rumpf: "Rückfrage ans Team wegen der Lizenz" }, + }); + await testPrisma.anhang.create({ + data: { + schrittergebnisId: ergebnis.id, + laufId: meinLauf.id, + kundeId: kunde.id, + dateiname: "schreibtisch.png", + medientyp: "image/png", + groesse: 10, + pruefsumme: "d".repeat(64), + }, + }); + + /* Ein Lauf am Geraet des Kollegen – darf nie auftauchen. */ + const fremderLauf = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + kundeId: kunde.id, + titel: "PC Neuinstallation – NB-099", + geraetId: fremdesNotebook.id, + mitarbeiterId: kollege.id, + status: LaufStatus.IN_ARBEIT, + gestartetAm: new Date(), + }, + }); + + /* Ein Lauf auf meine Person, ohne Geraet: das Onboarding. */ + const persoenlich = await testPrisma.lauf.create({ + data: { + vorlagenVersionId: version.id, + kundeId: kunde.id, + titel: "Onboarding Maria Huber", + mitarbeiterId: maria.id, + status: LaufStatus.ABGESCHLOSSEN, + gestartetAm: new Date("2026-08-01T08:00:00Z"), + abgeschlossenAm: new Date("2026-08-01T12:00:00Z"), + }, + }); + + return { kunde, maria, kollege, meinNotebook, fremdesNotebook, meinLauf, fremderLauf, persoenlich }; +} + +describe("Die Rolle MITARBEITER", () => { + it("darf genau die Selbstauskunft und sonst nichts", () => { + expect(darf("MITARBEITER", "selbstauskunft")).toBe(true); + expect(darf("MITARBEITER", "lesen")).toBe(false); + expect(darf("MITARBEITER", "laeufeBearbeiten")).toBe(false); + expect(darf("MITARBEITER", "geraeteBearbeiten")).toBe(false); + expect(darf("MITARBEITER", "mitarbeiterBearbeiten")).toBe(false); + expect(darf("MITARBEITER", "vorlagenBearbeiten")).toBe(false); + expect(darf("MITARBEITER", "archivVerwalten")).toBe(false); + expect(darf("MITARBEITER", "verwaltung")).toBe(false); + }); + + it("landet nach der Anmeldung auf der eigenen Seite und nicht auf „kein Zugriff“", () => { + /* + * Beim Durchklicken gefunden: das Standardziel nach der Anmeldung ist `/`, + * und die Uebersicht verlangt `lesen`. Ein MITARBEITER sah deshalb als + * Erstes „Dafuer fehlt dir die Berechtigung“ — genau die leere Seite, die + * dieses Issue vermeiden will. Dieselbe Funktion richtet auch den Knopf auf + * `/kein-zugriff`, der vorher im Kreis fuehrte. + */ + expect(startseite("MITARBEITER")).toBe("/meine-geraete"); + for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) { + expect(startseite(rolle), `${rolle} faengt auf der Uebersicht an`).toBe("/"); + } + }); + + it("sieht in der Navigation nur die eine Seite", () => { + /* + * „Alles andere ist weg, nicht grau“ — ein Eintrag, der auf + * `/kein-zugriff` fuehrt, ist schlimmer als keiner. + */ + const eintraege = navigationFuer("INTERN", (recht) => darf("MITARBEITER", recht)); + expect(eintraege.map((e) => e.pfad)).toEqual(["/meine-geraete"]); + }); + + it("nimmt den anderen Rollen die Selbstauskunft nicht weg – sie brauchen sie nicht", () => { + for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) { + const pfade = navigationFuer("INTERN", (recht) => darf(rolle, recht)).map((e) => e.pfad); + expect(pfade, `${rolle} sieht die Selbstauskunft nicht`).not.toContain("/meine-geraete"); + expect(pfade.length, `${rolle} sieht die gewoehnliche Navigation`).toBeGreaterThan(1); + } + }); +}); + +describe.runIf(datenbankVerfuegbar)("Was ein Mitarbeiter sieht", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + it("sieht die Läufe seiner Geräte und keine anderen", async () => { + const { maria, meinLauf, fremderLauf } = await aufbau(); + + const geraete = await meineGeraete(maria.id); + expect(geraete).toHaveLength(1); + expect(geraete[0]?.bezeichnung).toBe("NB-042"); + + const laufIds = geraete.flatMap((g) => g.laeufe.map((l) => l.id)); + expect(laufIds).toEqual([meinLauf.id]); + expect(laufIds).not.toContain(fremderLauf.id); + }); + + it("sieht die Läufe zu seiner Person ohne Gerät", async () => { + const { maria, persoenlich, meinLauf } = await aufbau(); + const ohne = await meineLaeufeOhneGeraet(maria.id); + expect(ohne.map((l) => l.id)).toEqual([persoenlich.id]); + // Der Lauf am Geraet steht schon unter dem Geraet – hier waere er doppelt. + expect(ohne.map((l) => l.id)).not.toContain(meinLauf.id); + }); + + it("sieht keine Schritte, Notizen, Kommentare oder Anhänge – geprüft über die Antwort", async () => { + /* + * Das Kernkriterium. Geprueft wird die **Antwort** und nicht die Seite: im + * Aufbau haengen an dem Lauf eine Notiz („Nutzerin war unkooperativ“), ein + * Kommentar, ein Anhang und ein Befehlsschritt mit + * `{{generiertes_passwort}}`. Nichts davon darf durchkommen. + */ + const { maria } = await aufbau(); + const antwort = JSON.stringify([ + await meineGeraete(maria.id), + await meineLaeufeOhneGeraet(maria.id), + await meineDaten(maria.id), + ]); + + expect(antwort).not.toContain("unkooperativ"); + expect(antwort).not.toContain("Rückfrage ans Team"); + expect(antwort).not.toContain("schreibtisch.png"); + expect(antwort).not.toContain("New-ADUser"); + expect(antwort).not.toContain("{{"); + expect(antwort).not.toContain("irgendein Wert"); + expect(antwort).not.toContain("ERLEDIGT"); + + // Und was durchkommen **soll**, kommt durch. + expect(antwort).toContain("PC Neuinstallation – NB-042"); + expect(antwort).toContain("ABGESCHLOSSEN"); + }); + + it("holt die verbotenen Felder nicht einmal aus der Datenbank", () => { + /* + * **Gefunden durch eine Gegenprobe, die gruen blieb.** Der Test unten prueft + * die Antwort, und die ist sicher: `laufBauen` setzt sie aus den erlaubten + * Feldern zusammen, also kommt auch bei einem zu weiten `select` nichts + * durch. Genau deshalb blieb er gruen, als im `select` versuchsweise + * `ergebnisse: { notiz, wert }` stand — waehrend der Kopfkommentar der Datei + * behauptet, das `select` hole es „gar nicht erst". + * + * Diese Behauptung braucht ihre eigene Deckung. Sie ist nicht bloss Zierde: + * ein `select`, das Notizen mitliest, zieht sie durch Protokolle und + * Fehlerberichte, und der naechste, der `laufBauen` erweitert, hat sie schon + * zur Hand. + */ + const quelle = readFileSync(join(process.cwd(), "src/server/queries/selbstauskunft.ts"), "utf8"); + for (const verboten of ["notiz", "wert", "ergebnisse", "kommentare", "anhaenge", "konfig"]) { + expect(quelle, `das Lesemodell darf "${verboten}" nicht abfragen`).not.toContain(`${verboten}: true`); + expect(quelle, `das Lesemodell darf "${verboten}" nicht einbinden`).not.toContain(`${verboten}: {`); + } + }); + + it("gibt je Lauf genau die erlaubten Felder heraus", async () => { + // Namentlich aufgezaehlt: ein zusaetzliches Feld im Lesemodell faellt hier + // auf und nicht erst, wenn es auf der Seite steht. + const { maria } = await aufbau(); + const geraete = await meineGeraete(maria.id); + const lauf = geraete[0]?.laeufe[0]; + expect(lauf).toBeDefined(); + expect(Object.keys(lauf ?? {}).sort()).toEqual([ + "abgeschlossenAm", + "begonnenAm", + "erstelltAm", + "id", + "status", + "titel", + "vorlagenName", + ]); + }); + + it("zeigt archivierte Geräte weiter – „mein altes Notebook“ ist die Frage", async () => { + const { maria, meinNotebook } = await aufbau(); + await testPrisma.geraet.update({ where: { id: meinNotebook.id }, data: { archiviert: true } }); + + const geraete = await meineGeraete(maria.id); + expect(geraete).toHaveLength(1); + expect(geraete[0]?.archiviert).toBe(true); + }); + + it("liefert die eigenen Stammdaten, aber keine fremden", async () => { + const { maria, kollege } = await aufbau(); + const daten = await meineDaten(maria.id); + expect(daten).toMatchObject({ anzeigename: "Maria Huber", abteilung: "Buchhaltung" }); + + // Die Funktion nimmt eine Kennung – mit der des Kollegen bekaeme man dessen + // Daten. Genau deshalb kommt sie aus der Sitzung und nie aus einer Eingabe. + const fremd = await meineDaten(kollege.id); + expect(fremd?.anzeigename).toBe("Ali Demir"); + }); + + it("gibt für jemanden ohne Geräte eine leere Liste und keinen Fehler", async () => { + const { kollege, fremdesNotebook } = await aufbau(); + await testPrisma.lauf.deleteMany({ where: { geraetId: fremdesNotebook.id } }); + await testPrisma.geraet.delete({ where: { id: fremdesNotebook.id } }); + + expect(await meineGeraete(kollege.id)).toEqual([]); + expect(await meineLaeufeOhneGeraet(kollege.id)).toEqual([]); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Zuordnung bei der Anmeldung", () => { + beforeEach(async () => { + await datenbankLeeren(); + process.env = { ...UMGEBUNG }; + // Zuordnung aktiv – sonst greift die Selbstauskunft absichtlich gar nicht. + process.env["OIDC_GROUP_ADMIN"] = "lz-admin"; + process.env["OIDC_GROUP_TECHNICIAN"] = "lz-technik"; + }); + + it("macht aus einer Anmeldung ohne Rollengruppe einen MITARBEITER", async () => { + const { maria } = await aufbau(); + + const benutzer = await benutzerAusOidcUebernehmen(anspruecke()); + + expect(benutzer.rolle).toBe("MITARBEITER"); + expect(benutzer.mitarbeiterId).toBe(maria.id); + }); + + it("ordnet unabhängig von Groß- und Kleinschreibung zu", async () => { + const { maria } = await aufbau(); + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ upn: "MHuber@Firma.Local" })); + expect(benutzer.mitarbeiterId).toBe(maria.id); + }); + + it("lässt eine echte Rollengruppe vorgehen und ordnet dann keine Person zu", async () => { + await aufbau(); + const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-technik"] })); + expect(benutzer.rolle).toBe("TECHNIKER"); + // Wer eine echte Rolle hat, sieht ueber `lesen` ohnehin alles – eine + // Zuordnung daneben waere ein zweiter Weg zu denselben Daten. + expect(benutzer.mitarbeiterId).toBeNull(); + }); + + it("weist ab, wenn es zu dem UPN keine Person gibt", async () => { + await aufbau(); + await expect(benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }))).rejects.toThrow( + AnmeldeFehler, + ); + + // Und kein halb angelegtes Konto hinterlassen. + expect(await testPrisma.benutzer.count({ where: { email: "maria.huber@firma.example" } })).toBe(0); + }); + + it("nennt den Grund, damit die Anmeldeseite etwas Hilfreiches sagen kann", async () => { + await aufbau(); + try { + await benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" })); + throw new Error("haette werfen muessen"); + } catch (fehler) { + expect(fehler).toBeInstanceOf(AnmeldeFehler); + expect((fehler as AnmeldeFehler).grund).toBe("keine-person"); + } + }); + + it("weist einen mehrdeutigen UPN ab und protokolliert ihn", async () => { + const { kunde } = await aufbau(); + // Zwei Personen mit demselben UPN – etwa zwei Mandanten derselben Firma. + const zweiter = await testPrisma.kunde.create({ data: { name: "Zweite GmbH", kuerzel: "ZWEI" } }); + await testPrisma.mitarbeiter.create({ + data: { + kundeId: zweiter.id, + vorname: "Maria", + nachname: "Huber", + anzeigename: "Maria Huber (zweite)", + upn: "mhuber@firma.local", + }, + }); + expect(kunde.id).toBeTruthy(); + + try { + await benutzerAusOidcUebernehmen(anspruecke()); + throw new Error("haette werfen muessen"); + } catch (fehler) { + expect((fehler as AnmeldeFehler).grund).toBe("upn-mehrdeutig"); + } + + const protokoll = await testPrisma.protokoll.findMany({ + where: { aktion: { contains: "mehrdeutig" } }, + }); + expect(protokoll).toHaveLength(1); + // Ohne Urheber: das Konto gibt es an der Stelle vielleicht noch nicht. + expect(protokoll[0]?.benutzerId).toBeNull(); + expect(JSON.stringify(protokoll[0]?.nachher)).toContain("mhuber@firma.local"); + }); + + it("ordnet keine deaktivierte Person zu", async () => { + const { maria } = await aufbau(); + await testPrisma.mitarbeiter.update({ where: { id: maria.id }, data: { aktiv: false } }); + + await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow(AnmeldeFehler); + }); + + it("greift gar nicht, solange keine Gruppen-Zuordnung eingerichtet ist", async () => { + /* + * Die Wache gegen ein Update, das Leute aussperrt. Ohne abgebildete Gruppen + * kann die Anwendung nicht unterscheiden, ob jemand Techniker oder + * Mitarbeiter ist — dann bleibt alles wie vor #035. + */ + delete process.env["OIDC_GROUP_ADMIN"]; + delete process.env["OIDC_GROUP_TECHNICIAN"]; + await aufbau(); + + const benutzer = await benutzerAusOidcUebernehmen(anspruecke()); + expect(benutzer.rolle).toBe("LESER"); + expect(benutzer.mitarbeiterId).toBeNull(); + }); + + it("stuft bei der nächsten Anmeldung um, wenn jemand eine Rollengruppe bekommt", async () => { + const { maria } = await aufbau(); + const erst = await benutzerAusOidcUebernehmen(anspruecke()); + expect(erst.rolle).toBe("MITARBEITER"); + expect(erst.mitarbeiterId).toBe(maria.id); + + const dann = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); + expect(dann.rolle).toBe("ADMIN"); + // Die Zuordnung muss **mit** verschwinden, sonst verletzt sie den CHECK. + expect(dann.mitarbeiterId).toBeNull(); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Was die Datenbank selbst festhält", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + it("lässt die Rolle MITARBEITER nicht ohne Zuordnung zu", async () => { + await expect( + testPrisma.benutzer.create({ + data: { email: "ohne@firma.example", name: "Ohne Person", rolle: "MITARBEITER" }, + }), + ).rejects.toThrow(); + }); + + it("lässt eine Zuordnung nicht an einer anderen Rolle zu", async () => { + const { maria } = await aufbau(); + await expect( + testPrisma.benutzer.create({ + data: { + email: "admin2@firma.example", + name: "Admin mit Person", + rolle: "ADMIN", + mitarbeiterId: maria.id, + }, + }), + ).rejects.toThrow(); + }); + + it("gibt eine Person an höchstens ein Konto", async () => { + const { maria } = await aufbau(); + await testPrisma.benutzer.create({ + data: { + email: "maria@firma.example", + name: "Maria Huber", + rolle: "MITARBEITER", + mitarbeiterId: maria.id, + }, + }); + await expect( + testPrisma.benutzer.create({ + data: { + email: "maria.zweitkonto@firma.example", + name: "Maria Huber (zweites Konto)", + rolle: "MITARBEITER", + mitarbeiterId: maria.id, + }, + }), + ).rejects.toThrow(); + }); + + it("zieht die Rolle mit, wenn die Person gelöscht wird", async () => { + /* + * `ON DELETE SET NULL` allein wuerde am CHECK scheitern: ein MITARBEITER + * ohne Zuordnung ist verboten. Der Trigger setzt deshalb beides zugleich — + * sonst waere das Loeschen eines Mitarbeiters unmoeglich statt folgerichtig. + */ + const { kunde } = await aufbau(); + const person = await testPrisma.mitarbeiter.create({ + data: { + kundeId: kunde.id, + vorname: "Weg", + nachname: "Damit", + anzeigename: "Weg Damit", + upn: "wdamit@firma.local", + }, + }); + const konto = await testPrisma.benutzer.create({ + data: { + email: "weg@firma.example", + name: "Weg Damit", + rolle: "MITARBEITER", + mitarbeiterId: person.id, + }, + }); + + await testPrisma.mitarbeiter.delete({ where: { id: person.id } }); + + const nachher = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: konto.id } }); + expect(nachher.rolle).toBe("LESER"); + expect(nachher.mitarbeiterId).toBeNull(); + // Das Konto bleibt – nur ohne Person. + expect(nachher.aktiv).toBe(true); + }); +}); diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts index 17ec797..1590c6d 100644 --- a/tests/zugang.test.ts +++ b/tests/zugang.test.ts @@ -176,6 +176,27 @@ describe("Rechtepruefung an den Einstiegspunkten", () => { expect(befunde).toEqual([]); }); + it("laesst keine Server Action auf dem Recht der Selbstauskunft stehen", () => { + /* + * Die Zusage „ein MITARBEITER kann nichts schreiben“ (#035) steht nicht in + * einer einzelnen Pruefung, sondern in der **Abwesenheit** einer Aktion mit + * diesem Recht. Das ist strukturell: `selbstauskunft` ist ein reines + * Leserecht, und sobald eine Server Action es verlangte, haette diese Rolle + * einen Schreibweg — ohne dass irgendein anderer Test rot wuerde. + * + * Deshalb hier und nicht in `tests/rollen.test.ts`: dort geht es um die + * Tabelle, hier um das, was mit ihr gebaut wird. + */ + const befunde: string[] = []; + for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) { + const quelle = readFileSync(join(verzeichnis, name), "utf8"); + if (quelle.includes('rechtErforderlich("selbstauskunft")')) { + befunde.push(`${name} verlangt "selbstauskunft" – das ist ein reines Leserecht`); + } + } + expect(befunde).toEqual([]); + }); + it("erkennt eine Aktion ohne jede Pruefung", () => { // Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er // trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.