feat(selbstauskunft): Mitarbeiter sehen ihre eigenen Geraete (#55)
„Was ist eigentlich mit meinem Notebook passiert?“ musste bisher ein Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen** Geraeten jetzt selbst ansehen — mit der fuenften Rolle MITARBEITER, zugeordnet beim Anmelden ueber den UPN. Was er sieht, sind Titel, Zeitraum und Status. Keine Schritte, keine Notizen, keine Kommentare, keine Anhaenge: in einem Lauf stehen Arbeitsnotizen ueber Menschen und Befehle mit Platzhaltern fuer Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das haelt nicht die Seite fest, sondern das Lesemodell — eine Seite, die filtert, ist eine Seite, die es vergessen kann. `selbstauskunft` ist ein **Ersatz** fuer `lesen` und kein Zusatz; auch ein ADMIN hat es nicht. Ein zweiter Weg zu denselben Daten waere eine zweite Stelle, an der jemand die Einschraenkung auf die eigene Person vergisst. Die Datenbank verlangt das sogar: ein CHECK haelt fest, dass Rolle und Zuordnung zusammengehoeren, in beide Richtungen. Ein mehrdeutiger UPN wird abgewiesen und protokolliert. Zwei Kunden koennen dieselbe Person fuehren; ein findFirst entschiede nach Sortierung darueber, wessen Geraete jemand sieht. **Verhaltensaenderung:** wer sich anmeldet und in keiner Rollengruppe steht, bekommt nicht mehr automatisch LESER — also nicht mehr Lesezugriff auf alle Laeufe des Hauses. Das greift nur mit konfigurierten OIDC_GROUP_*; ohne sie bleibt alles wie vorher, denn ohne abgebildete Gruppen laesst sich Techniker nicht von Mitarbeiter unterscheiden. Nebenbei behoben: `staerksteRolle` benutzte LESER als Startwert und damit als Untergrenze — mit einer Rolle darunter lieferte sie mehr Rechte als uebergeben. Gefunden von einem Test, der seit #007 unveraendert dasteht. Closes #55 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a417426f43
commit
0158d0292f
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
+70
-2
@@ -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.
|
||||
|
||||
@@ -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 <Rolle> 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.
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10">
|
||||
@@ -56,11 +58,16 @@ export default async function KeinZugriffSeite({
|
||||
Anmeldung übernommen.
|
||||
</p>
|
||||
|
||||
{/*
|
||||
* 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).
|
||||
*/}
|
||||
<Link
|
||||
href="/"
|
||||
href={einstieg}
|
||||
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
|
||||
>
|
||||
Zur Übersicht
|
||||
{einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<LaufStatus, string> = {
|
||||
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<LaufStatus, string> = {
|
||||
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 (
|
||||
<li className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-3 first:border-t-0">
|
||||
<span className="min-w-0 flex-1 break-words">{lauf.titel}</span>
|
||||
<span className="text-xs text-nebentext">{zeitraum(lauf)}</span>
|
||||
<span className={`text-xs font-semibold ${STATUS_FARBE[lauf.status]}`}>{STATUS_TEXT[lauf.status]}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Meine Geräte</h1>
|
||||
<p role="alert" className="mt-6 max-w-prose rounded-karte border border-fehler/40 bg-flaeche p-4">
|
||||
Dieses Konto ist keiner Person im Verzeichnis zugeordnet. Ein Administrator muss das einrichten —
|
||||
bis dahin gibt es hier nichts zu zeigen.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Meine Geräte</h1>
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
Die Geräte, die auf {daten?.anzeigename ?? "dich"} laufen, und was die IT daran gemacht hat.
|
||||
</p>
|
||||
|
||||
<p className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-3 text-sm text-nebentext">
|
||||
<Info className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||
<span>
|
||||
Hier stehen <strong>Titel, Zeitraum und Status</strong> einer Arbeit — nicht die einzelnen Schritte,
|
||||
Notizen oder Fotos. Wenn du wissen willst, was genau gemacht wurde, frag die IT.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{daten ? (
|
||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Meine Daten</h2>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
So steht es im Verzeichnis. Stimmt etwas nicht, meldet das die IT dem Personalbüro — ändern lässt
|
||||
es sich hier nicht.
|
||||
</p>
|
||||
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
|
||||
<dt className="text-nebentext">Name</dt>
|
||||
<dd>{daten.anzeigename}</dd>
|
||||
{daten.titel ? (
|
||||
<>
|
||||
<dt className="text-nebentext">Funktion</dt>
|
||||
<dd>{daten.titel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{daten.abteilung ? (
|
||||
<>
|
||||
<dt className="text-nebentext">Abteilung</dt>
|
||||
<dd>{daten.abteilung}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{daten.mail ? (
|
||||
<>
|
||||
<dt className="text-nebentext">E-Mail</dt>
|
||||
<dd className="break-all">{daten.mail}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{nichts ? (
|
||||
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||
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.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{geraete.length > 0 ? (
|
||||
<ul className="mt-6 flex flex-col gap-4">
|
||||
{geraete.map((geraet) => (
|
||||
<li key={geraet.id} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<Laptop
|
||||
className="size-[18px] shrink-0 self-center text-nebentext"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">{geraet.bezeichnung}</h2>
|
||||
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||
{GERAETE_TYP_TEXT[geraet.typ]}
|
||||
</span>
|
||||
{geraet.archiviert ? (
|
||||
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||
nicht mehr in Benutzung
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
|
||||
{geraet.seriennummer ? (
|
||||
<div>
|
||||
<dt className="inline">Seriennummer: </dt>
|
||||
<dd className="mono inline">{geraet.seriennummer}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt className="inline">Aufkleber: </dt>
|
||||
<dd className="mono inline">{geraet.tagCode}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{geraet.laeufe.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-nebentext">
|
||||
Zu diesem Gerät ist noch keine Arbeit festgehalten.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 text-sm">
|
||||
{geraet.laeufe.map((lauf) => (
|
||||
<LaufZeile key={lauf.id} lauf={lauf} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{ohneGeraet.length > 0 ? (
|
||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||
<h2 className="text-lg font-semibold">Zu mir persönlich</h2>
|
||||
<p className="mt-1 mb-2 max-w-prose text-sm text-nebentext">
|
||||
Arbeiten, die nicht an einem Gerät hängen — etwa das Einrichten deines Kontos.
|
||||
</p>
|
||||
<ul className="text-sm">
|
||||
{ohneGeraet.map((lauf) => (
|
||||
<LaufZeile key={lauf.id} lauf={lauf} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -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");
|
||||
|
||||
@@ -22,6 +22,23 @@ const MELDUNGEN = new Map<string, string>(
|
||||
"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.",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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<Response> {
|
||||
`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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Building2,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Laptop,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Menu,
|
||||
@@ -33,6 +34,7 @@ const SYMBOLE: Record<string, LucideIcon> = {
|
||||
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 (
|
||||
<div className="min-h-dvh md:flex">
|
||||
{/* Handy: Kopfzeile mit Menue */}
|
||||
@@ -191,7 +202,7 @@ export function Huelle({
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Bildmarke className="size-6 shrink-0 text-akzent" />
|
||||
<Wortmarke className="text-lg" />
|
||||
<ScannenKnopf className="ml-auto" />
|
||||
{darfScannen ? <ScannenKnopf className="ml-auto" /> : null}
|
||||
<details className="relative">
|
||||
<summary
|
||||
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
|
||||
@@ -217,7 +228,7 @@ export function Huelle({
|
||||
<Bildmarke className="size-7 shrink-0 text-akzent" />
|
||||
<Wortmarke className="text-xl" />
|
||||
</Link>
|
||||
<ScannenKnopf className="mb-6" />
|
||||
{darfScannen ? <ScannenKnopf className="mb-6" /> : null}
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||
</nav>
|
||||
|
||||
+28
-4
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ROLLEN_TEXT: Record<Rolle, string> = {
|
||||
[Rolle.REDAKTEUR]: "Redakteur",
|
||||
[Rolle.TECHNIKER]: "Techniker",
|
||||
[Rolle.LESER]: "Leser",
|
||||
[Rolle.MITARBEITER]: "Mitarbeiter (Selbstauskunft)",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,4 +25,11 @@ export const ROLLEN_RANG: Record<Rolle, number> = {
|
||||
[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,
|
||||
};
|
||||
|
||||
+125
-5
@@ -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<string> {
|
||||
* 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<Benutzer> {
|
||||
// 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(),
|
||||
},
|
||||
|
||||
+26
-3
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <Rolle> 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
|
||||
|
||||
@@ -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<readonly MeinGeraet[]> {
|
||||
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<readonly MeinLauf[]> {
|
||||
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<MeineDaten | undefined> {
|
||||
const zeile = await prisma.mitarbeiter.findUnique({
|
||||
where: { id: mitarbeiterId },
|
||||
select: { anzeigename: true, abteilung: true, titel: true, mail: true },
|
||||
});
|
||||
return zeile ?? undefined;
|
||||
}
|
||||
+49
-8
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
+48
-15
@@ -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<Recht, Record<Rolle, boolean>> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user