3 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 2d3325ef20 feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 17:22:20 +02:00
Florian WildeandClaude Opus 5 4aa79c93ff feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:36:10 +02:00
Florian WildeandClaude Opus 5 3cbd5026f8 feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am
Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter
ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen
nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes
selbst: der Route Handler ruft rechtErforderlich() selbst, weil die
Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber
eine Server Action statt ueber einen zweiten Endpunkt; der
Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere
am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt.

In der Datenbank halten zwei CHECK-Bedingungen und zwei
zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an
der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht
an einem Schritt eines anderen Laufs oder bei einem fremden Kunden
haengen.

docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein
Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach
der alten Anleitung haette ab hier still Daten verloren. Sicherung und
Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich.

Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder
"datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei"
waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel
liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts
laedt bewusst ueber den zweiten Knopf hoch.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 23:05:00 +02:00
129 changed files with 797 additions and 15067 deletions
+6 -160
View File
@@ -7,58 +7,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt
- **Vier-Augen-Freigabe.** Verlangt eine Vorlagen-Version sie, lässt sich ein Lauf erst abschließen,
wenn ein **Zweiter** ihn freigegeben hat — jemand mit dem neuen Recht `freigeben` (`ADMIN` und
`REDAKTEUR`, **nicht** `TECHNIKER`), der nicht an dem Lauf gearbeitet hat. Als gearbeitet zählt:
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein nicht.
**Auch ein Administrator kann sich nicht selbst freigeben** — das ist der ganze Sinn, und dieselbe
Regel hält ein CHECK in der Datenbank. Zurückweisen verlangt eine Begründung, setzt den Lauf
zurück auf *in Arbeit* und nimmt eine erteilte Freigabe zurück.
Sobald jemand abzuschließen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten
eine Mail — einmal, auch bei mehreren Versuchen. Das Häkchen steht im Editor beim Veröffentlichen
und wandert beim Bearbeiten in den neuen Entwurf mit (#040)
- **Bedingte Abschnitte und Schritte.** Ein Abschnitt oder ein einzelner Schritt erscheint nur,
wenn seine Bedingung zutrifft — abhängig von einer **Variable** des Laufs oder vom **Zustand
eines anderen Schritts**. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt beide
Konto-Abschnitte, fünf AD-Schritte und vier für Entra ID, und `Kontotyp` sagt beim Start, welcher
gilt. Bisher klickte ein Techniker die anderen neun bei jedem Onboarding weg.
**Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil** — hat niemand den Kontotyp
angegeben, stehen beide Abschnitte da, genau wie vorher. Ein ausgeblendeter Pflichtschritt hält
den Abschluss nicht auf, ausgeblendet heißt nicht gelöscht (eine Notiz bleibt stehen), und die
Nummerierung zählt nur, was man sieht. Ausgewertet wird **laufend**: ein Abschnitt „Nacharbeit“,
der erst erscheint, wenn der Restore-Test fehlschlägt, kommt bei einer offenen zweiten Sitzung
sofort dazu. Es gibt bewusst **kein** „und“ und kein „oder“ (#039)
### Behoben
- **Der Import einer Vorlage verwarf die Abteilungseinschränkung der Abschnitte.** Der Export
schreibt sie seit #031, das Schema nimmt sie an — der Import setzte sie nicht. Eine Vorlage, die
jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte: sie galten dort für
**alle**, also mehr Schritte statt weniger. Niemand hätte es gemerkt (#039)
- **„Bearbeiten“ einer Vorlage verlor ebenfalls alle Abteilungseinschränkungen.** Die tiefe Kopie,
aus der der Entwurf entsteht, nahm sie nicht mit — und `Vorlage duplizieren` genauso wenig, weil
beide die Kopie einzeln ausgeschrieben hatten. Ursache war ein von Hand getippter Zwischentyp, der
weniger Felder nannte als die Abfrage liefert; er ist jetzt aus Prisma abgeleitet, beide Wege
teilen eine Stelle, und ein Test vergleicht Kopie und Original Feld für Feld (#039)
- Die Auswahlfelder der neuen Bedingung trugen ihren Namen über ein umhüllendes `label`, wodurch
der zugängliche Name den Text der gewählten Option mitschluckte — für einen Screenreader eine
Bezeichnung, die sich beim Umschalten ändert (#039)
## [0.2.0] – 2026-10-11
### Geändert ### 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 - **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, 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 erscheint das beim anderen in weniger als einer halben Sekunde — gemessen 485 ms, vorher bis zu
@@ -83,75 +33,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne - Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
Begründung geht ins Protokoll des Prozesses (#025) Begründung geht ins Protokoll des Prozesses (#039)
- Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen — - Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen —
Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war
strenger formuliert als gemeint (#026) strenger formuliert als gemeint (#040)
- `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne - `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne
ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr, ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr,
und der Lauf sieht trotzdem grün aus (#026) und der Lauf sieht trotzdem grün aus (#040)
- Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010 - Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010
verlangt (#026) verlangt (#040)
### Behoben ### Behoben
- **Die Zusage „jede Seite und jeder Route Handler prüft sein Recht selbst“ wird jetzt geprüft.**
Sie steht seit #007 in den Konventionen und hielt bis hierher nur durch Nachsehen von Hand — das
gilt für den Tag, an dem es lief. `tests/zugang.test.ts` liest jetzt jede `page.tsx` und jeden
`route.ts`; die Ausnahmen (Auth, Healthcheck) stehen dort namentlich samt Begründung (#038)
- Das Lesemodell der Anhänge liegt jetzt in `queries/` und wendet seine Mandantensicht **selbst** an.
Vorher hing es daran, dass beide Aufrufer den Lauf vorher prüften — kein Schaden, aber die falsche
Form: der dritte Aufrufer ist der, der es vergisst (#037)
- `/meine-geraete` wird jetzt bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto. Die Seite
ist mit „am Handy kein horizontales Scrollen“ abgenommen worden — von Hand. Dazu ein Test, dass
dort kein Link in die Verwaltung angeboten wird (#037)
- **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ zeigte ins Leere.** Gebaut wurde
`/verwaltung/verzeichnis`; `verwaltung` ist aber der Name des Rechts, die Seite liegt unter
`/admin/verzeichnis`. Das ist die eine Meldung, die an alle Administratoren geht, und zwar im
Moment eines Ausfalls — der Empfänger klickte und landete auf der Nicht-gefunden-Seite. Ein Test
prüft jetzt jeden Pfad einer Meldung gegen die vorhandenen Routen (#036)
- **Ein offener Ereignisstrom überlebte Abmelden, Deaktivieren und Sitzungswiderruf.** Das Recht
wurde genau einmal geprüft, beim Aufbau des Stroms. Ein Administrator deaktivierte ein Konto, und
eine offen gelassene Lasche bekam weiter live Status und Schrittzustände dieses einen Laufs, bis
jemand sie schloss. Der Herzschlag prüft die Sitzung jetzt alle 25 Sekunden nach; eine
Herabstufung während des Stroms engt ihn ein, statt ihn zu beenden (#036)
- **Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt.** Ein Baustein-Abschnitt
bekommt seine Schritte erst beim Veröffentlichen; wer eine noch unveröffentlichte Vorlage in eine
zweite Installation brachte, fand den geteilten Abschnitt dort unter dem Namen „wird ersetzt“ und
ohne einen einzigen Schritt. Beim Export werden Bausteine jetzt aufgelöst (#036)
- **`docs/betrieb/sicherung.md` nannte für den nativen Weg einen Anhänge-Pfad, den es nirgends
gibt** — als Rückfallwert in einem Skript, in dem ein `rm -rf` steht. Auf einer Installation ohne
`ANHAENGE_PFAD` in der Env-Datei wäre in ein Verzeichnis zurückgespielt worden, das die Anwendung
nie liest: „Rücksicherung gelaufen“, und danach fehlt jede Datei. Jetzt bricht der Befehl ab,
statt zu raten (#036)
- **`README.md` kannte die Rolle `MITARBEITER` nicht** und behauptete weiter, unbekannte Benutzer
würden beim ersten Login angelegt. Seit #035 wird jemand ohne Rollengruppe über seinen UPN
zugeordnet oder abgewiesen. Wer auf v0.2.0 hebt und nur das README liest, hätte genau diese Leute
ausgesperrt (#036)
- **Der Protokolleintrag „Anmeldung abgewiesen: UPN mehrdeutig“ nannte immer zwei Treffer**, auch bei
sieben. Wer das Protokoll liest, um den Fall aufzulösen, braucht die wirkliche Zahl (#036)
- **`anzahlSchritte` im Protokoll eines neuen Laufs zählte die Schritte der Version**, nicht die des
Laufs — bei einer Vorlage mit abteilungsgebundenen Abschnitten also zu viele (#036)
- Die Attributzuordnung einer Verzeichnisquelle wird jetzt **im Dienst** geprüft und nicht nur im
Formular. Ein Attributname mit Komma und `$filter` kam zuvor durch und schrieb einen zweiten
`$filter` in die Graph-Abfrage (#036)
- Der Graph-Seitenabruf folgt `@odata.nextLink` nur noch, wenn die Adresse auf denselben Host zeigt
wie `GRAPH_API_URL`. Jede Seite wird mit einem mandantenweit gültigen Token geholt (#036)
- **Die Ende-zu-Ende-Abläufe räumen auf, was sie anlegen** — zentral, vor und nach dem Lauf. In der
Entwicklungsdatenbank standen 105 Vorlagen aus früheren Durchgängen. Ein struktureller Test
schlägt an, wenn ein neuer Ablauf das Präfix nicht benutzt (#027)
- Vier Ende-zu-Ende-Abläufe waren seit #020, #030, #031 beziehungsweise #033 rot, ohne dass es
auffiel: `pnpm test:e2e` steht in keinem Tor. Alle vier behoben, die Lücke ist als #038 benannt
(#027)
- Der Verbindungstest einer Verzeichnisquelle unterschied „der Filter findet niemanden“ nicht mehr
von „gelesen, aber nichts zuordenbar“ — zwei verschiedene Ursachen, die den Administrator an
verschiedene Stellen schicken. Die Unterscheidung war in #028 eingebaut worden, aber durch eine
doppelte Bedingung seit dem ersten Tag unerreichbar (#029)
- **`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 - **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 `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 wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der
@@ -160,53 +53,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- **SCIM 2.0: Personen können jetzt auch geschickt werden, statt geholt zu werden.** Bisher
verbindet sich Laufzettel nach Zeitplan mit einem Verzeichnis und liest. Das setzt zwei Dinge
voraus, die nicht überall gegeben sind: Lesezugriff auf das Verzeichnis, und ein Netzweg dorthin.
Mit der neuen Quellenart `SCIM` ruft stattdessen der Identitätsanbieter Laufzettel an — authentik,
Entra ID, Okta — und zwar in dem Moment, in dem sich bei ihm etwas ändert, nicht erst beim
nächsten Intervall.
Umgesetzt ist, was die Anbieter wirklich schicken: `Users` mit dem Filter `userName eq "…"`,
`POST`, `GET`, `PUT`, `PATCH`, `DELETE`, dazu `ServiceProviderConfig`, `Schemas` und
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen. Übernommen
werden Anmeldename, Name, Mailadresse, Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv
ist; keine Gruppen, keine Rollen, keine Passwörter.
**`DELETE` deaktiviert und löscht nicht.** Der Anbieter bekommt `204` und hält die Person damit
für entfernt — aus seiner Sicht richtig. In Laufzettel bleibt sie inaktiv stehen, weil Läufe und
Geräte an ihr hängen. Das steht in der Anleitung, weil es sonst wie ein Fehler wirkt.
**Eine SCIM-Quelle wird nicht abgeglichen**, und das ist keine Kosmetik: ein Abgleich, der dort
nichts liest und „Erfolg“ meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert.
Verbindungstest und „Jetzt abgleichen“ gibt es für sie deshalb nicht; an ihrer Stelle steht die
letzte Zustellung.
Das **Token** wird unter Admin → Verzeichnis erzeugt, **einmal** angezeigt und nur als
argon2-Hash gespeichert — es gibt keinen zweiten Weg, es zu lesen. Es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt: ein Token sieht nur die Personen seiner eigenen
Quelle. Einrichtung in authentik und Entra ID steht in
[Kapitel 06](docs/anleitung/06-verzeichnis.md#scim-der-anbieter-schickt), der Betrieb in
[`docs/betrieb/scim.md`](docs/betrieb/scim.md) (#029)
- **Entra ID als Verzeichnisquelle, über Microsoft Graph.** Ein Haus ohne lokales Active Directory
hat kein LDAP, auf das sich zeigen ließe. Eine Verzeichnisquelle hat jetzt eine **Art** — `LDAP`
oder `GRAPH` —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren,
deaktivieren, **nie löschen**. Nur das Holen der Einträge unterscheidet sich, sonst drifteten die
Regeln auseinander.
Gelesen wird mit dem Client-Credentials-Flow über `GET /v1.0/users`, seitenweise über
`@odata.nextLink`, der Vorgesetzte über `$expand=manager($select=id)`. Das Client-Secret wird
verschlüsselt gespeichert wie das LDAP-Bind-Passwort und erscheint in keiner Antwort und in keinem
Protokolleintrag.
**Zwei Platzhalter bleiben bei Graph bewusst leer:** `{{ou}}` (Entra ID hat keine OU — steht an der
Abteilung eine Ziel-OU, greift die) und `{{sam}}` ohne AD-Synchronisierung. Was in Entra ID
einzurichten ist, steht in `docs/betrieb/entra-id.md` — mit einer Prüfliste, denn gegen einen
echten Mandanten ist dieser Weg **nicht** gelaufen (#028)
- **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 - **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 — die Übersicht öffnete; ein überfälliger überhaupt nicht. Vier Anlässe gehen jetzt hinaus —
**zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je **zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je
@@ -274,9 +120,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen
(#021) (#021)
- `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von - `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von
beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#026) beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#040)
- Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll - Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll
geschrieben und nicht bloß verschluckt wird (#025) geschrieben und nicht bloß verschluckt wird (#039)
## [0.1.0] – 2026-09-26 ## [0.1.0] – 2026-09-26
+3 -75
View File
@@ -40,16 +40,15 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` | | — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
| — (ergänzt) | `Zustellung` | | | | — (ergänzt) | `Zustellung` | | |
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` · Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` · `LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` · `SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` ·
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) · `SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` · `VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · `GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` · `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
`QuellenArt {LDAP GRAPH SCIM}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` · `Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN LAUF_FREIGABE_OFFEN ABGLEICH_FEHLGESCHLAGEN}`. `Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias. `SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
@@ -77,29 +76,15 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`. Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind. - **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz. `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
- **Vier-Augen-Freigabe:** Verlangt eine `VorlagenVersion` sie (`freigabePflicht`), lässt sich ein
Lauf erst nach der Freigabe abschließen (#040). **Wer daran gearbeitet hat, gibt nicht frei** —
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein zählt nicht. Das prüft
`hatDaranGearbeitet` an genau einer Stelle, wie `laufMussVeraenderbarSein`, und ein CHECK hält die
wichtigste Hälfte (der Ersteller) auch gegen Schreiben an der Dienstschicht vorbei. Das Recht
`freigeben` hat **nicht** `TECHNIKER`: sonst hängt die Regel daran, dass zufällig ein zweiter
greifbar ist. Die Pflicht wandert bei „Bearbeiten“ und „Duplizieren“ in die Kopie mit.
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus - **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. - **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. 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, **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. nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben Layouts nicht neu.
**Route Handler deckt die Hülle nicht ab**; alles unter `src/app/api/`, das mehr tut als Auth **Route Handler deckt die Hülle nicht ab**; alles unter `src/app/api/`, das mehr tut als Auth
und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur und Healthcheck, ruft selbst `rechtErforderlich()`. Der Proxy prüft das Cookie absichtlich nur
auf Vorhandensein, nie inhaltlich. auf Vorhandensein, nie inhaltlich.
Beides prüft `tests/zugang.test.ts` seit #036 **strukturell**: es liest jede `page.tsx` und jeden
`route.ts` und schlägt an, wenn eine Datei nichts prüft. Die Ausnahmen stehen dort namentlich,
samt Begründung — ein neuer Handler ohne Prüfung soll jemanden zwingen, sie aufzuschreiben.
Vorher hielt die Zusage nur durch Nachsehen von Hand, und das gilt für den Tag, an dem es lief.
- **Server-Module gehören nicht ins Browser-Bündel.** Eine Client-Komponente importiert nichts aus - **Server-Module gehören nicht ins Browser-Bündel.** Eine Client-Komponente importiert nichts aus
`src/server/*`. Was beide Seiten brauchen (Beschriftungen, Formate), liegt in `src/lib` — `src/server/*`. Was beide Seiten brauchen (Beschriftungen, Formate), liegt in `src/lib` —
`ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig `ROLLEN_TEXT` etwa in `src/lib/rollen-text.ts`, während `RECHTE` und `darf()` serverseitig
@@ -112,16 +97,6 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
gedruckte Aufkleber wertlos. gedruckte Aufkleber wertlos.
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in - **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht. `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
- **Bedingte Teile:** Eine Bedingung an `VorlagenAbschnitt` oder `VorlagenSchritt` sagt, wann der
Teil gilt (#039). **Nicht entscheidbar heißt: gilt** — ein Abschnitt zu viel ist sichtbar und
korrigierbar, ein fehlender fällt niemandem auf. Eine Bedingung, kein Ausdrucksbaum: kein `und`,
kein `oder`. Die Auswertung sitzt in `src/lib/bedingungen.ts` und wird von **drei** Schichten
gerufen (Abschlussregel, Abarbeitung, Druck); `geltendeStruktur` legt die Reihenfolge der zwei
Filter fest — erst die beim Start festgeschriebene Abteilungsauswahl aus #031, dann die laufende
Auswertung. `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine Schicht sie vergisst.
**Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Austausch, Import, Seed und
`entwurfSpeichern` schreiben eine Struktur neu und geben neue Kennungen; ein Titel-Pfad überlebt
das. `bedingungAufloesen` ist für alle vier dieselbe Stelle.
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt - **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf `Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
@@ -210,53 +185,6 @@ Wer eine Oberfläche abnimmt, klickt sie im gebauten Stand durch — hinzufügen
speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht. speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
`pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand. `pnpm docs:screenshots` verlangt deshalb ebenfalls den gebauten Stand.
## Die Ende-zu-Ende-Abläufe laufen vor jedem Merge nach `main`
```bash
pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start &
E2E_APP_URL=http://127.0.0.1:3300 pnpm test:e2e
```
**Nicht im Commit-Tor, und das ist eine Rechnung, keine Bequemlichkeit.** Gemessen am 2026-10-11:
Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen 4 min 37 s, zu den 4 min des Commit-Tors. Neun
Minuten vor jedem Commit ist ein Tor, das umgangen wird, und ein umgangenes Tor prüft nichts.
**Vor jedem Merge ist die Regel, weil sie gereicht hätte.** In Phase 2 waren das acht Läufe. Beim
ersten vollständigen Durchgang nach Monaten waren **sieben Abläufe rot** (#027): ein Test, der die
Felder des Laufzustands aufzählt und `anzahlAnhaenge` nicht kannte (seit #033); ein Selektor, der
seit #020 zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (seit #030/#031); und
Entwicklungsdaten, die vom Seed abwichen. Keiner davon war ein Produktfehler — und jeder wäre am Tag
seiner Entstehung aufgefallen.
**Diese Zusagen stehen ausschließlich dort**, und keine davon lässt sich nach Vitest holen: sie
brauchen einen echten Browser.
| Zusage | Warum nur im Browser |
|---|---|
| Kein horizontales Scrollen bei 390 px | verlangt Layout, nicht Logik |
| Scannen über `BarcodeDetector` | Browser-API samt Polyfill |
| Sortieren per Finger | Touch-Ereignisse und `dnd-kit` |
| Zwei Sitzungen sehen einander live | zwei Browser-Kontexte gleichzeitig |
| Rückfall auf Polling ohne `EventSource` | das Client-Verhalten; der Handler-Teil steht in Vitest |
| „Alle Befehle kopieren“ zeichengenau | Zwischenablage |
| Druckansicht und Etiketten | Print-CSS |
| Eine Rolle sieht keine Schreib-Knöpfe | gerendertes DOM |
| Dass überhaupt hydriert wird | der Grund für „gebauter Stand“, siehe oben |
**Was sich holen ließ, ist geholt.** Die Rechteprüfung der Route Handler steht seit #036 in
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts` — die Handler
werden mit echten `Request`-Objekten gefahren. Dazu prüft `tests/zugang.test.ts` **strukturell**,
dass jede Seite und jeder Route Handler sein Recht selbst prüft; die Ausnahmen (Auth und
Healthcheck) stehen dort namentlich. Vorher hielt diese Zusage nur durch Nachsehen von Hand.
Die 35 übersprungenen Abläufe sind die modusabhängigen: sie laufen im jeweils anderen
`Betriebsmodus` und sagen das beim Überspringen.
**Die Abläufe räumen auf, was sie anlegen** (#027). Alles trägt `ZZ` am Anfang;
`tests/e2e/aufraeumen.ts` greift daran, **vor und nach** dem Lauf. Der Nachlauf fängt Testfehler und
Zeitabläufe, der Vorlauf fängt `Strg+C` — ein Vorlauf lässt sich nicht überspringen. Er sagt laut,
wenn er einen Rückstand findet.
## Vor jedem Commit ## Vor jedem Commit
```bash ```bash
@@ -274,7 +202,7 @@ geprüft. In CI bricht sie stattdessen ab, absichtlich.
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ | | `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**, `ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
nicht geschätzt — in #026 hätte beinahe eine geratene in dieser Datei gestanden. nicht geschätzt — in #040 hätte beinahe eine geratene in dieser Datei gestanden.
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert `format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+7 -40
View File
@@ -4,15 +4,15 @@ Checklisten für wiederkehrende IT-Arbeiten. Versionierte Vorlagen, daraus gesta
Team abgearbeitet, abgeschlossen und unveränderlich archiviert — mit Historie je Gerät und je Team abgearbeitet, abgeschlossen und unveränderlich archiviert — mit Historie je Gerät und je
Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, loslegen. Mitarbeiter. Einstieg über QR-Aufkleber: Code scannen, Geräteseite öffnen, loslegen.
> **Stand:** `v0.1.0` ist veröffentlicht, Phase 2 liegt auf `main` und wird `v0.2.0`. Der > **Stand:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in
> Fortschritt steht in [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im > [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
> [Entwicklungstagebuch](docs/devlog.md). > [Entwicklungstagebuch](docs/devlog.md).
## Zwei Betriebsmodi ## Zwei Betriebsmodi
| Modus | Für wen | Kunden-Ebene | Verzeichnis | | Modus | Für wen | Kunden-Ebene | Verzeichnis |
|---|---|---|---| |---|---|---|---|
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes Verzeichnis per LDAP, Microsoft Graph oder SCIM | | `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes AD per LDAP |
| `DIENSTLEISTUNG` | IT-Dienstleister mit mehreren Kunden | sichtbar | manuell oder per CSV je Kunde | | `DIENSTLEISTUNG` | IT-Dienstleister mit mehreren Kunden | sichtbar | manuell oder per CSV je Kunde |
Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene
@@ -158,48 +158,15 @@ zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.
| `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf | | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen; alles was `TECHNIKER` darf |
| `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen | | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `LESER` | nur lesen | | `LESER` | nur lesen |
| `MITARBEITER` | **nur die eigenen Geräte und deren Läufe** — ohne Schritte, Notizen und Kommentare |
**`MITARBEITER` hat keine eigene Gruppe, und das ist Absicht** (seit #035). Wer sich anmeldet und in
keiner der vier abgebildeten Gruppen steht, bekommt **nicht mehr** `LESER` — das hieße Lesezugriff
auf alle Läufe des Hauses. Stattdessen 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.
Das greift **nur, wenn Rollengruppen konfiguriert sind** — ohne `OIDC_GROUP_*` 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. Einzelheiten in
[`docs/betrieb/authentik.md`](docs/betrieb/authentik.md).
Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md) Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md)
Schritt für Schritt beschrieben. Schritt für Schritt beschrieben.
## Verzeichnis (nur Modus `INTERN`) ## Verzeichnis-Abgleich (nur Modus `INTERN`)
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
sich in der **Richtung**: `ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
optional ein eigener Filter. Ein Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis.
| Art | Für wen | Richtung |
|---|---|---|
| `LDAP` | lokales Active Directory oder anderes LDAP-Verzeichnis | Laufzettel holt nach Zeitplan |
| `GRAPH` | Entra ID über Microsoft Graph, für Häuser ohne lokales AD | Laufzettel holt nach Zeitplan |
| `SCIM` | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt, sofort |
**Die Art ist unveränderlich.** An ihr hängt, was die dauerhafte Kennung einer Person bedeutet;
nach einem Wechsel würde niemand wiedererkannt. Alle drei verhalten sich danach gleich: anlegen,
übernehmen, aktualisieren, deaktivieren, **nie löschen**.
Für **LDAP** gehören ins Formular: Adresse (`ldaps://` empfohlen, `ldap+starttls://` und `ldap://`
gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und optional ein eigener Filter. Ein
Dienstkonto genügt — Laufzettel schreibt nie ins Verzeichnis. Für **Graph** eine App-Registrierung
mit der Anwendungsberechtigung `User.Read.All`, siehe `docs/betrieb/entra-id.md`.
Für **SCIM** gibt es keine Zugangsdaten: Quelle anlegen, Token erzeugen (es erscheint **einmal**,
gespeichert wird nur ein argon2-Hash), Token und die Adresse `…/api/scim/v2` beim Anbieter
eintragen. Verbindungstest, Zeitplan und Abgleich-Log gibt es dort nicht — an ihrer Stelle steht
die letzte Zustellung. Ein `DELETE` des Anbieters **deaktiviert** die Person und löscht nichts.
Einzelheiten in `docs/betrieb/scim.md`.
- **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**, - **„Verbindung testen“** liest die ersten Einträge und zeigt sie **mit den gemappten Feldern**,
ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt. ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
-52
View File
@@ -242,58 +242,6 @@ Knopf gesperrt und darunter steht, was fehlt.
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie. Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
## Vier-Augen-Freigabe
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
Restore-Test, das Löschen eines Kontos. Verlangt die Vorlage eine Freigabe, steht das oben am Lauf —
und **Lauf abschließen** wartet, bis jemand sie erteilt hat.
### Wer freigeben darf
Es braucht zweierlei, und beides zusammen:
1. das Recht **freigeben** — das haben `ADMIN` und `REDAKTEUR`, **nicht** `TECHNIKER`;
2. und man darf **nicht an dem Lauf gearbeitet** haben.
Als „gearbeitet“ zählt: ihn angelegt, einen Schritt gesetzt oder ihn abgeschlossen zu haben. Nur
**zugewiesen** zu sein zählt nicht — wer zugewiesen wurde und nichts getan hat, darf freigeben.
**Auch ein Administrator kann sich nicht selbst freigeben.** Das ist der ganze Sinn, und es ist eine
bewusste Härte: wer allein im Haus ist, kommt damit nicht weiter und braucht einen Zweiten mit dem
Recht. Dieselbe Regel hält auch die Datenbank fest, nicht nur die Anwendung.
### Freigeben oder zurückweisen
Oben am Lauf steht eine Karte mit einem Feld für die **Begründung** und zwei Knöpfen:
- **Freigeben** — die Begründung ist freiwillig. Danach lässt sich der Lauf abschließen.
- **Zurückweisen** — die Begründung ist **Pflicht**. Der Lauf geht zurück auf *in Arbeit*, ein
bereits erteilter Abschluss wird aufgehoben, und der Bearbeiter sieht die Begründung an derselben
Stelle. Ohne Begründung steht er vor derselben Frage wie vorher.
Eine Zurückweisung nimmt auch eine **erteilte** Freigabe zurück. Damit lässt sich ein Lauf, der
schon abgeschlossen war, in die Arbeit zurückholen — ohne `archivVerwalten`, solange er nicht
archiviert ist.
### Wer erfährt davon
Sobald jemand den Lauf abzuschließen versucht und die Freigabe fehlt, bekommen die
**Freigabeberechtigten** eine Mail — nicht der Zuständige, wie bei allen anderen Meldungen. Wer an
dem Lauf gearbeitet hat, bekommt sie nicht: er dürfte ohnehin nicht freigeben.
Das geht **einmal** hinaus, auch wenn jemand es mehrmals versucht. Nach einer Zurückweisung wird
nicht erneut gemahnt — der Zurückweisende weiß, dass er wartet.
### Einschalten
Das Häkchen steht im Editor beim **Veröffentlichen**: „Läufe dieser Version brauchen eine Freigabe
durch einen Zweiten“. Es gehört zur **Version**, nicht zur Vorlage — ein laufender Lauf kann seine
Regel also nicht unter den Füßen verlieren, und in der Versionsliste steht an jeder Fassung, ob sie
eine Freigabe verlangt.
Beim Bearbeiten wandert das Häkchen in den neuen Entwurf mit. Eine Vorlage, die eine Freigabe
verlangt, verliert sie also nicht durch eine Textkorrektur.
## Archivieren — das ist endgültig ## Archivieren — das ist endgültig
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein **archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
-67
View File
@@ -219,73 +219,6 @@ Import: ein Abteilungsname bedeutet in einer anderen Installation dasselbe.
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts **Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist. abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
## Bedingte Abschnitte und Schritte
Die Onboarding-Vorlage enthält **beide** Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit
fünf Schritten und „Konto anlegen (Entra ID)“ mit vier. In jedem einzelnen Lauf gilt nur einer, und
die Variable **Kontotyp** sagt beim Start, welcher. Bis hierher klickte ein Techniker die anderen
neun Schritte weg.
Mit einer **Bedingung** erscheint ein Abschnitt oder ein einzelner Schritt nur dann, wenn er
zutrifft. Im Editor steht an jedem Abschnitt und an jedem Schritt **Bedingung (optional)**; ohne
eine gilt er immer.
### Zwei Arten, und die Wahl entscheidet, wann die Antwort entsteht
| Die Bedingung hängt an … | Beispiel | Wann sie entschieden wird |
|---|---|---|
| einer **Variable** | „gilt, wenn Kontotyp gleich Active Directory ist“ | beim Start, wenn die Variablen erfasst werden |
| einem **Schritt** | „gilt, wenn Prüfen fehlgeschlagen ist“ | während der Arbeit, sobald jemand den Schritt setzt |
Die zweite ist die interessantere: ein Abschnitt „Nacharbeit“, der erst erscheint, wenn der
Restore-Test fehlgeschlagen ist. Er ist zu Beginn nicht da und kommt in dem Moment dazu, in dem der
Schritt auf „fehlgeschlagen“ geht — bei einer offenen zweiten Sitzung sofort.
### Eine Bedingung, nicht mehr
Es gibt **kein** „und“ und kein „oder“. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte oder
zwei Vorlagen. Das ist Absicht: ein Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre
eine kleine Programmiersprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
schlimmer als einer zu viel.
Bei einer Schritt-Bedingung lassen sich dagegen **mehrere Zustände** ankreuzen: „erledigt oder nicht
zutreffend“ ist eine Frage, nicht zwei.
### Vier Dinge, die man dazu wissen muss
- **Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil.** Hat niemand den Kontotyp
angegeben, stehen **beide** Konto-Abschnitte da — genau wie vorher. Auszublenden hieße, eine
Antwort zu erfinden, die niemand gegeben hat. Ein Abschnitt zu viel ist sichtbar und
korrigierbar; ein fehlender fällt niemandem auf.
- **Ein ausgeblendeter Pflichtschritt blockiert den Abschluss nicht** — dieselbe Regel wie bei den
Abteilungsabschnitten. Er steht nicht in der Abarbeitung, also lässt er sich nicht abhaken.
- **Ausgeblendet heißt nicht gelöscht.** Hat jemand in einem Schritt eine Notiz hinterlassen und
kippt dessen Bedingung danach, verschwindet der Schritt aus der Ansicht — die Notiz bleibt. Kippt
die Bedingung zurück, ist alles wieder da.
- **Die Nummerierung zählt, was man sieht.** Ein ausgeblendeter Abschnitt hinterlässt keine Lücke;
aus `01 · 02 · 03` wird nicht `01 · 03`.
### Die Warnung „dieser Schritt kommt später“
Zeigt eine Bedingung auf einen Schritt, der **nach** dem bedingten Teil steht, warnt der Editor.
Verboten ist es nicht, aber fast immer ein Versehen: der Abschnitt wäre beim Abarbeiten zunächst
unsichtbar und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also rückwärts.
Gemeint ist meist ein Schritt davor.
### Export und Import
Eine Bedingung wandert mit. Im Austauschdokument steht sie über **Titel** statt über Kennungen
(`"abschnitt": "Prüfung", "schritt": "Prüfen"`), denn eine Kennung bedeutet in einer anderen
Installation nichts. Beim Import wird daraus wieder der richtige Schritt.
Findet sich der Titel-Pfad in der Datei nicht — etwa nach einem Tippfehler in einer von Hand
geschriebenen Vorlage —, wird die Bedingung **weggelassen**. Der Abschnitt gilt dann immer, und das
steht sichtbar im Editor. Die ganze Vorlage deswegen abzuweisen wäre die teurere Antwort.
**Ein Baustein-Schritt hat keine Bedingung.** Ein Baustein ist wiederverwendbar, und eine Bedingung
auf einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht entscheidbar. Der
Abschnitt, in dem der Baustein eingesetzt wird, darf eine haben.
## Bausteine: ein Abschnitt für mehrere Vorlagen ## Bausteine: ein Abschnitt für mehrere Vorlagen
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
-143
View File
@@ -16,141 +16,6 @@ Zwei Dinge vorweg:
Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird
nie, sonst verlöre die Historie ihre Namen. nie, sonst verlöre die Historie ihre Namen.
## Drei Arten von Quelle
Laufzettel braucht die Personen aus einem Verzeichnis. Dafür gibt es drei Wege, und du wählst ihn
beim Anlegen der Quelle:
| Art | Für wen | Richtung |
|---|---|---|
| **LDAP** | ein lokales Active Directory (oder ein anderes LDAP-Verzeichnis) | Laufzettel holt |
| **Microsoft Graph** | Entra ID, also Microsoft 365 — für Häuser ohne lokales AD | Laufzettel holt |
| **SCIM** | authentik, Entra ID, Okta — wenn Laufzettel nicht ins Verzeichnis sehen soll oder es nicht erreicht | der Anbieter schickt |
Die ersten zwei sind dieselbe Arbeitsweise mit anderen Zugangsdaten: Laufzettel verbindet sich nach
Zeitplan und liest. **SCIM ist die andere Richtung** — dort gibt es keine Zugangsdaten, keinen
Zeitplan und keinen Verbindungstest; der Anbieter schickt jede Änderung in dem Moment, in dem sie
dort entsteht. Für SCIM gibt es [einen eigenen Abschnitt](#scim-der-anbieter-schickt) weiter unten.
Alle drei verhalten sich danach gleich: anlegen, aktualisieren, deaktivieren, **nie löschen**. Was
LDAP und Graph unterscheidet, sind die Zugangsdaten und zwei Platzhalter:
- **`{{ou}}` bleibt bei Graph leer.** Entra ID hat keine OU. Steht an der Abteilung eine Ziel-OU
gepflegt, nimmt Laufzettel die; sonst bleibt der Platzhalter stehen.
- **`{{sam}}` ist bei Graph leer**, wenn der Mandant kein lokales AD synchronisiert — dort gibt es
keinen Anmeldenamen in diesem Sinn.
**Die Art lässt sich nachträglich nicht ändern.** An ihr hängt, was die dauerhafte Kennung einer
Person bedeutet; nach einem Wechsel würde niemand mehr wiedererkannt, und der nächste Abgleich legte
alle neu an. Wer wechseln muss, legt eine zweite Quelle an und lässt die erste stehen.
Was in Entra ID einzurichten ist — App-Registrierung, Berechtigung, Einwilligung — steht in
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md).
## SCIM: der Anbieter schickt
Bei den ersten zwei Arten fragt Laufzettel das Verzeichnis. Bei SCIM ist es umgekehrt: der
Identitätsanbieter ruft Laufzettel an, sobald sich bei ihm etwas ändert. Das ist der Weg für zwei
Lagen:
- Das Haus will Laufzettel **keinen Lesezugriff** auf sein Verzeichnis geben.
- Laufzettel läuft in einem Netz, das das Verzeichnis **nicht erreicht** — ein Push von außen nach
innen geht, ein Lesezugriff von innen nach außen nicht.
### Was anders ist
| | LDAP und Graph | SCIM |
|---|---|---|
| Zugangsdaten | Dienstkonto bzw. App-Registrierung | **keine** — ein Token für den Anbieter |
| Zeitplan | alle *n* Minuten | keiner, es kommt sofort |
| Verbindung testen | zeigt die ersten zehn Treffer | gibt es nicht — es gibt keine Verbindung |
| Abgleich-Log | je Lauf eine Zeile | gibt es nicht; stattdessen **letzte Zustellung** |
| Attributzuordnung | einstellbar | fest, der Anbieter bestimmt die Feldnamen (RFC 7643) |
| `{{ou}}` | aus dem DN bzw. der Abteilung | leer — SCIM kennt keine OU |
Was Laufzettel übernimmt: Anmeldename (`userName`), Vor- und Nachname, Anzeigename, Mailadresse,
Titel, Abteilung, der Vorgesetzte und ob das Konto aktiv ist. Mehr nicht — keine Gruppen, keine
Rollen, keine Passwörter.
### Einrichten: Laufzettel zuerst
1. **Quelle anlegen.** Unten auf der Seite **Art → SCIM (wird geliefert)** wählen, einen Namen
geben (etwa „authentik Push“), **Zustellungen annehmen** anhaken und speichern. Mehr Felder gibt
es nicht.
2. **Token erzeugen.** An der neuen Quelle steht **Token erzeugen**. Es erscheint **genau einmal** —
kopier es sofort. Gespeichert wird es nur als Hash; ein zweites Ansehen gibt es nicht. Wer es
verliert, erzeugt ein neues, und das alte gilt dann nicht mehr.
3. **Adresse ablesen.** Unter dem Namen der Quelle steht die Adresse, die der Anbieter braucht, etwa
`https://laufzettel.firma.de/api/scim/v2`.
### Einrichten in authentik
Unter **Applications → Providers → Create → SCIM Provider**:
| Feld | Wert |
|---|---|
| **Name** | etwa „Laufzettel“ |
| **URL** | die Adresse von oben, also `https://…/api/scim/v2` |
| **Token** | das erzeugte Token, unverändert eingefügt |
Bei **User filtering** wählst du die Gruppe, deren Mitglieder in Laufzettel stehen sollen — nicht
alle Konten, sondern die Menschen, für die Läufe entstehen. Unter **Attribute mapping** genügen die
Vorgaben; authentik schickt `userName`, `name`, `emails` und die Erweiterung
`enterprise:2.0:User` von selbst.
Danach **Save** und einmal **Sync** auslösen. In Laufzettel steht dann an der Quelle **Letzte
Zustellung am …** und die Personen stehen unter **Mitarbeiter**.
### Einrichten in Entra ID
Unter **Entra ID → Enterprise applications → New application → Create your own application →
Integrate any other application you don't find in the gallery**. Dann in der neuen Anwendung
**Provisioning → New configuration**:
| Feld | Wert |
|---|---|
| **Tenant URL** | die Adresse von oben, also `https://…/api/scim/v2` |
| **Secret Token** | das erzeugte Token |
**Test Connection** muss grün werden — Entra ID fragt dabei `ServiceProviderConfig`, `Schemas` und
`ResourceTypes` ab und legt probeweise niemanden an. Danach unter **Users and groups** die Gruppe
zuweisen und das Provisioning auf **On** stellen.
Zwei Eigenheiten von Entra ID, die hier nicht stören, aber erklären, was in den Protokollen steht:
- Entra ID schickt `active` als **Zeichenkette** `"True"`/`"False"`, nicht als `true`/`false`.
Laufzettel liest beides.
- Der erste vollständige Durchlauf beginnt bis zu **40 Minuten** nach dem Einschalten. Das ist
Entra ID und nichts, was sich hier beschleunigen ließe.
### `Löschen` beim Anbieter heißt hier `inaktiv`
Nimmt jemand eine Person beim Anbieter aus der zugewiesenen Gruppe oder löscht sie dort, schickt
der Anbieter ein `DELETE`. **Laufzettel setzt die Person dann auf inaktiv und löscht nichts.** Die
Zeile bleibt, weil Läufe und Geräte an ihr hängen: ein archivierter Lauf muss auch in zehn Jahren
noch sagen können, für wen er lief.
Der Anbieter bekommt `204` und hält die Person damit für entfernt — aus seiner Sicht richtig, denn
sie wird ihm nicht mehr zugewiesen. **In Laufzettel steht sie weiter in der Liste**, inaktiv. Das
sieht aus wie ein Fehler und ist keiner. Wer sie aus der Ansicht haben will, filtert auf „nur
aktive“ — das ist die Vorgabe der Mitarbeiterliste.
### Wenn nichts ankommt
| Was der Anbieter meldet | Woran es liegt |
|---|---|
| `401` | Das Token stimmt nicht, oder **Zustellungen annehmen** ist nicht angehakt. Beides ergibt dieselbe Antwort — absichtlich. |
| `404` auf eine bekannte Person | Die Person gehört zu einer **anderen** Quelle. Jedes Token sieht nur die Personen seiner eigenen. |
| `409` beim Anlegen | Die Person gibt es in dieser Quelle schon. Der Anbieter fragt normalerweise vorher mit einem Filter; schickt er trotzdem, ist `409` die richtige Antwort und kein Datenverlust. |
| `400 invalidFilter` | Der Anbieter benutzt einen Filter, den Laufzettel nicht unterstützt — nur `userName eq "…"` geht. |
| nichts, Zeitüberschreitung | Der Reverse Proxy lässt `/api/scim/` nicht durch, oder TLS stimmt nicht. Siehe [`docs/betrieb/scim.md`](../betrieb/scim.md). |
Eine Quelle **stilllegen**, ohne das Token beim Anbieter zu löschen: Häkchen **Zustellungen
annehmen** entfernen. Der Endpunkt antwortet dann mit `401`, und beim Anbieter stehen die
Zustellungen als Fehler — sie gehen nicht verloren, er versucht es erneut.
## Eine Quelle anlegen ## Eine Quelle anlegen
![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png) ![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png)
@@ -225,10 +90,6 @@ Namensänderung der Quelle ihre Anmeldung.
![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png) ![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png)
Die folgenden vier Abschnitte gelten für **LDAP und Graph**. Bei einer SCIM-Quelle gibt es weder
Verbindungstest noch Zeitplan noch Abgleich-Log — dort steht an ihrer Stelle die letzte Zustellung,
siehe [oben](#scim-der-anbieter-schickt).
**Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert **Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert
wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot
darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md). darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md).
@@ -308,7 +169,3 @@ deshalb als Hinweis und nicht als Fehler da. Steigt die Zahl, prüf die Attribut
Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei
einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe
[Kapitel 05](05-mitarbeiter-geraete.md). [Kapitel 05](05-mitarbeiter-geraete.md).
Von Hand angelegte Personen gehen dabei nicht verloren, wenn später eine Quelle dazukommt: taucht
jemand mit demselben Anmeldenamen aus dem Verzeichnis oder per SCIM auf, **übernimmt** Laufzettel
die vorhandene Zeile statt eine zweite anzulegen. Ihre Läufe und Geräte bleiben an ihr hängen.
-69
View File
@@ -1,69 +0,0 @@
# 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.
-8
View File
@@ -29,14 +29,6 @@ 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 - [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen
- [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden - [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 ## Zwei Betriebsmodi
Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und
-144
View File
@@ -153,17 +153,6 @@ Zwei Folgen, beide verbindlich:
klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu
laden und nachsehen, ob es wirklich in der Datenbank steht. laden und nachsehen, ob es wirklich in der Datenbank steht.
Daraus folgt eine dritte, die erst #027 gelehrt hat: **diese Abläufe müssen regelmäßig laufen, weil
sie die einzigen Prüfer ihrer Zusagen sind.** Sie stehen nicht im Commit-Tor — gemessen 256 s für
die Abläufe, dazu Bau und Start, zu den vier Minuten des Tores; neun Minuten vor jedem Commit ist
ein Tor, das umgangen wird. Verbindlich ist deshalb **vor jedem Merge nach `main`**, und `CLAUDE.md`
führt auf, welche Zusagen ausschließlich dort hängen.
Beim ersten vollständigen Durchgang nach Monaten waren sieben Abläufe rot, vier Ursachen, **keine
ein Produktfehler** — ein veralteter Feldvergleich, ein seit #020 mehrdeutiger Selektor, eine feste
Pixelzahl bei gewachsenem Layout und abweichende Entwicklungsdaten. Dass keiner ein Produktfehler
war, ist kein Trost: es heißt, dass die Abläufe in dieser Zeit **nichts** geprüft haben.
Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit
`unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an `unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an
`process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine `process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine
@@ -276,41 +265,6 @@ 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 *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. 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 ## 5e. Der Aufkleber ist die Schnittstelle
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
@@ -387,106 +341,8 @@ Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben; alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
was darauf steht, ist aus der Hand gegeben. was darauf steht, ist aus der Hand gegeben.
## 5f-2. Bedingte Teile: zwei Filter, eine Reihenfolge
Was ein Lauf zeigt, entsteht aus **zwei** Filtern uebereinander, und ihre
Reihenfolge ist der ganze Inhalt von `geltendeStruktur` in
`src/lib/bedingungen.ts`:
1. **Die Abteilungsauswahl (#031)** — beim Start festgeschrieben in
`Lauf.geltendeAbschnitte`. Festgeschrieben, weil ein archivierter Lauf genau
die Schritte zeigen muss, die damals abgearbeitet wurden, und ein spaeterer
Abteilungswechsel keinen Schritt entfernen darf, an dem eine Notiz haengt.
2. **Die Bedingungen (#039)** — **laufend** ausgewertet. Eine Bedingung auf einer
Variable liesse sich auch festschreiben; eine auf einem Schrittergebnis nicht,
denn ihre Antwort entsteht erst waehrend der Arbeit. Ein Abschnitt
„Nacharbeit“, der erscheint, wenn der Restore-Test fehlschlaegt, ist der Fall,
fuer den es gebaut ist.
Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
zweiten anwendet, umgeht die Abteilungsregel. Drei Schichten brauchen beide — die
Abschlussregel im Dienst, die Abarbeitung und die Druckansicht —, und
`tests/bedingte-laeufe.test.ts` prueft **strukturell**, dass keine davon die
zweite vergisst. Dieselbe Art Test wie „jede Seite prueft selbst“ (#038): die
Zusage steht in der Vollstaendigkeit einer Liste, und die muss jemand bewachen.
**Nicht entscheidbar heisst: gilt.** Hat niemand den Kontotyp angegeben, stehen
beide Konto-Abschnitte da — genau wie vor #039. Auszublenden hiesse, eine Antwort
zu erfinden, die niemand gegeben hat; und die Richtung, in die der Zweifel
faellt, soll die harmlose sein.
**Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen.** Vier Wege schreiben
eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
`entwurfSpeichern`. Eine Bedingung auf einer Kennung ueberlebte keinen davon —
ein Titel-Pfad ueberlebt alle vier, und `bedingungAufloesen` ist fuer alle
dieselbe Stelle. Dass eine Schritt-Bedingung auf **dieselbe Version** zeigt,
haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
entscheidbar, und der Abschnitt erschiene stillschweigend immer.
## 5f-3. Die Vier-Augen-Freigabe: eine Stelle, zwei Netze
Verlangt eine Version eine Freigabe, haengt der Abschluss daran — und die Regel
dahinter ist **eine Funktion**: `hatDaranGearbeitet` in
`src/server/services/laeufe.ts`. Sie wird vor jeder Freigabe und vor jeder
Zurueckweisung gerufen, genau wie `laufMussVeraenderbarSein` vor jeder Aenderung
an einem Lauf. Zwei Stellen liefen auseinander, und die erste, die es vergisst,
hebt die Regel auf.
Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
**Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
Zustaendige gilt bewusst **nicht** als Bearbeiter — eine Zuweisung ist noch keine
Arbeit, und waere die Regel hier strenger, blockierte sie kleine Teams ohne
Gewinn.
**Zwei Netze, nicht eines.** Die wichtigste Haelfte steht als CHECK in der
Datenbank (`Lauf_freigabe_nicht_selbst`): der Ersteller gibt nicht frei, auch
dann nicht, wenn jemand an der Dienstschicht vorbei schreibt. Der Dienst prueft
strenger, weil er in zwei weitere Tabellen sehen kann; ein CHECK braeuchte dafuer
eine Unterabfrage.
**Das Recht `freigeben` hat nicht `TECHNIKER`**, und das ist eine Entscheidung
gegen Bequemlichkeit: sonst haengt die Vier-Augen-Regel daran, dass zufaellig ein
zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
Rechten — und das steht im Protokoll.
**Die Aufforderung entsteht ausserhalb der Transaktion.** Der Abschlussversuch
scheitert mit `FreigabeFehlt`; `laufAbschliessen` faengt genau diesen Typ,
reiht die Meldung an die Freigabeberechtigten ein und wirft weiter. Innerhalb
der Transaktion wuerde sie mitsamt dem Fehler zurueckgerollt — und dann wartet
ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index
(`Zustellung_freigabe_einmal`) sorgt dafuer, dass mehrere Versuche nicht mehrere
Mails ergeben.
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse ## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
und die erste Frage ist nicht, wie gelesen wird, sondern **in welche Richtung**:
| Art | Richtung | Wo der Code liegt |
|---|---|---|
| `LDAP` | Laufzettel holt | `src/server/ldap/*` |
| `GRAPH` | Laufzettel holt | `src/server/graph/*` |
| `SCIM` | der Anbieter schickt | `src/app/api/scim/*`, `src/server/scim/*`, `src/server/services/scim.ts` |
Die ersten zwei laufen durch **denselben** Abgleich; die eine Stelle, die die Art
kennt, ist `eintraegeDerQuelleStroemen` in
`src/server/verzeichnis/quellenleser.ts`. Eine zweite Verzweigung waere eine
zweite Stelle, die sich an eine neue Quellenart erinnern muss, und die vergisst
man.
**Bei `SCIM` gibt es keinen Abgleich**, und das ist an zwei Stellen gehalten:
`faelligeQuellen` nimmt solche Quellen nicht auf, und die Weiche wirft. Der Grund
ist nicht Ordnungsliebe — ein Abgleich, der dort nichts liest und `ERFOLG` meldet,
haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Dasselbe Muster
wie der Reviewbefund aus #016, nur mit einer anderen Ursache.
Was beim Schreiben danach gilt, gilt fuer alle drei gleich: anlegen, uebernehmen,
aktualisieren, deaktivieren, **nie loeschen** — auch ein `DELETE` eines
SCIM-Anbieters setzt nur `aktiv = false`. Das **Token** einer SCIM-Quelle ist der
einzige Mandantenbeweis, den eine Zustellung mitbringt; es steht als argon2-Hash
in der Datenbank, wird einmal angezeigt und kommt in keiner Sicht und keinem
Protokolleintrag vor.
Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien Das LDAP-Modul liegt unter `src/server/ldap/` und ist absichtlich in vier Dateien
geteilt, von denen nur eine das Netz anfasst: geteilt, von denen nur eine das Netz anfasst:
-4
View File
@@ -56,12 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang - **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und - **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
- **[Entra ID als Verzeichnisquelle](entra-id.md)** — App-Registrierung, `User.Read.All`,
Administratoreinwilligung, und was anders ist als bei einem lokalen AD
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die - **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
- **[SCIM-Endpunkt](scim.md)** — die einzige Stelle, an der ein fremder Dienst von außen schreibend
zugreift: das Token, was nicht unterstützt wird, und was der Reverse Proxy durchlassen muss
## Wo sonst noch etwas steht ## Wo sonst noch etwas steht
+3 -46
View File
@@ -58,16 +58,9 @@ Lege in authentik vier Gruppen an und trage die Mitglieder ein:
| `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen | | `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen |
| `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen | | `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen |
| `lz-leser` | `LESER` | nur lesen | | `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. 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 ## 5. Laufzettel konfigurieren
In der `.env`: In der `.env`:
@@ -100,47 +93,11 @@ docker compose up -d
- Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning) - 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 - Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift
ab der nächsten Anmeldung ab der nächsten Anmeldung
- Ist der Benutzer in keiner der vier Gruppen, versucht Laufzettel, ihn über seinen **UPN** einer - Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER`
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 - Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen
eines QR-Codes), landet danach genau dort eines QR-Codes), landet danach genau dort
## 7. Selbstauskunft für Mitarbeiter ## 7. Notfallzugang
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: Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login:
@@ -155,7 +112,7 @@ Passwörter liegen als argon2-Hash in der Datenbank.
Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED` Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED`
im Normalbetrieb auf `false` und nur im Notfall umgestellt. im Normalbetrieb auf `false` und nur im Notfall umgestellt.
## 9. Fehlersuche ## 8. Fehlersuche
| Symptom | Ursache | | Symptom | Ursache |
|---|---| |---|---|
-126
View File
@@ -1,126 +0,0 @@
# Entra ID als Verzeichnisquelle
Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen ließe. Für reine
Microsoft-365-Umgebungen liest Laufzettel über **Microsoft Graph** — mit denselben Regeln wie der
LDAP-Abgleich: anlegen, aktualisieren, deaktivieren, **nie löschen**.
> **Dieser Weg ist nicht gegen einen echten Mandanten gelaufen.** Geprüft ist er gegen einen
> HTTP-Doppelgänger mit aufgezeichneten Graph-Antworten (`tests/graph.test.ts`), und die Form dieser
> Antworten stammt aus der Dokumentation, nicht aus einem Mitschnitt. Die Prüfliste am Ende dieser
> Seite gehört einmal abgearbeitet, so wie bei Proxmox (#024).
## Was in Entra ID einzurichten ist
### 1. App-Registrierung anlegen
Im Entra-Portal unter **Identität → Anwendungen → App-Registrierungen → Neue Registrierung**:
| Feld | Wert |
|---|---|
| Name | `Laufzettel Verzeichnisabgleich` |
| Unterstützte Kontotypen | **Nur Konten in diesem Organisationsverzeichnis** |
| Umleitungs-URI | **keiner** — Laufzettel meldet sich als Anwendung an, nicht im Namen eines Menschen |
Notiere von der Übersichtsseite die **Anwendungs-ID (Client)** und die **Verzeichnis-ID (Mandant)**.
### 2. Berechtigung erteilen
Unter **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
Anwendungsberechtigungen**:
| Berechtigung | Wofür |
|---|---|
| `User.Read.All` | die Benutzer lesen |
Dann **Administratoreinwilligung erteilen**. Ohne diesen Klick antwortet Graph mit `403` und
`Authorization_RequestDenied` — Laufzettel nennt den Grund in der Fehlermeldung, weil ihn sonst
niemand errät.
> **Anwendungsberechtigung, nicht delegierte.** Der Abgleich läuft nachts, wenn niemand angemeldet
> ist; eine delegierte Berechtigung braucht einen angemeldeten Menschen und funktioniert dort nicht.
`Directory.Read.All` wird **nicht** gebraucht. `User.Read.All` deckt die Benutzer und über
`$expand=manager` auch den Vorgesetzten ab — die weitergehende Berechtigung wäre mehr Zugriff, als
die Aufgabe verlangt.
### 3. Geheimnis erzeugen
Unter **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Die Laufzeit ist eine
Entscheidung mit Folgen: **läuft das Geheimnis ab, hört der Abgleich auf, und niemand merkt es**,
weil jeder Lauf weiter funktioniert. Setz dir eine Erinnerung, und sieh unter **Verwaltung →
Verzeichnis** nach dem Status, wenn Personen nicht mehr aktualisiert werden.
Der Wert ist **nur beim Anlegen sichtbar**. Kopier ihn sofort.
## In Laufzettel einrichten
Unter **Verwaltung → Verzeichnis → Quelle anlegen**, Art **Microsoft Graph**:
| Feld | Wert |
|---|---|
| Mandanten-ID | die Verzeichnis-ID oder der Domänenname (`contoso.onmicrosoft.com`) |
| Anwendungs-ID | die Anwendungs-ID (Client) aus Schritt 1 |
| Client-Secret | der Wert aus Schritt 3 — wird verschlüsselt gespeichert und nie wieder angezeigt |
Dann **Verbindung testen**: es erscheinen die ersten zehn zugeordneten Personen. Steht dort nichts,
hilft die Tabelle unter „Wenn es klemmt“.
## Was anders ist als bei einem lokalen AD
| | LDAP (AD) | Microsoft Graph (Entra ID) |
|---|---|---|
| Dauerhafte Kennung | `objectGUID` (binär, umsortiert) | `id` (GUID als Text) |
| Nachname | `sn` | `surname` |
| Funktion | `title` | `jobTitle` |
| Anmeldename | `sAMAccountName` | `onPremisesSamAccountName` |
| Vorgesetzter | `manager` als DN, danach aufgelöst | `manager` direkt als Kennung |
| Deaktivierte Konten | über den Filter ausgeschlossen | über `$filter=accountEnabled eq true` |
| `{{ou}}` | aus dem DN | **leer** |
### `{{ou}}` bleibt leer, und das ist Absicht
Entra ID hat keinen DN und keine OU. Etwas hineinzudeuten — den Abteilungsnamen, eine erfundene
Struktur — wäre ein Wert, den kein PowerShell-Befehl brauchen kann, und er sähe richtig aus.
Steht an der **Abteilung** eine Ziel-OU gepflegt, nimmt Laufzettel die (`{{ou}}` hat zwei Quellen,
siehe `docs/architektur.md`). Wer OU-abhängige Befehle fährt, braucht ein lokales AD — oder pflegt
die Ziel-OU je Abteilung von Hand.
### `{{sam}}` ist leer ohne AD-Synchronisierung
`onPremisesSamAccountName` kommt aus einem lokalen AD. Ein Mandant, der nie eines hatte, liefert dort
nichts — eine reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn. Vorlagen, die `{{sam}}`
benutzen, zeigen dann einen leeren Wert; gemeint ist meist ohnehin der UPN.
### Die Art einer Quelle lässt sich nicht ändern
An ihr hängt, was die dauerhafte Kennung bedeutet. Nach einem Wechsel von LDAP auf Graph würde
**kein einziger Mitarbeiter wiedererkannt**: der nächste Abgleich legte die ganze Belegschaft neu an
und setzte die alten Zeilen auf `aktiv = false` — mit allen Läufen und Geräten daran.
Wer wechselt, legt eine **zweite** Quelle an und lässt die erste stehen, bis die Personen übernommen
sind. Laufzettel übernimmt dabei von Hand angelegte und bereits vorhandene Personen über Name und
Mailadresse, statt Dubletten anzulegen (#016).
## Wenn es klemmt
| Meldung | Ursache |
|---|---|
| `401` beim Anmelden | Mandant, Anwendungs-ID oder Geheimnis stimmen nicht — oder das Geheimnis ist abgelaufen. Die ausführliche Antwort von Entra ID steht im Protokoll des Dienstes, nicht in der Oberfläche: sie nennt Mandant und Anwendung. |
| `403 Authorization_RequestDenied` | Die Administratoreinwilligung für `User.Read.All` fehlt. Erteilen und erneut testen. |
| `Invalid expand clause` | Sollte nicht vorkommen — Laufzettel baut `$expand=manager($select=id)` mit unkodierten Klammern. Tritt es auf, steht ein Proxy dazwischen, der die Adresse umschreibt. |
| Verbindung testen zeigt niemanden | Der Mandant hat keine Benutzer mit `accountEnabled = true`, oder alle wurden übersprungen. Die Liste der Übersprungenen steht unter dem Ergebnis. |
| Personen werden nicht mehr aktualisiert | Zuerst den Status der Quelle ansehen. Fast immer ist das Client-Secret abgelaufen. |
## Prüfliste für den ersten echten Lauf
Diese Punkte sind **nicht** automatisiert geprüft — sie brauchen einen echten Mandanten:
- [ ] App-Registrierung angelegt, `User.Read.All` als **Anwendungs**berechtigung, Einwilligung erteilt
- [ ] „Verbindung testen“ zeigt die ersten zehn Personen mit richtigen Namen und Abteilungen
- [ ] Der Vorgesetzte steht bei jemandem, der einen hat (`manager` über `$expand`)
- [ ] Erster Abgleich legt an; die Zahl passt zur Zahl der aktiven Konten im Mandanten
- [ ] Zweiter Abgleich legt **nichts** an und meldet nur Änderungen
- [ ] Ein Konto in Entra ID sperren → nächster Abgleich setzt `aktiv = false`, löscht aber nichts
- [ ] Ein Mandant mit mehr als 999 Benutzern: der Seitenabruf über `@odata.nextLink` läuft durch
- [ ] Nach einem abgelaufenen Geheimnis steht der Grund an der Quelle und nicht nur im Log
-159
View File
@@ -1,159 +0,0 @@
# SCIM-Endpunkt betreiben
Laufzettel kann Personen aus einem Verzeichnis **holen** (LDAP, Microsoft Graph) oder sich
**beliefern lassen**. Der zweite Weg ist SCIM 2.0, und er ist die einzige Stelle, an der ein
fremder Dienst von außen schreibend auf Laufzettel zugreift. Diese Seite beschreibt, was der
Betreiber dafür wissen muss. Wie eine Quelle eingerichtet wird, steht in
[Kapitel 06 der Anleitung](../anleitung/06-verzeichnis.md#scim-der-anbieter-schickt).
## Was der Endpunkt ist
| | |
|---|---|
| Adresse | `https://<APP_URL>/api/scim/v2` |
| Anmeldung | `Authorization: Bearer <Token>`, ein Token je Quelle |
| Medientyp | `application/scim+json` |
| Normen | RFC 7643 (Schema), RFC 7644 (Protokoll) |
Vollständig:
```
GET /api/scim/v2/ServiceProviderConfig
GET /api/scim/v2/Schemas
GET /api/scim/v2/ResourceTypes
GET /api/scim/v2/Users?filter=userName eq "…"
POST /api/scim/v2/Users
GET /api/scim/v2/Users/{id}
PUT /api/scim/v2/Users/{id}
PATCH /api/scim/v2/Users/{id}
DELETE /api/scim/v2/Users/{id}
```
Die drei Beschreibungs-Endpunkte sind nicht Zierde: manche Anbieter, Entra ID darunter, fragen sie
beim „Test Connection“ ab und weigern sich sonst anzufangen. Sie verlangen **ebenfalls** ein Token.
## Was nicht unterstützt wird — und warum das so gemeldet wird
`ServiceProviderConfig` sagt ausdrücklich `supported: false` für `bulk`, `sort`, `filter` und
`etag`. Die **Verneinungen** sind der wichtigere Teil der Antwort: ein Anbieter, der `bulk` für
unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet das als Fehler der
Gegenseite.
Nicht umgesetzt sind außerdem:
- **`/Groups`.** Laufzettel führt keine Gruppen. Rollen entstehen beim Anmelden aus den
OIDC-Gruppen (siehe [`authentik.md`](authentik.md)), nicht aus dem Provisioning. Ein Anbieter,
der Gruppen zuweisen will, bekommt nichts zugewiesen — `ResourceTypes` nennt nur `User`.
- **Allgemeine Filter.** Unterstützt wird genau `userName eq "…"` — die Frage, die ein Anbieter vor
jedem Anlegen stellt. Alles andere ergibt `400` mit `scimType: invalidFilter`. Das ist Absicht:
einen unverstandenen Filter zu ignorieren und **alle** Personen zu liefern wäre die gefährlichere
Antwort, denn der Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie nie
an.
- **Paginierung über 200 Einträge.** Ein `GET /Users` ohne Filter liefert höchstens 200 Zeilen. Die
Anbieter fragen gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts.
## Das Token
Ein Token je Quelle, erzeugt unter **Admin → Verzeichnis**, Form `lzscim_` + 43 Zeichen aus
`randomBytes(32)`.
- Es steht in der Datenbank **nur als argon2-Hash**. Ein CHECK hält das fest
(`Verzeichnisquelle_scimtoken_gehasht`): ein roh gespeichertes Token weist die Datenbank ab, auch
wenn jemand am Dienst vorbei schreibt.
- Es wird **einmal** angezeigt, direkt nach dem Erzeugen. Danach gibt es keinen Weg, es zu lesen —
auch nicht für einen Administrator, auch nicht aus einem Backup der Anwendung.
- Es erscheint in **keinem Protokolleintrag**. Vermerkt wird „SCIM-Token erzeugt“ mit dem Namen der
Quelle, nicht das Token; das Protokoll ist in der Anwendung ansehbar.
- Ein neues ersetzt das alte. Es gibt genau eines je Quelle — zwei gültige wären zwei Dinge, die man
widerrufen muss, und eines davon vergisst man.
**Das Token ist der einzige Mandantenbeweis, den eine Zustellung mitbringt.** Aus ihm folgt die
Quelle, aus der Quelle der Kunde, und daran hängt, welche Personen die Anfrage sehen und ändern
darf. Ein Token einer anderen Quelle bekommt auf deren Personen `404` und nicht `403`: ob es eine
Person gibt, ist für eine fremde Quelle keine Auskunft.
Gleiche Antwort für **kein Token** und **falsches Token**: `401` mit
`WWW-Authenticate: Bearer realm="Laufzettel SCIM"`. Die Unterscheidung wäre eine Auskunft darüber,
ob ein geratenes Token die richtige Form hat.
Widerrufen geht jederzeit über **Token widerrufen**. Eine Quelle **stilllegen**, ohne das Token beim
Anbieter zu entfernen, geht über das Häkchen **Zustellungen annehmen** — der Endpunkt antwortet dann
ebenfalls mit `401`.
## Reverse Proxy
Der Pfad `/api/scim/` braucht **keine Sonderbehandlung**, aber er muss durchgelassen werden. Wer vor
Laufzettel eine Regel hat, die `/api/` einschränkt oder nur bekannte Pfade weiterleitet, trägt ihn
ein. Drei Punkte:
- **Kein Cache.** Der Endpunkt setzt `Cache-Control: no-store` auf jede Antwort; ein Proxy, der das
überschreibt, liefert veraltete Personenlisten.
- **Der `Authorization`-Kopf muss ankommen.** Manche Konfigurationen entfernen ihn, wenn davor eine
eigene Authentifizierung sitzt. Dann kommt jede Zustellung als `401` zurück.
- **Eine Zustellung ist klein**, einige Kilobyte. Die Grenzen für Anhänge
(`client_max_body_size`, siehe [`reverse-proxy.md`](reverse-proxy.md)) reichen weit aus.
Eine IP-Einschränkung auf die Adressen des Anbieters ist möglich und sinnvoll, wenn er feste hat.
authentik im eigenen Haus hat welche; Entra ID nicht zuverlässig — Microsoft veröffentlicht die
Bereiche, aber sie ändern sich, und eine veraltete Liste sperrt das Provisioning aus.
## Was im Betrieb auffällt
**Es wird nicht abgeglichen.** Eine SCIM-Quelle taucht im Zeitplan nicht auf. Das ist wichtig und
nicht nur Kosmetik: ein Abgleich, der an einer SCIM-Quelle nichts liest und „Erfolg“ meldet, würde
über die Abschaltregel die ganze Belegschaft deaktivieren. Der Zeitplan lässt sie deshalb aus, und
„Jetzt abgleichen“ bricht mit einer Erklärung ab.
**`DELETE` deaktiviert.** Der Anbieter bekommt `204`, die Zeile bleibt mit `aktiv = false`. An einer
Person hängen Läufe und Geräte; ein archivierter Lauf muss auch in zehn Jahren noch sagen können,
für wen er lief. Das steht auch in der Anleitung, weil es sonst wie ein Fehler wirkt.
**Protokolleinträge haben keinen Benutzer.** Jede Zustellung schreibt einen `Protokoll`-Eintrag mit
der Quelle als Urheber („Person angelegt (SCIM: authentik)“) und ohne `benutzerId`. Das ist ein
Systemlauf wie der Zeitplan; ein erfundener Administrator wäre eine Lüge im Protokoll.
**„Letzte Zustellung“ ist die Lebenszeichenanzeige.** Sie steht an der Quelle und wird bei jeder
Anfrage gesetzt, die etwas geschrieben hat. Steht dort nichts, ist die Einrichtung beim Anbieter
nicht fertig — nicht die Verbindung kaputt, denn es gibt keine.
## Prüfen von Hand
Mit dem Token aus der Oberfläche, von einem Rechner aus, der die öffentliche Adresse erreicht:
```bash
TOKEN='lzscim_…'
BASIS='https://laufzettel.firma.de/api/scim/v2'
# 1. Antwortet der Endpunkt überhaupt, und gilt das Token?
curl -s -H "Authorization: Bearer $TOKEN" "$BASIS/ServiceProviderConfig" | head -c 400
# 2. Ohne Token muss 401 kommen.
curl -s -o /dev/null -w '%{http_code}\n' "$BASIS/Users"
# 3. Die Frage, die jeder Anbieter vor dem Anlegen stellt.
curl -s -H "Authorization: Bearer $TOKEN" \
--get --data-urlencode 'filter=userName eq "mhuber@firma.example"' "$BASIS/Users"
```
Die zweite Zeile muss `401` ausgeben. Kommt dort `200`, läuft etwas vor Laufzettel, das den
`Authorization`-Kopf selbst setzt — dann ist der Endpunkt offen, und das gehört sofort behoben.
Anlegen von Hand ist zum Prüfen **nicht** nötig und auch nicht empfehlenswert: die Person stünde
danach in der Quelle, und der Anbieter kennt sie nicht. Löschen lässt sie sich nicht, nur
deaktivieren.
## Fehlerbilder
| Antwort | Ursache |
|---|---|
| `401` | Token falsch, widerrufen, oder **Zustellungen annehmen** nicht angehakt. |
| `404` auf eine bekannte Person | Sie gehört zu einer anderen Quelle. Jedes Token sieht nur seine eigene. |
| `409 uniqueness` | Die Person gibt es in dieser Quelle schon — richtige Antwort auf ein wiederholtes `POST`, kein Datenverlust. |
| `400 invalidFilter` | Ein Filter außer `userName eq "…"`. |
| `400 invalidSyntax` | Kein gültiges JSON, oder ein `PATCH` ohne `Operations`. |
| `500` | Ein Fehler in Laufzettel. Die Einzelheiten stehen im Protokoll des Dienstes, **nicht** in der Antwort: eine Datenbankmeldung nennt Host, Port und Benutzernamen. |
`docker compose logs -f web` beziehungsweise `journalctl -u laufzettel -f` zeigt die Zeilen, die
hinter einem `500` stehen, und auch die Pfade, die ein `PATCH` mitgeschickt und Laufzettel übergangen
hat (`SCIM-PATCH: übergangene Pfade: …`) — die sind kein Fehler, aber die Antwort auf die Frage
„warum steht das Feld nicht drin“.
+5 -22
View File
@@ -17,7 +17,7 @@ Drei Dinge, und nur diese drei:
|---|---|---| |---|---|---|
| **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten | | **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` | | **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD`, von `install.sh` auf `/var/lib/laufzettel/anhaenge` gesetzt | | **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` |
> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre > **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre
> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine > **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine
@@ -157,23 +157,11 @@ sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).du
Und die Anhänge dazu — gewöhnliche Dateien, also `tar`: Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
```bash ```bash
# Der Pfad steht in ANHAENGE_PFAD; `deploy/install.sh` setzt ihn auf # Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
# /var/lib/laufzettel/anhaenge. **Kein Rückfallwert** – siehe den Kasten darunter. ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" . sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
``` ```
> **Warum hier kein `${ANHAENGE_PFAD:-…}` steht.** Es stand einmal eines, und es war falsch:
> `/opt/laufzettel/daten/anhaenge` passt zu keiner Installation. `deploy/install.sh` setzt
> `/var/lib/laufzettel/anhaenge`, und ohne die Variable nimmt die Anwendung `./daten/anhaenge`
> **relativ zu ihrem Arbeitsverzeichnis** `/opt/laufzettel/.next/standalone` — also noch etwas
> anderes.
>
> Ein geratener Pfad ist beim Sichern eine leere Datei und beim Zurückspielen ein Schaden: dort
> steht ein `rm -rf` davor. Fehlt die Variable, soll der Befehl **abbrechen** und nicht raten.
> Deshalb `:?` und nicht `:-`. Gefunden bei der Durchsicht von Phase 2, nachdem derselbe Fehler in
> dieser Datei schon einmal Daten gekostet hätte (#032).
Und das Geheimnis, einmalig und in den Passwortmanager: Und das Geheimnis, einmalig und in den Passwortmanager:
```bash ```bash
@@ -199,10 +187,7 @@ sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-202
# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf # 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
# die keine Zeile mehr zeigt # die keine Zeile mehr zeigt
# Ohne ANHAENGE_PFAD bricht das hier ab – und zwar **vor** dem rm -rf. Ein ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
# geratener Pfad hiesse: das echte Verzeichnis bleibt leer, die Anwendung meldet
# „Die Datei zu … fehlt im Dateisystem“, und niemand weiss, warum.
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"')
sudo rm -rf "$ANHAENGE" sudo rm -rf "$ANHAENGE"
sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE" sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE" sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
@@ -262,9 +247,7 @@ runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.du
# shellcheck source=/dev/null # shellcheck source=/dev/null
. /etc/laufzettel/laufzettel.env . /etc/laufzettel/laufzettel.env
# Kein Rueckfallwert: ein geratener Pfad ergibt eine leere Sicherung, und die anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
# faellt erst beim Zuruckspielen auf.
anhaenge="${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"
if [ -d "$anhaenge" ]; then if [ -d "$anhaenge" ]; then
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" . tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
fi fi
+2 -595
View File
@@ -33,22 +33,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min | | #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min | | #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min | | #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 min | | | **Summe** | **53 h 35 min** |
| #028 | M15 – Quellenarten und Microsoft-Graph-Quelle | 4 h 10 min |
| #029 | M16 – SCIM-Endpunkt als Alternative zum Abgleich | 4 h 05 min |
| #036 | Reviewbefunde aus der Durchsicht von Phase 2 | 3 h 40 min |
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
| #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min |
| #040 | M24 – Vier-Augen-Freigabe | 3 h 30 min |
| | **Summe** | **82 h 45 min** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht | | v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | veröffentlicht | | v0.2.0 | 18 h 15 min | in Arbeit |
| v0.3.0 | 8 h 50 min | in Arbeit |
--- ---
@@ -1312,586 +1302,3 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
eigenen Benachrichtigungen abschalten kann. eigenen Benachrichtigungen abschalten kann.
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen. - **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. - 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.
---
## 2026-10-06 · Sitzung 21 · 09:00–13:10
**Issue:** #028 (M15)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Eine Verzeichnisquelle hat eine Art**: `LDAP`, `GRAPH` oder `SCIM`. Bestehende Zeilen sind
`LDAP` — das war vor #028 die einzige.
- **Entra ID über Microsoft Graph**: Client-Credentials-Flow mit Token-Zwischenspeicher, Seitenabruf
über `@odata.nextLink`, Vorgesetzter über `$expand`.
- **Derselbe Abgleich für beide Arten.** Die Weiche sitzt in
`src/server/verzeichnis/quellenleser.ts` und ist die einzige Stelle, die die Art kennt.
- 1373 Vitest-Tests (vorher 1342), davon 31 neue gegen einen HTTP-Doppelgänger mit aufgezeichneten
Graph-Antworten.
- Im gebauten Stand eine Graph-Quelle wirklich angelegt: Art gespeichert, Geheimnis verschlüsselt,
nicht im Quelltext der Seite.
### Entscheidungen
- **Ein Enum auf der Quelle, keine Tabelle je Art.** Drei Arten rechtfertigen keine Vererbung; was
eine Art braucht, hält ein CHECK fest — in **beide** Richtungen, sonst wäre eine Graph-Quelle mit
`basisDn` möglich.
- **Die Art ist unveränderlich.** An ihr hängt, was `externeId` bedeutet; nach einem Wechsel würde
kein Mitarbeiter wiedererkannt. Steht so nicht im Issue und ist die wichtigste Zusage dieses
Meilensteins.
- **`{{ou}}` bleibt bei Graph leer.** Etwas hineinzudeuten wäre ein Wert, den kein PowerShell-Befehl
brauchen kann — und er sähe richtig aus.
- **`SCIM` steht im Enum, wird aber abgewiesen.** Ein Wert, der stillschweigend nichts tut, hätte
hier die ganze Belegschaft deaktiviert.
### Probleme
- **Der teuerste Fund: „deaktiviert“ als „übersprungen“ zu melden hätte Leute aktiv gelassen.**
„Übersprungen“ heißt im Abgleich „nicht zuordenbar“, und solche Einträge werden seit #016
absichtlich nicht abgeschaltet. Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt
worden als ein gesperrtes AD-Konto. Jetzt filtert die Abfrage serverseitig, als Gegenstück zum
LDAP-Standardfilter.
**Gefunden hat es der Test — aber nur, weil der Doppelgänger den Filter wirklich auswertet.** Hätte
er den Filter bloß empfangen und ignoriert, wäre der Test grün gewesen und der Fehler im Betrieb
aufgefallen. Ein Doppelgänger, der nur antwortet, prüft die Anfrage nicht.
- **`URLSearchParams` kodiert `$` zu `%24`** und die Klammern in `manager($select=id)`. Graph nimmt
das teils an und antwortet teils mit „Invalid expand clause“. Jetzt von Hand zusammengesetzt.
- **Ein Import-Zyklus drohte**: die Weiche braucht `zugangAus`, und der Dienst braucht die Weiche.
Beides aufgelöst durch ein eigenes Modul. In ESM läuft so ein Zyklus oft — aber nicht zuverlässig.
- **Zwei eigene Testfehler**: die Token-Grenze liegt bei +3540 s und nicht „ungefähr eine Stunde“,
und die Klickprobe zählte die Felder der Bearbeiten-Formulare mit.
### Was offen bleibt, und das ist benannt
Gegen einen **echten Entra-Mandanten** ist nichts davon gelaufen. Die Form der Antworten im
Doppelgänger stammt aus der Dokumentation, nicht aus einem Mitschnitt — stimmt sie nicht, stimmen
Test und Wirklichkeit nicht. Die Prüfliste steht am Ende von `docs/betrieb/entra-id.md`, acht
Punkte, darunter ein Mandant mit mehr als 999 Benutzern. Dieselbe Art offener Punkt wie #024.
### Nächster Schritt
- **M16 (#029)** SCIM-Endpunkt ist der letzte Meilenstein von Phase 2. Die Weiche dafür steht.
- Vor #029 wäre ein unabhängiger Review angebracht: **sieben Meilensteine** dieser Phase liegen ohne
fremden Blick auf `main`, und diese Sitzung hat wieder einen Fehler gezeigt, den die Suite nur
deshalb gefunden hat, weil der Doppelgänger streng genug war.
---
## 2026-10-10 · Sitzung 22 · 17:40–21:45
**Issue:** #029 (M16)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **SCIM 2.0 als dritte Quellenart.** Neun Endpunkte unter `/api/scim/v2`, jeder prüft sein Token
selbst — die Hülle unter `(app)` liegt dort nicht darüber, und der Proxy lässt `/api/scim`
absichtlich ohne Cookie durch.
- **Ein Token je Quelle**, argon2-gehasht, einmal angezeigt. Zwei CHECKs halten das in der
Datenbank: `…_scimtoken_gehasht` (`LIKE '$argon2%'`) und `…_scimtoken_mit_zeitpunkt`.
- **Oberfläche**: Art SCIM im Anlege-Formular, Token erzeugen und widerrufen, letzte Zustellung,
Endpunktadresse an der Quelle. Verbindungstest, Zeitplan, Abgleich-Log und Attributzuordnung
fallen dort weg — es gibt sie nicht, und ein Knopf, der nur eine Erklärung auswirft, ist kein
Knopf.
- 1432 Vitest-Tests (vorher 1373), davon 59 neue. Die Anfrageformen von authentik und Entra ID
stehen als benannte Konstanten oben in `tests/scim.test.ts`.
- **Klickprobe am gebauten Stand, zwanzig Prüfungen**, von „Art SCIM wählbar“ über eine echte
Zustellung mit genau dem Token aus der Oberfläche bis „390 px ohne horizontales Scrollen“. Die
Probe hat ihre Daten danach selbst entfernt — die Lehre aus #027.
- Anleitung Kapitel 06 um den SCIM-Abschnitt erweitert (Einrichtung in authentik und Entra ID,
Fehlerbilder), `docs/betrieb/scim.md` neu.
### Entscheidungen
- **Umgesetzt ist nicht der Standard, sondern was die Anbieter schicken.** Komplexe Filter,
Sortierung, `bulk`, `/Groups` — nichts davon. `ServiceProviderConfig` **meldet** das als
`supported: false`, und die Verneinungen sind der wichtigere Teil der Antwort: ein Anbieter, der
`bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet.
- **Ein unverstandener Filter ergibt `400`, nicht „alle Personen“.** Die wichtigste
Einzelentscheidung hier. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter schlösse
daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an.
- **`404` und nicht `403`** für eine Person einer fremden Quelle. Ob es sie gibt, ist für ein
fremdes Token keine Auskunft. Ebenso dieselbe `401` für „kein Token“ und „falsches Token“.
- **`DELETE` deaktiviert.** Der Anbieter bekommt `204` und hält die Person für entfernt — aus seiner
Sicht richtig. In der Anleitung steht das ausdrücklich, sonst wirkt die inaktive Zeile wie ein
Fehler.
- **SCIM-Quellen bleiben aus dem Zeitplan heraus**, an zwei Stellen gehalten. Ein Abgleich, der dort
nichts liest und `ERFOLG` meldet, hätte die ganze Belegschaft deaktiviert.
### Probleme
- **Zwei von acht Gegenproben blieben grün — und das waren die wertvollen.** Sechs schlugen sofort
an. Die beiden stillen:
- **`PATCH remove` wirkte gar nicht.** Kein Testloch, ein Fehler: `personAendern` hatte einen
Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las. Nimmt jemand
beim Anbieter die Abteilung aus dem Profil, stand sie in Laufzettel weiter — sichtbar, gepflegt
aussehend, falsch. Der Schalter war nie nötig: `patchAnwenden` setzt auf dem vorhandenen Stand
auf und liefert immer einen vollständigen Zielzustand. Der Unterschied zwischen `PUT` und
`PATCH` liegt **vor** der Funktion, nicht in ihr.
- **Entra ID schickt `active` als `"True"`/`"False"`.** Richtig umgesetzt, aber nur halb geprüft:
beim Deaktivieren kommt mit einem reinen `value === true` **zufällig** das Richtige heraus. Nur
das Aktivieren wäre kaputt gewesen — ein wieder freigeschaltetes Konto, das in Laufzettel für
immer inaktiv bleibt. Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste.
- **`manager` wäre fast untergegangen.** Es stand im Issue, und umgesetzt war zunächst nur
`department`. Dazugekommen: drei Lesearten (`{value}`, `{$ref}`, nackte Zeichenkette), die
Auflösung, und `manager` im `Schemas`-Endpunkt — das Letzte ist das Entscheidende, denn ein
Anbieter schickt nur, was dort steht. Ohne den Eintrag bliebe der Vorgesetzte dauerhaft leer,
**ohne Fehlermeldung auf einer der beiden Seiten.**
- **`manager` war ein Weg über die Quellengrenze.** Eine Kennung aus einer fremden Quelle hätte über
die Vorgesetzten-Anzeige einen fremden Namen in die Oberfläche gebracht. Die Auflösung sucht nur
in der eigenen Quelle; die Gegenprobe ohne diese Einschränkung ist rot.
- **Ein Fehler aus #028 im Nachbarcode**: in `verbindungTestenAktion` stand zweimal dieselbe
Bedingung, der zweite Zweig war unerreichbar. Genau dieser Zweig war dort als Reviewbefund
eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte anders klingen als „der Filter findet
niemanden“, weil die Ursachen den Administrator an verschiedene Stellen schicken. Wirkungslos
seit dem Tag, an dem er geschrieben wurde. Das ist dasselbe Muster wie die stillen Gegenproben:
**Code, der aussieht, als prüfte er etwas.**
- **Ein eigener Probenfehler**: die Klickprobe suchte „Klick Probe“ in der Mitarbeiterliste. Ohne
`displayName` baut Laufzettel „Nachname, Vorname“ — wie der Abgleich. Kein Produktfehler, aber
ein Beleg dafür, dass ein roter Befund erst gelesen und dann bewertet gehört.
- Ein Test aus #028 war **überholt**, nicht falsch: er verlangte, dass die Meldung der Weiche „#029“
nennt.
### Was offen bleibt, und das ist benannt
Gegen **echte Mandanten** ist nichts davon gelaufen. Die Anfrageformen stammen aus der
Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe Grenze wie bei #028,
und sie steht im Kopfkommentar von `tests/scim.test.ts`. Die Prüfbefehle für den ersten echten Lauf
stehen in `docs/betrieb/scim.md`.
### Nächster Schritt
- **Phase 2 ist damit vollständig.** Acht Meilensteine (M15–M22) liegen danach auf `main`.
- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host) und **#027** (die Editor-Abläufe
lassen ihre Vorlagen in der Dev-Datenbank stehen).
- **Ein unabhängiger Review ist jetzt überfällig.** Acht Meilensteine dieser Phase liegen ohne
fremden Blick auf `main`, und diese Sitzung hat wieder zwei Dinge gezeigt, die eine grüne Suite
nicht sieht: einen Schalter, der eine Änderung verschluckte, und einen Zweig, der nie erreicht
wurde. Beide Befunde kamen aus Gegenproben, nicht aus Tests.
---
## 2026-10-11 · Sitzung 23 · 21:50–01:30
**Issues:** #036 (Reviewbefunde Phase 2), #027 (E2E-Reste)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Der unabhängige Review ist gelaufen** — acht Meilensteine (M15–M22) lagen ohne fremden Blick auf
`main`, und das stand seit Sitzung 21 als „überfällig“ im Devlog. Ein Agent mit eigener Datenbank,
der den Code nicht geschrieben hat, gegen die Akzeptanzkriterien und gegen `CLAUDE.md`.
- **Dreizehn Befunde, zwölf behoben**, zwei als #037 und #038 benannt. Alle lagen am Rand: zwei
Route Handler, ein Export-Pfad, Betriebsdokumentation, Kommentare. **Die Kernregeln halten** —
fünf von fünf Gegenproben gegen Versionierung, Mandantengrenze, Abschlussregel und
Selbstauskunft wurden rot.
- **#027 abgeschlossen**: die 105 Altreste sind weg, geräumt wird jetzt zentral vor und nach dem
Lauf, und ein struktureller Test bewacht es.
- 1454 Vitest-Tests (vorher 1432), 113 E2E-Abläufe grün.
### Entscheidungen
- **Eine Liste, die nur in einer Richtung geprüft wird, läuft dem Code hinterher.** `/api/scim` kam
mit #029 in die Proxy-Allowlist und stand in keinem Test; die Zeile ließ sich entfernen, ohne
dass etwas rot wurde. `OEFFENTLICH` ist jetzt exportiert, und der Test vergleicht **beide**
Richtungen: wer die Allowlist erweitert, muss es aufschreiben. Dasselbe Muster wie die Zahlen in
`CLAUDE.md`, die dreimal der Wirklichkeit nachliefen.
- **Ein Rückspielskript rät nicht.** In `sicherung.md` stand `${ANHAENGE_PFAD:-/opt/…}` — ein
Rückfallwert, der zu keiner Installation passte, in einem Skript mit `rm -rf`. Jetzt `:?`: fehlt
die Variable, bricht es ab. Ein geratener Pfad ist beim Sichern eine leere Datei und beim
Zurückspielen ein Schaden.
- **Die Zugweite eines Sortiertests wird gemessen, nicht gesetzt.** Eine feste Pixelzahl ist eine
Zusage über das Layout, die niemand pflegt — und sie war falsch, seit #030 und #031 jeden
Abschnitt höher gemacht haben.
- **Protokoll-Waisen werden nicht erraten.** Eine Zeile, deren Gegenstand fort ist, über den Namen
im Json zu greifen wäre naheliegend und falsch: derselbe Name steht in echter Historie einer noch
bestehenden Zeile. Dass eine Protokollzeile ihren Gegenstand überlebt, ist der Grund, warum
`entitaetId` kein Fremdschlüssel ist.
### Probleme
- **Die drei wertvollsten Befunde waren Gegenproben, die grün blieben.** Das ist inzwischen das
wiederkehrende Muster dieses Projekts: nicht der falsche Code fällt auf, sondern der Code, der
*aussieht*, als prüfte er etwas.
- Der **Link in der Abgleich-Meldung** war ein 404 (`/verwaltung/verzeichnis` statt
`/admin/verzeichnis`) — die eine Meldung, die an alle Administratoren geht, im Moment eines
Ausfalls. Der bestehende Test prüfte, dass der **übergebene** Link im Rumpf steht. Das ist
richtig und sagt nichts darüber, ob der Pfad existiert.
- Der **Anhang-Handler** hatte keinen einzigen Test. Die ganze Rechteprüfung ließ sich durch einen
hartcodierten ADMIN ersetzen, Suite grün. Das ist die einzige Stelle, die rohe Dateibytes
ausliefert. Bei SCIM war das Muster schon da — hier wurde es nicht angewandt.
- **`/api/scim`** in der Allowlist, siehe oben.
- **Ein offener Ereignisstrom überlebte das Deaktivieren eines Kontos.** Gefunden durch Lesen, nicht
durch einen Test. Dabei fiel auf, warum: die Attrappe der Sitzung war `{}` — ohne `benutzerId`
ließe sich eine Nachprüfung nicht einmal zeigen. **Eine Attrappe, die nicht die Form der
Wirklichkeit hat, prüft etwas anderes als die Wirklichkeit.** Dieselbe Lehre wie beim
Graph-Doppelgänger in #028, nur von der anderen Seite.
- **Die E2E-Suite war seit Monaten rot**, vier Ursachen, keine ein Produktfehler: ein Test, der die
Felder des Zustands aufzählt und `anzahlAnhaenge` nicht kannte (#033); ein Selektor, der seit #020
zweimal traf; eine feste Zugweite von 300 px bei 532 px Abstand (#030/#031); und
Entwicklungsdaten, die vom Seed abwichen. Niemand hat es gemerkt, weil `pnpm test:e2e` in keinem
Tor steht — das ist jetzt #038 und keine Fußnote.
- **Ein Befund des Reviews war falsch:** der Zustands-Handler habe keinen Vitest-Test. Er hat acht
direkte Aufrufe. Nachgeprüft, bevor etwas geändert wurde — ein Agentenbericht ist eine Behauptung,
keine Feststellung.
- **Ein `DELETE` von 105 Zeilen wurde abgelehnt**, und das war am Ende richtig: der im Issue
vorgesehene Weg erledigte es von selbst, weil das Aufräumen über das Präfix beim ersten Lauf auch
die Altlast mitnimmt. Eine Sicherung der Entwicklungsdatenbank lag vorher.
### Nächster Schritt
- Offen bleiben **#024** (Proxmox-Einzeiler auf echtem PVE-Host), **#037** (zwei Nachzügler) und
**#038** (E2E in ein Tor).
- Danach ist v0.2.0 reif für einen Tag. Vorher gehört #038 entschieden: ein Release, dessen
E2E-Suite nur gelegentlich läuft, ist ein Release mit ungeprüften Zusagen — und genau das hat
diese Sitzung gezeigt.
---
## 2026-10-11 · Sitzung 24 · 02:00–06:10
**Issues:** #038 (E2E in ein Tor), #037 (zwei Nachzügler)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **#038 entschieden und festgeschrieben.** E2E läuft **vor jedem Merge nach `main`**, nicht vor
jedem Commit. Die Regel steht in `CLAUDE.md` mit gemessenen Zahlen, `docs/architektur.md` §5b
trägt sie mit.
- **Die Liste der Zusagen, die nur in E2E hängen** — neun Stück, alle brauchen einen echten
Browser, keine lässt sich nach Vitest holen. Steht als Tabelle in `CLAUDE.md`.
- **Was sich holen ließ, ist geholt:** `tests/zugang.test.ts` prüft jetzt strukturell, dass jede
`page.tsx` und jeder `route.ts` sein Recht selbst prüft.
- **#037 beide Teile**: `anhaengeDesLaufs` liegt in `queries/` und engt selbst ein; `/meine-geraete`
wird bei 390 px gemessen, mit einem echten `MITARBEITER`-Konto aus dem E2E-Vorlauf.
- 1457 Vitest-Tests (vorher 1454), 115 E2E-Abläufe (vorher 113).
### Entscheidungen
- **Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird.** Gemessen: Bau 14 s, Start 7 s,
Abläufe 256 s — zu den vier Minuten des bestehenden Tores. Vor jedem Merge hätte in Phase 2 acht
Läufe bedeutet und jeden der vier Befunde aus #027 am Tag seiner Entstehung gezeigt. Das ist die
Rechnung, die die Regel trägt.
- **Die Antwort auf #038 ist nicht „verlagern“.** Jeder der 16 Abläufe wurde gegen die Frage
geprüft, ob es ein Vitest-Gegenstück gibt. Was dort hängt, braucht einen Browser: Layout bei
390 px, `BarcodeDetector`, Touch, zwei Kontexte, Zwischenablage, Print-CSS, gerendertes DOM, und
die Hydrierung selbst.
- **Das MITARBEITER-Konto liegt im E2E-Vorlauf, nicht im Seed.** Ein Konto mit bekanntem Passwort
gehört nicht in einen Seed, der auch produktiv läuft. Die Person wird gewählt (erste aktive mit
Gerät), nicht gesetzt — eine feste Kennung wäre eine Annahme über den Seed.
### Probleme
- **Der eigentliche Fund steckte in #038 und war keine Testlücke, sondern eine Konvention ohne
Prüfer.** „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md`; geprüft hat es bis hierher
niemand außer einem Menschen, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten
durchgesehen — ein Ergebnis, das für genau einen Tag gilt. Jetzt liest ein Test sie, und beide
Gegenproben (eine Seite, ein Handler ohne Prüfung) werden rot.
- **Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Für das zweite Konto brauchte
`anmelden` eine Variante, die nicht auf `/` wartet. Ich habe das Warten dabei für **alle**
gelockert — und damit zwei unverwandte Abläufe unzuverlässig gemacht, darunter einen in
`laeufe.spec.ts`, der mit dem Thema nichts zu tun hat. **Sichtbar nur im vollen Durchgang**, nicht
beim Einzellauf: einzeln waren beide grün. Behoben, indem `anmelden` wieder streng wartet und
`anmeldenMit` das Ziel als Parameter nimmt; dazu beide auf `load`, denn ein `waitForURL` endet an
der Adresse, nicht am geladenen Dokument.
Dass genau dieser Fehler in derselben Sitzung passiert ist, in der die Regel „E2E vor jedem Merge“
entstand, ist die beste Begründung für sie: ohne vollen Durchgang hätte ich zwei rote Abläufe
gemergt.
### Nächster Schritt
- Offen bleibt nur noch **#024** (Proxmox-Einzeiler auf einem echten PVE-Host) — und das verlangt
einen Host, den es hier nicht gibt.
- **v0.2.0 ist damit reif für einen Tag**: Changelog-Abschnitt, Devlog-Phasenzusammenfassung, Tag,
Release-Notiz. Die Bedingung aus Sitzung 23 („vorher gehört #038 entschieden“) ist erfüllt.
---
## Phase 2 abgeschlossen: was aus dem Plan wurde
**38 h 35 min**, geschätzt waren **90 h** für die zwölf Issues dieser Version. Acht davon waren
Meilensteine (M15–M22), die übrigen entstanden **während** der Arbeit — und zwei der nützlichsten
(#036, #038) stammen gar nicht aus einem Plan, sondern aus der Durchsicht.
### Was dazugekommen ist
| | v0.1.0 | v0.2.0 |
|---|---|---|
| Quelltext unter `src/` | rund 30.000 Zeilen | **40.781 Zeilen** (ohne erzeugten Prisma-Client) |
| Datenmodell | 20 Tabellen, 9 Aufzählungen, 2 Migrationen | **25 Tabellen, 13 Aufzählungen, 14 Migrationen** |
| Datenbankregeln namentlich | — | **56** (CHECKs, partielle Indizes, Trigger, zusammengesetzte Fremdschlüssel) |
| Vitest | 1131 Tests | **1457 Tests** in 61 Dateien |
| Ende-zu-Ende | 101 Abläufe | **115 Abläufe** in 16 Dateien |
| Verzeichnisquellen | LDAP | **LDAP, Microsoft Graph, SCIM** |
| Dokumentation | 9 Kapitel, 14 Issue-Dateien | 9 Kapitel, 8 Betriebsseiten, **38 Issue-Dateien** |
Der Seed ist unverändert bei 7 Vorlagen mit 155 Schritten — nachgezählt, nicht übernommen.
### Die acht Meilensteine
| | | Zeit |
|---|---|---|
| M15 | Quellenarten und Microsoft-Graph-Quelle (#028) | 4 h 10 |
| M16 | SCIM-Endpunkt als Alternative zum Abgleich (#029) | 4 h 05 |
| M17 | Wiederverwendbare Bausteine (#030) | 2 h 25 |
| M18 | Abteilungsspezifische Zusatzabschnitte (#031) | 1 h 35 |
| M19 | Anhänge und Fotos an Schritten (#032) | 3 h 05 |
| M20 | Live-Aktualisierung per SSE statt Polling (#033) | 1 h 40 |
| M21 | Benachrichtigungen per Mail und Webhook (#034) | 3 h 30 |
| M22 | Mitarbeiter-Selbstauskunft (#035) | 2 h 50 |
### Was diese Phase gelehrt hat
**Die teuersten Fehler waren dreimal derselbe: eine Zusage ohne Prüfer.** Nicht falscher Code —
Code, der *aussieht*, als prüfte er etwas.
- Ein Kommentar sagte, ein `select` hole Notizen „gar nicht erst“, während ein anderer Codepfad den
Test rettete (#035).
- `docs/betrieb/sicherung.md` sagte „kein Upload-Verzeichnis“, nachdem die Anhänge dazugekommen
waren — eine Sicherung nach der alten Anleitung hätte still Daten verloren (#032). Dasselbe
Dokument hatte in #036 wieder einen Satz, der zu keiner Installation passte.
- „Jede Seite prüft selbst“ steht seit #007 in `CLAUDE.md` und hielt bis #038 **nur durch Nachsehen
von Hand**. Der Review hat alle 27 Seiten durchgesehen — ein Ergebnis, das für genau einen Tag
gilt.
**Die Gegenprobe ist das Werkzeug, das sie findet.** Regel brechen, Test muss rot werden. In dieser
Phase blieben fünf Gegenproben grün, und jede davon war ein Befund: `PATCH remove` wirkte gar nicht,
Entra IDs `"True"` wäre beim *Aktivieren* kaputt gewesen, der Anhang-Handler hatte keinen Test,
`/api/scim` stand in keiner Allowlist-Prüfung, und der Link in der Abgleich-Meldung war ein 404.
**Ein Fehler, der nur in einer Richtung sichtbar ist, ist der teuerste.** Entra ID schickt `active`
als `"True"`/`"False"`. Beim Deaktivieren kommt mit einem reinen `value === true` zufällig das
Richtige heraus — nur das Aktivieren wäre kaputt gewesen: ein wieder freigeschaltetes Konto, das für
immer inaktiv bleibt.
**Eine Attrappe ohne die Form der Wirklichkeit prüft etwas anderes als die Wirklichkeit.** Zweimal
gelernt, von beiden Seiten: der Graph-Doppelgänger fand einen echten Fehler, **weil** er den Filter
wirklich auswertete (#028); die Sitzungs-Attrappe `{}` verdeckte einen, weil sie keine `benutzerId`
hatte (#036).
**Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand.** Das Anmelde-Warten für einen
neuen Fall aufzuweichen machte zwei unverwandte Abläufe unzuverlässig — sichtbar nur im vollen
Durchgang (#037).
### Wo die Zeit hinging
Die beiden Verzeichnis-Meilensteine (#028, #029) waren mit 8 h 15 zusammen die teuersten, und zwar
**nicht** wegen der Protokolle: beide liefen gegen Doppelgänger, und in beiden Fällen entschied die
Strenge der Attrappe, ob ein Fehler gefunden wurde. Die eigentliche Arbeit war, die Weiche an genau
einer Stelle zu halten und beide Richtungen eines CHECKs aufzuschreiben — was eine Art braucht, und
was sie **nicht haben darf**.
### Was offen bleibt
**#024** — der Proxmox-Einzeiler ist auf einem echten PVE-Host nie gelaufen. Das steht seit v0.1.0
so da und wandert mit dem Tag auf v0.3.0: es ist eine **Gegenprüfung** und kein fehlendes Stück. Das
Skript ist `shellcheck`-geprüft und im `--dry-run` gefahren, der Installationsteil läuft vollständig
in einem `debian:13`-Container. Was fehlt, ist `pct create` auf einem echten Host — und der verlangt
einen Host, den diese Umgebung nicht hat. Die Prüfliste liegt bereit.
Zwei Grenzen gehören dazu genannt, beide in den Issues festgehalten: **gegen einen echten
Entra-Mandanten** und **gegen echte SCIM-Anbieter** ist nichts gelaufen. Die Formen in den Tests
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
`docs/betrieb/scim.md`.
---
## 2026-10-23 · Sitzung 25 · 11:00–15:10
**Issue:** #039 (M23)
**Version:** 0.3.0 (unveröffentlicht)
### Erledigt
- **Bedingte Abschnitte und Schritte**, der erste Meilenstein von Phase 3. Eine Bedingung hängt an
einer Variable des Laufs oder am Zustand eines anderen Schritts; die Auswertung läuft bei jedem
Lesen und sitzt in `src/lib/bedingungen.ts`.
- **Der Seed nutzt es am echten Fall**: „Mitarbeiter-Onboarding“ hat die Bedingung auf `konto_typ`,
beide Konto-Abschnitte bleiben drin und schließen sich aus. Neun Schritte, die niemand mehr
wegklickt.
- Editor, Abarbeitung, Druck, Export, Import, Entwurf und Seed — sieben Wege, alle mit Tests.
- 1496 Vitest-Tests (vorher 1457), davon 29 neue. Klickprobe am gebauten Stand: Bildschirm, Druck
und 390 px zeigen dasselbe.
### Entscheidungen
- **Laufend auswerten, nicht beim Start festschreiben.** Für die Abteilungsabschnitte (#031) ist
Festschreiben richtig: die Abteilung steht beim Start fest. Für Bedingungen nicht — die Antwort auf
„ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit. `geltendeStruktur` legt die
Reihenfolge der zwei Filter fest, und ein struktureller Test prüft, dass alle drei Schichten sie
rufen.
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte. Das ist
genau das Verhalten von vorher; eine erfundene Antwort wäre die Verschlechterung. Ein Abschnitt zu
viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
- **`OFFEN` ist eine Antwort, keine Lücke** — der scharfe Gegensatz zur nicht gesetzten Variable.
Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an sichtbar, also das
Gegenteil des Zwecks.
- **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine Verschachtelung. Wer zwei
braucht, nimmt zwei Abschnitte. Ein Formular für beliebige Ausdrücke wäre eine kleine
Abfragesprache, und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist schlimmer als einer zu
viel.
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Vier Wege schreiben eine Struktur neu
und geben neue Kennungen: Import, Export, Seed, `entwurfSpeichern`. Eine Kennung überlebt keinen
davon.
### Probleme
- **Zwei stille Datenverluste, und der zweite war älter als dieser Meilenstein.**
- Der **Import** verwarf `nurFuerAbteilungen`: der Export schreibt es seit #031, das Schema nimmt
es an, der Import setzte es nicht. Eine exportierte Vorlage verlor ihre Abteilungsabschnitte.
- Die **tiefe Kopie** verwarf es ebenfalls — damit verlor **jedes „Bearbeiten“** einer Vorlage alle
Abteilungseinschränkungen. Beide Kopierstellen hatten den Code einzeln ausgeschrieben und
vergaßen dieselben Felder.
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
als die Abfrage liefert; die Daten waren da, der Code konnte sie nicht sehen. Jetzt aus Prisma
abgeleitet — ein neues Feld ist dort automatisch vorhanden. Und bewacht von einem Test, der Kopie
und Original **Feld für Feld** vergleicht, nicht stichprobenweise.
- **Gefunden hat beides die Klickprobe am gebauten Stand**, nicht die Suite: im Editor stand
„Bedingung gesetzt“, und nach „Bearbeiten“ war sie weg. Genau dafür steht diese Probe in
`CLAUDE.md`. Dreizehn Gegenproben liefen in dieser Sitzung, alle wurden rot — und trotzdem lag der
teuerste Befund außerhalb von allen.
- **Zwei Schemas für dieselbe Form** liefen fast auseinander: Austauschformat und Editor-Struktur
brauchen die Pfad-Form, und TypeScript meldete die Abweichung erst, als die Typen aufeinandertrafen.
Jetzt eine Stelle.
- **Ein Mangel an der neuen Komponente**, beim Klicken aufgefallen: die Auswahlfelder standen in
einem umhüllenden `label`, und ihr zugänglicher Name schluckte den Text der gewählten Option mit.
Derselbe Fehler wie bei der Adresse im Verzeichnis-Formular (#016) — dort war es ein Reviewbefund.
- **Zwei eigene Testfehler**: dreimal versucht, eine Bedingung an einer **veröffentlichten** Version
zu setzen (die ist unveränderlich, dafür sorgen Trigger), und `vorlageBearbeiten` mit
`entwurfHolenOderErzeugen` verwechselt. Beide Male hat die Datenbank es gesagt.
### Nächster Schritt
- **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den
Freigeber für das Blatt.
---
## 2026-10-24 · Sitzung 26 · 22:30–01:40
**Issue:** #040 (M24)
**Version:** 0.3.0 (unveröffentlicht)
### Erledigt
- **Vier-Augen-Freigabe.** Neues Recht `freigeben`, Pflicht an der Version, Freigeben und
Zurückweisen samt Begründung, Aufforderung an die Berechtigten, Häkchen im Editor, Anzeige an der
Versionsliste.
- 1526 Vitest-Tests (vorher 1496), davon 22 neue in `tests/freigabe.test.ts`.
- Klickprobe am gebauten Stand: elf Prüfungen, alle grün.
### Entscheidungen
- **Das Recht `freigeben` hat nicht `TECHNIKER`.** Sonst hängt die Vier-Augen-Regel daran, dass
zufällig ein zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
Rechten — und das steht im Protokoll.
- **Das Recht allein genügt nicht.** Wer an dem Lauf gearbeitet hat, gibt ihn nicht frei, auch nicht
als Administrator. Die Prüfung sitzt in `hatDaranGearbeitet`, an **einer** Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Hälfte (der Ersteller) hält zusätzlich ein CHECK.
- **Eine Zuweisung ist noch keine Arbeit.** Wer zugewiesen wurde und nichts getan hat, darf
freigeben — wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn.
- **Kein fünfter `LaufStatus`** für „zurückgewiesen“: der Lauf geht auf `IN_ARBEIT`, die Begründung
steht in einem Feld und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad betroffen, der
heute vier Fälle kennt.
- **Die Pflicht hängt an der Version**, nicht an der Vorlage: eine veröffentlichte Version ist
unveränderlich, ein laufender Lauf behält also seine Regel.
### Probleme
- **Die Aufforderung muss außerhalb der Transaktion entstehen.** Innerhalb würde sie mit dem Fehler
zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand weiß. Gelöst mit einem eigenen
Fehlertyp `FreigabeFehlt`, den `laufAbschliessen` außen fängt; dafür musste die Transaktion in
eine eigene Funktion. Ein Zeichenkettenvergleich auf die Meldung wäre eine Zusage über ihren
Wortlaut gewesen.
- **Drei rote Punkte in der Klickprobe waren meine eigenen Testfehler**, und einer ist lehrreich:
ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand, weil es den
Router-Cache von Next trifft. Die Action revalidiert den Pfad; ein Benutzer sieht das Ergebnis
**ohne** Neuladen. Nachgewiesen, bevor ich etwas geändert habe: in einem frischen Browser-Kontext
stand alles richtig da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
Die beiden anderen: ein Selektor traf versteckten Protokolltext, und ein Suchmuster `/freigeb/`
traf nichts — in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge gar nicht vor. Dreimal
gesucht, bis es auffiel.
- **Die Freigabepflicht wäre bei „Bearbeiten“ verloren gegangen**, hätte #039 es mir nicht gerade
gezeigt: dort verwarf die tiefe Kopie `nurFuerAbteilungen` und `bedingung`. Diesmal von Anfang an
mitgenommen — und das ist der ganze Wert eines Befundes, der verstanden statt nur behoben wurde.
### Nächster Schritt
- **M25 (#041)** PDF-Übergabeprotokoll. Der Freigeber steht jetzt am Lauf und gehört auf das Blatt.
+4 -9
View File
@@ -11,15 +11,10 @@ Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut.
## Aufgaben ## Aufgaben
Geschnitten am 2026-10-11, nach dem Tag von v0.2.0. Vier Meilensteine, 34 h geschätzt; die - [ ] PDF-Übergabeprotokoll mit Unterschrift
Reihenfolge ist nicht beliebig. - [ ] Bedingte Schritte und Abschnitte
- [ ] Vier-Augen-Freigabe
| | Issue | Warum an dieser Stelle | - [ ] Weitere Schritt-Typen
|---|---|---|
| M23 | [Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | zuerst, weil es als einziges am Datenmodell und an der Abschlussregel arbeitet — und weil der echte Fall im Seed steht: „Mitarbeiter-Onboarding“ trägt beide Konto-Abschnitte |
| M24 | [Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | danach, weil der Abschluss dann einmal angefasst ist |
| M25 | [PDF-Übergabeprotokoll](041-pdf-uebergabeprotokoll.md) | nach M24, damit das Blatt den Freigeber nennen kann |
| M26 | [Weitere Schritt-Typen](042-weitere-schritt-typen.md) | zuletzt, weil `UNTERSCHRIFT` erst im PDF einen Zweck hat |
## Vorleistung im MVP ## Vorleistung im MVP
+1 -11
View File
@@ -2,7 +2,7 @@
**Status:** offen **Status:** offen
**Im Tracker:** [#38](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/38) **Im Tracker:** [#38](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/38)
**Meilenstein:** v0.3.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, infra **Labels:** phase-2, infra
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** –
@@ -53,13 +53,3 @@ nachprüfen musst“) abgehakt ist. Die Liste ist der Prüfplan, nicht dieses Is
`status ok` liefert `status ok` liefert
- [ ] `pct exec <ctid> -- ss -ltn` zeigt keinen Port 5432 - [ ] `pct exec <ctid> -- ss -ltn` zeigt keinen Port 5432
- [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert - [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert
## Verschoben auf v0.3.0 (2026-10-11)
Stand bis hierher auf v0.2.0 und hat den Tag nicht aufgehalten, weil es eine **Gegenprüfung** ist
und kein fehlendes Stück: `deploy/proxmox/laufzettel.sh` ist `shellcheck`-geprüft und im
`--dry-run` gefahren, und der eigentliche Installationsteil (`deploy/install.sh`) läuft vollständig
in einem `debian:13`-Container — PostgreSQL, Bau, Migration, Seed, Dienststart.
Was fehlt, ist der `pct create`-Teil auf einem echten PVE-Host, und der verlangt einen Host, den
diese Umgebung nicht hat. Die Prüfliste steht oben; sie wartet unverändert.
+8 -63
View File
@@ -1,10 +1,10 @@
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen # #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
**Status:** erledigt **Status:** offen
**Im Tracker:** [#46](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/46) **Im Tracker:** [#46](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/46)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, test **Labels:** phase-2, test
**Geschätzt:** 1 h **Tatsächlich:** 2 h 10 min **Geschätzt:** 1 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -22,69 +22,14 @@ Vorlagenliste in der Oberfläche, Screenshots, und jede Abfrage von Hand. Bei ei
## Aufgaben ## Aufgaben
- [x] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg — - [ ] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg —
über ihr Präfix, nicht über gemerkte Kennungen (ein abgebrochener Lauf merkt sich nichts) über ihr Präfix, nicht über gemerkte Kennungen (ein abgebrochener Lauf merkt sich nichts)
- [x] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen - [ ] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
- [x] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen - [ ] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
- [x] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert, - [ ] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
darf den Nachlauf nicht überspringen darf den Nachlauf nicht überspringen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E` - [ ] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
- [x] Ein abgebrochener Lauf lässt ebenfalls nichts stehen - [ ] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
## Beim Umsetzen dazugekommen
- **Die Beschreibung oben war zur Hälfte überholt.** „Bei jedem Lauf kommen drei dazu“ stimmte beim
Schreiben, aber nicht mehr: alle 105 Reste stammen aus einem Fenster von sechzehn Stunden am
25./26. September, und seither ist keine dazugekommen. `tests/e2e/vorlagen-editor.spec.ts` räumt
seit [#018](018-e2e-anleitung-pdf.md) über das Präfix auf, und das hält. Nachgewiesen an den
Zeitstempeln der Zeilen, nicht vermutet.
- **Dafür gab es eine neue Lücke, an anderer Stelle:** `anhaenge.spec.ts` (#032) und
`live.spec.ts` (#033) legen `ZZ E2E`-Läufe an und hatten **keinen** Nachlauf. In der
Entwicklungsdatenbank stand von ihnen nichts — nicht weil sie aufräumten, sondern weil die
E2E-Suite dort seit dem 26. September nicht mehr gelaufen war.
- **Geräumt wird jetzt zentral und an einer Stelle**: `tests/e2e/aufraeumen.ts`. Ein `afterAll` je
Datei ist die falsche Form für diese Zusage — beim nächsten Meilenstein fehlt wieder einer.
- **Vor *und* nach dem Lauf.** Ein `globalTeardown` läuft bei Testfehlern und bei Zeitabläufen, aber
nicht bei `Strg+C`. Der Vorlauf fängt genau das: ein Vorlauf lässt sich nicht überspringen. Er
**sagt es laut** („Rückstand aus einem früheren Lauf: …“), denn still wegzuräumen hieße, die
Ursache zu verdecken.
- **Das Präfix ist `ZZ` und nicht `ZZ E2E`.** Die Abläufe benutzen es in fünf Schreibweisen
(`ZZ E2E Editor …`, `ZZ-E2E-<zeit>-PC-01` — ein Hostname darf kein Leerzeichen haben —,
`ZZPruef…`, `ZZQuelle…`, `ZZ Abteilung …`). Alle fünf sind sinnvoll, alle fünf beginnen mit `ZZ`.
Darauf zu greifen deckt sie ab, ohne sechzehn Dateien umzubenennen.
- **Ein struktureller Test bewacht das** (`tests/betriebsstand.test.ts`): er liest die Abläufe und
schlägt an, wenn einer Zeilen anlegt, ohne das Präfix zu benutzen — und prüft, dass Vor- und
Nachlauf registriert sind. Er fand beim ersten Durchgang sofort zwei Dateien, die ich übersehen
hätte.
- **Protokoll-Waisen werden nicht erraten.** Eine Zeile, deren Gegenstand schon fort ist, bleibt
stehen; sie über den Namen im Json zu greifen wäre naheliegend und falsch, weil derselbe Name in
echter Historie einer noch bestehenden Zeile steht. Dass eine Protokollzeile ihren Gegenstand
überlebt, ist der Grund, warum `entitaetId` kein Fremdschlüssel ist.
## Was der erste echte Lauf zutage brachte
Der E2E-Durchgang war die ganze Zeit **rot**, und niemand hat es gemerkt — `pnpm test:e2e` steht
nicht im Tor vor dem Commit (Reviewbefund zu [#029](029-scim-endpunkt.md)). Sieben Fehlschläge, vier
Ursachen, keine davon ein Produktfehler, alle vier behoben:
- `laeufe.spec.ts` zählte die Felder des Zustands auf und kannte `anzahlAnhaenge` nicht — das kam mit
#033 dazu. Die Aufzählung ist Absicht und bleibt: der Zustand ist die Antwort, die am häufigsten
hinausgeht, und ein Feld, das sich einschleicht, wandert ungesehen mit.
- `stammdaten.spec.ts` suchte „kommt aus dem Verzeichnis“ — seit #020 steht derselbe Satz auch am
Vorgesetzten-Feld, also zweimal auf der Seite. Playwright wies das als „strict mode violation“ ab.
- `vorlagen-editor.spec.ts` zog am Handy feste 300 px. Der Abstand zwischen den Anfassern ist
**532 px**, seit mit #030 und #031 Bausteinverweis und Abteilungsfilter in jeden Abschnitt kamen —
gemessen, nicht geschätzt. Dazu lag der erste Anfasser bei `y = -115`, also außerhalb des
Sichtfensters: der Touch-Start traf nichts. Jetzt wird erst gescrollt und die Zugweite aus dem
Layout genommen. Eine feste Pixelzahl in einem Sortiertest ist eine Zusage über das Layout, die
niemand pflegt.
- `onboarding.spec.ts` prüft den `New-ADUser`-Befehl zeichengenau und scheiterte an
`DC=firma,DC=de` statt `DC=firma,DC=local`: die **Entwicklungsdaten** wichen vom Seed ab, und der
Seed setzt bestehende Abteilungen absichtlich nicht zurück. Der Vorlauf prüft das jetzt und sagt,
was zu tun ist — statt einer Meldung, die nach einem Fehler im Befehlsbau aussieht.
**Daraus folgt ein eigenes Issue:** `pnpm test:e2e` gehört ins Tor oder mindestens in eine
nachvollziehbare Regel, sonst wiederholt sich das. Siehe [#038](038-e2e-im-tor.md).
+19 -74
View File
@@ -1,10 +1,10 @@
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle # #028 M15 – Quellenarten und Microsoft-Graph-Quelle
**Status:** erledigt **Status:** offen
**Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48) **Im Tracker:** [#48](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/48)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min **Geschätzt:** 10 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -17,94 +17,39 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
## Zu entscheiden ## Zu entscheiden
- [x] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder - [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
`url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht, `url`, `bindDn` und `basisDn` sind dann je Art unterschiedlich belegt; was eine Art braucht,
prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit prüft der Service, nicht die Datenbank. Die Alternative wäre eine Tabelle je Art mit
gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen. gemeinsamer Oberklasse — mehr Struktur, als drei Arten rechtfertigen.
- [x] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den - [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das Platzhalter bei einer Graph-Quelle **leer** zu lassen, statt etwas hineinzudeuten — und das
in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD. in der Anleitung zu sagen. Wer OU-abhängige Befehle fährt, braucht ein lokales AD.
## Aufgaben ## Aufgaben
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen - [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
- [x] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie - [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf in einer Antwort), Token über Client-Credentials-Flow, Zwischenspeicher bis kurz vor Ablauf
- [x] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der - [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
Vorgesetzte über `$expand=manager($select=id)` Vorgesetzte über `$expand=manager($select=id)`
- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`, - [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`) `userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung - [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen - [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
- [x] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge - [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
unterscheidet sich. Sonst driften die Regeln auseinander unterscheidet sich. Sonst driften die Regeln auseinander
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl - [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
- [x] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist - [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
(App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung) (App-Registrierung, `User.Read.All`, `Directory.Read.All`, Administratoreinwilligung)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster - [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt Abgleich legt an, zweiter aktualisiert ohne Dubletten, ein `accountEnabled: false` setzt
`aktiv = false` `aktiv = false`
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten - [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag - [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden - [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün - [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
- [x] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die - [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md) Gegenprobe gehört in eine Prüfliste wie bei [#024](024-proxmox-echtlauf.md)
## Beim Umsetzen dazugekommen
- **„Deaktiviert in Entra ID“ als *übersprungen* zu melden war falsch — und hätte Leute aktiv
gelassen.** „Übersprungen“ bedeutet im Abgleich „nicht zuordenbar“, und solche Einträge werden
seit #016 absichtlich **nicht** abgeschaltet (ein Konto ohne GUID soll nicht die Person
deaktivieren). Ein gesperrtes Entra-Konto wäre damit genau umgekehrt behandelt worden als im AD.
Gefiltert wird jetzt serverseitig mit `$filter=accountEnabled eq true`, als Gegenstück zum
LDAP-Standardfilter. Gefunden hat es der Test „setzt aktiv = false“ — und nur, weil der
Doppelgänger den Filter wirklich auswertet statt ihn bloß zu empfangen.
- **`URLSearchParams` kodiert `$` zu `%24`.** Die Abfrage hätte `%24select` und `%28` in
`manager($select=id)` geschickt. Graph nimmt das teils an und antwortet teils mit „Invalid expand
clause“ — und eine Adresse, die in einem Mitschnitt anders aussieht als in der Dokumentation,
kostet bei der Fehlersuche eine Stunde. Die Parameter werden jetzt von Hand zusammengesetzt.
- **Die Art einer Quelle ist unveränderlich**, und das steht nicht im Issue. An ihr hängt, was
`externeId` bedeutet: im AD eine `objectGUID`, in Entra ID eine andere GUID. Nach einem Wechsel
würde **kein einziger Mitarbeiter wiedererkannt** — der nächste Abgleich legte die ganze
Belegschaft neu an und setzte die alten Zeilen auf `aktiv = false`, mit allen Läufen und Geräten
daran. Der Dienst weist einen Wechsel ab, die Oberfläche bietet ihn nicht an, und die Anleitung
sagt, was stattdessen zu tun ist.
- **`zuordnungLesen` brauchte die Art als Parameter.** Ohne sie bekäme eine Graph-Quelle mit leerer
Zuordnung die AD-Namen und fragte bei Graph nach `sn` und `objectGUID`, die es dort nicht gibt.
Der Standardwert `LDAP` hält jeden Aufrufer von vor #028 bei seinem Verhalten.
- **Die Zugangs-Helfer mussten in ein eigenes Modul.** `quellenleser.ts` (die Weiche) braucht
`zugangAus`, und `services/verzeichnis.ts` braucht die Weiche für „Verbindung testen“ — zusammen
ein Import-Zyklus. Der läuft in ESM oft, aber nicht zuverlässig, und ein Modul, das beim Laden
halb leer ist, ergibt Fehler, die niemand liest. Jetzt liegt beides in
`src/server/verzeichnis/zugang.ts`.
- **`SCIM` steht im Enum, wird aber abgewiesen.** Die Weiche braucht #029, und ein Wert, der
stillschweigend nichts tut, wäre hier besonders teuer: ein Abgleich, der null Einträge liest und
`ERFOLG` meldet, hätte über die Abschaltregel die ganze Belegschaft deaktiviert — genau der Fall,
der in #016 einen Reviewbefund gekostet hat. Mit eigenem Test.
- **Die Felder je Art hält die Datenbank fest.** Ein CHECK verlangt bei `LDAP` die LDAP-Felder
**und** verbietet die Graph-Felder, bei `GRAPH` umgekehrt. Ohne den zweiten Teil wäre eine
Graph-Quelle mit `basisDn` möglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
- **Der Vorgesetzte kommt bei Graph direkt**, nicht über eine DN-Auflösung. `RoherEintrag` hat dafür
ein zweites Feld (`vorgesetzterDirekt`) und nicht ein Feld mit zwei Bedeutungen: ein DN und eine
GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal das andere trägt, verwechselt
irgendwann jemand.
- **Der Token-Zwischenspeicher hat 60 Sekunden Sicherheitsabstand.** Ein Token, das in zehn Sekunden
abläuft, ist für eine Seite, die zwei Sekunden braucht, formal noch gültig — und in der Mitte der
nächsten nicht mehr. Der Fehler wäre ein Abgleich, der nach der dritten Seite mit 401 abbricht.
- **Eine Reißleine bei 1000 Seiten.** Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
eine Schleife bis zum Speicherende — still, weil jeder einzelne Abruf gelingt.
- **Zwei eigene Testfehler**, keine Produktfehler: die Token-Grenze liegt bei +3540 s und nicht bei
„so ungefähr einer Stunde“, und die Klickprobe zählte die Felder der Bearbeiten-Formulare der
bestehenden Quellen mit.
## Noch offen, und benannt
Gegen einen **echten Entra-Mandanten** ist das nicht gelaufen. Geprüft ist es gegen einen
HTTP-Doppelgänger mit aufgezeichneten Antworten, und deren Form stammt aus der Dokumentation, nicht
aus einem Mitschnitt. Die Prüfliste steht am Ende von
[`docs/betrieb/entra-id.md`](../betrieb/entra-id.md) — acht Punkte, darunter ein Mandant mit mehr als
999 Benutzern für den Seitenabruf. Dieselbe Art offener Punkt wie [#024](024-proxmox-echtlauf.md).
+19 -115
View File
@@ -1,10 +1,10 @@
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich # #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
**Status:** erledigt **Status:** offen
**Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49) **Im Tracker:** [#49](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/49)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend **Labels:** phase-2, backend
**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min **Geschätzt:** 12 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -16,137 +16,41 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic
## Zu entscheiden ## Zu entscheiden
- [x] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim - [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das lokalen Passwort (argon2). Es wird **einmal** angezeigt und ist danach nicht mehr lesbar. Das
Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den Token identifiziert die Quelle und damit den Kunden; es ist der einzige Mandantenbeweis, den
ein Push mitbringt. ein Push mitbringt.
- [x] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel - [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die des ganzen Projekts, und ein Identitätsanbieter, der löschen will, bekommt `204` — aber die
Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie Zeile bleibt, weil Läufe an ihr hängen. Das muss in der Anleitung stehen, sonst wirkt es wie
ein Fehler. ein Fehler.
## Aufgaben ## Aufgaben
- [x] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST), - [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
`Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`, `Users/{id}` (GET, PUT, PATCH, DELETE), dazu `ServiceProviderConfig`, `Schemas`,
`ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen `ResourceTypes` — ohne die drei weigern sich manche Anbieter, überhaupt anzufangen
- [x] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die - [ ] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die
Hülle unter `(app)` liegt hier nicht darüber (CLAUDE.md) Hülle unter `(app)` liegt hier nicht darüber (CLAUDE.md)
- [x] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`, - [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
`emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` `emails[primary]` → `mail`, `active` → `aktiv`, `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`
für `department` und `manager` für `department` und `manager`
- [x] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen - [ ] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen
`totalResults`/`itemsPerPage`/`startIndex`; Fehler als `urn:ietf:params:scim:api:messages:2.0:Error` `totalResults`/`itemsPerPage`/`startIndex`; Fehler als `urn:ietf:params:scim:api:messages:2.0:Error`
- [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken — - [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
nicht der ganze Standard nicht der ganze Standard
- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person - [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer) - [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar - [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID - [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden): - [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
anlegen, aktualisieren, deaktivieren, wieder aktivieren anlegen, aktualisieren, deaktivieren, wieder aktivieren
- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle - [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
- [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts - [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile - [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
- [x] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort - [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
- [x] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt - [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
geschickt geschickt
## Beim Umsetzen dazugekommen
- **Zwei Gegenproben blieben grün, und beide deckten etwas auf.** Von acht Gegenproben (Regel
brechen, Test muss rot werden) schlugen sechs sofort an. Die zwei stillen waren die wertvollen:
- **`PATCH remove` wirkte gar nicht** — ein echter Fehler, kein Testloch. `personAendern` hatte
einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht mitgeschickt“ las und den
alten Wert stehen ließ. Nimmt jemand beim Anbieter die Abteilung aus dem Profil, stand sie in
Laufzettel weiter — sichtbar, gepflegt aussehend, falsch. Nötig war der Schalter nie:
`patchAnwenden` setzt auf dem vorhandenen Stand auf und liefert deshalb immer einen
**vollständigen** Zielzustand. Der Unterschied zwischen `PUT` und `PATCH` liegt vor der
Funktion, nicht in ihr. Schalter entfernt, Test ergänzt, Gegenprobe schlägt jetzt an.
- **Entra ID schickt `active` als Zeichenkette `"True"`/`"False"`.** Das war richtig umgesetzt,
aber nur halb geprüft: der Deaktivierungs-Test lief gegen `"False"`, und mit einem reinen
`value === true` kommt dort **zufällig** das Richtige heraus. Nur das Aktivieren wäre kaputt
gewesen — ein Konto, das wieder freigeschaltet wird und in Laufzettel für immer inaktiv bleibt.
Der Fehler wäre also nur in einer Richtung sichtbar gewesen. Jetzt steht `ENTRA_PATCH_AN` mit
`"True"` als eigene aufgezeichnete Form daneben.
- **`manager` war im Issue genannt und wäre fast untergegangen.** Die Zuordnung nennt die
Enterprise-Erweiterung für `department` **und** `manager`; umgesetzt war zunächst nur die
Abteilung. Dazugekommen sind: `vorgesetzterAus` liest alle drei Formen, die die Anbieter schicken
(`{value}`, `{$ref}` und die nackte Zeichenkette — letztere nicht standardkonform, aber authentik
schickt sie), die Auflösung über die Laufzettel-Kennung, und `manager` im `Schemas`-Endpunkt. Das
Letzte ist das Entscheidende: ein Anbieter liest dieses Schema und schickt nur, was darin steht.
Fehlte es, bliebe der Vorgesetzte dauerhaft leer — **ohne Fehlermeldung auf einer der beiden
Seiten**.
- **`manager` wäre ein Weg über die Quellengrenze gewesen.** Eine Kennung aus einer fremden Quelle
zeigt auf jemanden, den diese hier nicht sehen darf, und über die Vorgesetzten-Anzeige stünde
dessen Name in der Oberfläche. Die Auflösung sucht deshalb nur in der eigenen Quelle; eine
Gegenprobe ohne diese Einschränkung ist rot. Dasselbe für den Fall „sein eigener Vorgesetzter“ —
die Datenbank weist das ohnehin ab, aber dann wäre die ganze Zuweisung daran gescheitert.
- **Ein unbekannter Vorgesetzter lässt die Zuweisung nicht scheitern.** Der Anbieter schickt die
Leute in beliebiger Reihenfolge. Wer an einer noch unbekannten Kennung scheiterte, ließe die halbe
Belegschaft draußen; nachgezogen wird beim nächsten `PATCH`.
- **Ein unverstandener Filter ergibt `400` und nicht „alle Personen“.** Das ist die wichtigste
Einzelentscheidung dieses Meilensteins. Ihn zu ignorieren hieße, alle zu liefern — und der
Anbieter schlösse daraus, die gesuchte Person gäbe es schon, und legte sie **nie** an. Unterstützt
wird genau `userName eq "…"`, die Frage, die jeder Anbieter vor dem Anlegen stellt.
- **Ein Fehler im Nachbarcode aus #028**: in `verbindungTestenAktion` stand zweimal dieselbe
Bedingung (`ergebnis.treffer.length === 0`), der zweite Zweig war damit unerreichbar. Genau der
Zweig war in #028 als Reviewbefund eingebaut worden — „gelesen, aber nichts zuordenbar“ sollte
anders klingen als „der Filter findet niemanden“, weil die Ursachen verschiedene sind und den
Administrator an verschiedene Stellen schicken. Wirkungslos, seit er geschrieben wurde. Behoben.
- **„Noch nie abgeglichen“ ist an einer SCIM-Quelle eine Falschaussage**, und „kein Zeitplan“ liest
sich wie ein Mangel. Beides ersetzt: die Pille sagt „nimmt Zustellungen an“, und an der Stelle, an
der bei LDAP der Verbindungstest sitzt, steht die letzte Zustellung. Verbindungstest und „Jetzt
abgleichen“ gibt es dort nicht — ein Knopf, der nur eine Erklärung auswirft, ist kein Knopf. Wer
sie trotzdem über den Dienst ruft, bekommt eine Erklärung statt „unbekannter Fehler“.
- **Der Zeitplan lässt SCIM-Quellen aus, und das ist keine Kosmetik.** Ein Abgleich, der an einer
SCIM-Quelle nichts liest und `ERFOLG` meldet, hätte über die Abschaltregel die **ganze
Belegschaft deaktiviert** — derselbe Fall, der in #016 einen Reviewbefund gekostet hat. Zwei
Stellen halten das: `faelligeQuellen` nimmt sie nicht auf, und die Weiche im Quellenleser wirft.
- **Das Token in keiner Sicht** — geprüft nicht an einem Feld, sondern an der **ganzen**
serialisierten Sicht aus Liste und Detail. Ein später ergänztes Feld fällt damit auch auf. Dazu:
nicht im Protokolleintrag, nicht im HTML der Seite nach dem Neuladen (in der Klickprobe am
gebauten Stand nachgesehen).
- **Dieselbe `401` für „kein Token“ und „falsches Token“.** Die Unterscheidung wäre eine Auskunft
darüber, ob ein geratenes Token die richtige Form hat. Und vor der Hash-Prüfung steht eine
Vorab-Prüfung auf das Präfix: ohne sie würde ein Token mit falschem Präfix gegen jeden Hash
geprüft, eines mit richtigem nur bis zum Treffer — ein Zeitorakel.
- **Die Beschreibungs-Endpunkte verlangen ebenfalls ein Token**, und ihre **Verneinungen** sind der
wichtigere Teil: `bulk`, `sort`, `filter` und `etag` stehen ausdrücklich auf `false`. Ein
Anbieter, der `bulk` für unterstützt hält, schickt Anfragen, die niemand beantwortet, und meldet
das als Fehler der Gegenseite.
- **Die Klickprobe am gebauten Stand fand keinen Bedienfehler** — zwanzig Prüfungen von „Art SCIM
wählbar“ über „Token angezeigt“, eine echte Zustellung mit genau diesem Token, „Person steht in
der Liste“, `DELETE` deaktiviert, Widerruf sperrt, bis „390 px ohne horizontales Scrollen“. Die
Probe hat ihre Daten danach selbst wieder entfernt; das ist die Lehre aus #027.
- **Ein Test aus #028 war überholt**, nicht falsch: er prüfte, dass die Meldung der Weiche „#029“
enthält. Jetzt ist #029 da, und die Antwort bleibt dieselbe — nur ohne Issue-Nummer, denn die
Meldung steht auch in der Oberfläche.
## Was offen bleibt
- **Gegen echte Mandanten ist das nicht gelaufen.** Die Anfrageformen in `tests/scim.test.ts`
stammen aus der Dokumentation von authentik und Entra ID, nicht aus einem Mitschnitt — dieselbe
Grenze wie bei #028, und sie ist dort im Kopfkommentar der Testdatei benannt. Stimmt eine Form
nicht, stimmen Test und Wirklichkeit nicht. Die Prüflisten für den ersten echten Lauf stehen in
[`docs/betrieb/scim.md`](../betrieb/scim.md) unter „Prüfen von Hand“.
- **Keine Paginierung über 200 Einträge** bei `GET /Users` ohne Filter. Die Anbieter fragen
gefiltert; ein voller Abzug ist keine Betriebsart dieses Endpunkts.
- **Kein `/Groups`.** Rollen entstehen beim Anmelden aus den OIDC-Gruppen, nicht aus dem
Provisioning. `ResourceTypes` nennt deshalb nur `User`.
+19 -72
View File
@@ -1,10 +1,10 @@
# #035 M22 – Mitarbeiter-Selbstauskunft # #035 M22 – Mitarbeiter-Selbstauskunft
**Status:** erledigt **Status:** offen
**Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55) **Im Tracker:** [#55](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/55)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** 2 h 50 min **Geschätzt:** 8 h **Tatsächlich:** –
## Ziel ## Ziel
@@ -20,95 +20,42 @@ Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen.
## Zu entscheiden ## Zu entscheiden
- [x] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das - [ ] **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 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 Rollengruppe angehört, wird über `upn` einer `Mitarbeiter`-Zeile zugeordnet und bekommt genau
ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest. ein Recht: `selbstauskunft`. `Benutzer.mitarbeiterId` hält die Zuordnung fest.
Die Alternative — ein Link mit Token je Person — braucht keine Anmeldung, aber jeder 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. weitergeleitete Link ist dann ein Datenleck, und widerrufen müsste man je Person.
- [x] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die - [ ] **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 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 Arbeitsnotizen über Menschen und Zugangsdaten-Platzhalter; eine Selbstauskunft ist keine
Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die Akteneinsicht. Das ist die wichtigste Entscheidung dieses Meilensteins und gehört in die
Anleitung, nicht nur in den Code. Anleitung, nicht nur in den Code.
- [x] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt, - [ ] **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. bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
## Aufgaben ## Aufgaben
- [x] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`. - [ ] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
**Jede** Rollenprüfung in `tests/rollen.test.ts` wächst um eine Spalte — die Matrix ist **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 ausgeschrieben und nicht berechnet, genau damit das auffällt
- [x] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel - [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
- [x] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein - [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
Fehler und kein Zufallstreffer Fehler und kein Zufallstreffer
- [x] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status - [ ] 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 - [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
seine Sicht selbst (CLAUDE.md) seine Sicht selbst (CLAUDE.md)
- [x] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau - [ ] 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 - [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
## Akzeptanzkriterien ## Akzeptanzkriterien
- [x] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen - [ ] 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, - [ ] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
nicht über die Seite nicht über die Seite
- [x] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts` - [ ] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
erkennt eine ungeprüfte Aktion ohnehin) erkennt eine ungeprüfte Aktion ohnehin)
- [x] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung - [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
- [x] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert - [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
- [x] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht - [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
- [x] Am Handy kein horizontales Scrollen - [ ] 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.
-136
View File
@@ -1,136 +0,0 @@
# #036 Reviewbefunde aus der Durchsicht von Phase 2
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, test, docs
**Geschätzt:** 4 h **Tatsächlich:** 3 h 40 min
## Ziel
Acht Meilensteine von Phase 2 (M15–M22) lagen ohne fremden Blick auf `main`. Ein Review-Agent, der
den Code nicht geschrieben hat, hat sie gegen die Akzeptanzkriterien und gegen `CLAUDE.md` geprüft —
mit eigener Datenbank, Gegenproben und einem vollen Suitenlauf.
**Ergebnis:** Die Kernregeln halten. Fünf von fünf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde lagen am Rand — in zwei
Route Handlern, einem Export-Pfad, in Betriebsdokumentation und in Kommentaren.
**Die drei wertvollsten Befunde waren Gegenproben, die grün blieben.** Das ist das wiederkehrende
Muster dieses Projekts: nicht der falsche Code fällt auf, sondern der Code, der *aussieht*, als
prüfte er etwas.
## Behoben
### Schwer
- [x] **Der Link in der Mail „Verzeichnis-Abgleich fehlgeschlagen“ war ein 404.** Gebaut wurde
`/verwaltung/verzeichnis` — `verwaltung` ist der Name des **Rechts**, die Seite liegt unter
`/admin/verzeichnis`. Das ist die eine Meldung, die an **alle** Administratoren geht, und
zwar im Moment eines Ausfalls: der Empfänger klickt und landet auf der
Nicht-gefunden-Seite. Die Pfade stehen jetzt als `MELDUNGS_PFADE` an einer Stelle, und ein
Test prüft **jeden gegen die Routen unter `src/app/`** — ein Pfad, den es nicht gibt, fällt
beim Testlauf auf und nicht im Postfach.
- [x] **Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt**, unter dem
Editor-Platzhalter „wird ersetzt“, und der Import nahm das stillschweigend an. Ein Redakteur
bringt eine noch unveröffentlichte Vorlage in eine zweite Installation — genau der Zweck des
Formats —, und der geteilte Abschnitt kommt dort ohne einen einzigen Schritt an. Baustein-
Abschnitte werden beim Export jetzt **aufgelöst**, wie beim Veröffentlichen; der Verweis
wandert weiterhin nicht mit.
### Mittel
- [x] **Der Route Handler, der Anhänge ausliefert, hatte keinen einzigen Test.** Die ganze
Rechteprüfung ließ sich durch einen hartcodierten ADMIN ersetzen, ohne dass ein Test rot
wurde. Das ist die **einzige** Stelle der Anwendung, die rohe Dateibytes ausliefert. Neu:
`tests/anhang-route.test.ts` fährt den Handler echt, acht Prüfungen — darunter das
Akzeptanzkriterium von #032 („nicht abrufbar **über seine Adresse**“), das bis hierher nur
auf Dienstebene geprüft war. Die Gegenprobe ist jetzt dreifach rot.
- [x] **`/api/scim` in der Proxy-Allowlist stand in keinem Test.** Die Zeile ließ sich entfernen,
ohne dass etwas rot wurde — und ohne sie leitet der Proxy jede Zustellung auf `/anmelden` um:
der Anbieter meldet „Provisioning failed“, in Laufzettel steht nichts, das Provisioning hört
still auf zu arbeiten. `OEFFENTLICH` ist jetzt exportiert, und ein Test prüft **in beide
Richtungen**: wer die Liste erweitert, muss es im Test aufschreiben. Eine Aufzählung, die nur
in einer Richtung geprüft wird, läuft dem Code hinterher.
- [x] **Ein offener Ereignisstrom überlebte Abmelden, Deaktivieren und Sitzungswiderruf.** Das
Recht wurde genau einmal geprüft, beim Aufbau, und Kennung samt Rolle für die Lebensdauer des
Stroms festgehalten. Ein Administrator deaktiviert ein Konto — und eine offen gelassene
Lasche bekommt weiter live Status, Schrittzustände und Zählwerte, bis jemand sie schließt.
`docs/architektur.md` verspricht „ein deaktivierter Benutzer wird auch mit gültigem Cookie
abgewiesen“; für einen Strom galt das nicht. Der Herzschlag prüft jetzt nach
(`sitzungNochGueltig`), eine Herabstufung engt den Strom ein statt ihn zu beenden.
- [x] **`README.md` kannte die Rolle `MITARBEITER` nicht**, und ein Satz daneben war seit #035
falsch: „Unbekannte Benutzer werden beim ersten Login angelegt.“ Wer auf v0.2.0 hebt, liest
das README, nimmt an, für Leute ohne Rollengruppe bleibe alles wie bisher — und sperrt danach
genau diese Leute aus. #035 hatte den Satz in `docs/betrieb/authentik.md` richtiggestellt und
das README stehengelassen. Dazu: der Stand stand weiter auf „in Entwicklung auf v0.1.0“.
- [x] **`docs/betrieb/sicherung.md` nannte einen Anhänge-Pfad, den es nirgends gibt**
(`/opt/laufzettel/daten/anhaenge`) — als Rückfallwert in einem Skript, in dem ein `rm -rf`
steht. `deploy/install.sh` setzt `/var/lib/laufzettel/anhaenge`, und ohne die Variable nimmt
die Anwendung `./daten/anhaenge` relativ zu ihrem Arbeitsverzeichnis. Der Rückfall passte zu
**keinem** der drei. Jetzt `${ANHAENGE_PFAD:?…}`: fehlt die Variable, **bricht** der Befehl
ab, statt zu raten — vor dem `rm -rf`. Dasselbe Dokument hat denselben Fehler in #032 schon
einmal gehabt.
- [x] **Attributnamen wurden nur in der Server Action geprüft**, während sich
`src/server/graph/leser.ts` im Kommentar darauf berief („feste Feldnamen aus
`ATTRIBUT_MUSTER`, also ist hier nichts zu maskieren“). Nachgewiesen: ein Name mit Komma und
`$filter` kam durch den Dienst und schrieb einen **zweiten `$filter`** in die Graph-Adresse.
Hereinkommen konnte er nur mit `verwaltung` oder per Skript, Seed und `psql`, die
Sicherheitswirkung ist also klein — der Befund ist der **Ort** der Regel. Jetzt
`zuordnungPruefen` im Dienst (wirft) und Verwerfen in `zuordnungLesen` (damit eine kaputte
Zeile nicht die Quellenliste sprengt).
### Leicht
- [x] **Drei Kommentare versprachen mehr als der Code hält.** Der Kommentar am Konstanten-Block der
Wartezeiten sagte „nach dem vierten Fehlschlag wird aufgegeben“, während es der fünfte ist —
und das ist der Kommentar, den man zuerst liest. Die Migration der Benachrichtigungen
behauptete „der Dienst prüft dasselbe noch einmal, nur mit besserer Meldung“; tut er nicht,
und er soll es auch nicht (zwei Instanzen hinter einem Lastverteiler fragen beide und
bekommen beide „nein“ — nur der partielle Index hält). **Die Migration selbst bleibt
unverändert:** eine angewandte Migration wird nicht umgeschrieben, so wie eine
veröffentlichte `VorlagenVersion` nicht. Die Richtigstellung steht bei `einreihen`, wo sie
gelesen wird.
- [x] **Der Protokolleintrag „UPN mehrdeutig“ nannte immer „2“**, auch bei sieben Treffern:
`personZuUpn` hatte `take: 2`, und dieselbe Zahl ging in den Eintrag. Wer das Protokoll
liest, liest es, **um den Fall aufzulösen** — er muss wissen, wie viele Zeilen zu bereinigen
sind. `take` entfernt, Test mit vier Treffern.
- [x] **`anzahlSchritte` im Protokoll eines neuen Laufs zählte die Version, nicht den Lauf.** Bei
einer Vorlage mit abteilungsgebundenen Abschnitten (#031) behauptete der Eintrag mehr
Schritte, als der Lauf hat. Das Protokoll ist seit #023 ansehbar.
- [x] **Die Phase-2-Seiten fehlten in der 390-px-Prüfung.** Ergänzt: Bausteinliste, Schnellstart,
Protokoll, Zustellungen — gerade die Tabellen, also die Stellen, an denen horizontales
Scrollen entsteht. Jedes Issue berichtet eine Klickprobe von Hand; eine Probe von Hand ist
kein Regressionstest.
- [x] **Der Graph-Seitenabruf folgte `@odata.nextLink` ohne Host-Prüfung**, mit einem mandantenweit
gültigen Token im Kopf. Graph über TLS zu trauen ist vertretbar — aber `GRAPH_API_URL` ist
absichtlich einstellbar (nationale Clouds, Test-Doppelgänger), und ein Tippfehler darin
schickte das Token woandershin. `naechsteSeite` vergleicht jetzt den Ursprung.
## Nicht behoben, bewusst
- **`anhaengeDesLaufs` liegt in `services/` und wendet keine Mandantensicht an.** Beide Aufrufer
prüfen den Lauf vorher, und die Kommentare sagen das auch — aber es steht gegen die Regel aus
`CLAUDE.md` („`queries/*` wenden ihre Sicht selbst an, damit kein Aufrufer sie vergessen kann“).
Zwei Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst. Eigenes
Issue: [#037](037-anhaenge-lesemodell.md).
- **`/meine-geraete` fehlt weiter in der 390-px-Prüfung.** Die Seite verlangt `selbstauskunft`, und
das hat nur `MITARBEITER`; der E2E-Ablauf meldet sich als Admin an und würde die
Verweigerungsseite messen. Dafür braucht es ein eigenes Konto samt Personenbindung — siehe
[#037](037-anhaenge-lesemodell.md).
- **Das Graph-Token wird einmal je Abgleich geholt**, vor der Seitenschleife. Bei einem Mandanten,
dessen Abgleich länger als die Token-Lebensdauer läuft, hilft der Sicherheitsabstand nicht. Das
gehört auf die offene Prüfliste in `docs/betrieb/entra-id.md` und ist ohne echten Mandanten nicht
zu beurteilen.
## Was der Review *nicht* gefunden hat
Benannt, weil es den Befunden ihr Gewicht gibt: keine `kundeId` oder `quelleId` aus der Eingabe
statt aus Sitzung bzw. Token; kein Lesemodell, das seine Sicht ganz vergisst; keine
Client-Komponente mit einem Wert-Import aus `src/server/*`; kein Geheimnis in einer Sicht, Antwort
oder einem Protokolleintrag; kein gespeichertes `{{generiertes_passwort}}`; kein Codepfad, der
annimmt, dass nur ein Mensch einen Schritt erledigt.
Ein Befund des Reviews war **falsch**: der Zustands-Handler habe keinen Vitest-Test. Er hat acht
direkte Aufrufe in `tests/lauf-ereignisse.test.ts`, darunter „weist ohne Anmeldung ab“ und „liefert
für einen fremden Kunden keinen Strom“. Nachgeprüft, bevor etwas geändert wurde.
-87
View File
@@ -1,87 +0,0 @@
# #037 Zwei Nachzügler aus der Durchsicht von Phase 2
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, test
**Geschätzt:** 2 h **Tatsächlich:** 1 h 50 min
## Ziel
Zwei Befunde aus [#036](036-reviewbefunde-phase-2.md), die mehr sind als eine Korrektur: beide
verlangen einen Umbau, und beide sind heute kein Schaden, sondern eine offene Flanke.
## 1. `anhaengeDesLaufs` ist ein Lesemodell am falschen Ort
`anhaengeDesLaufs(laufId)` liegt in `src/server/services/anhaenge.ts`, nimmt nur eine Lauf-Kennung
und wendet **weder Rollen- noch Mandantensicht** an. Beide Aufrufer
(`src/app/(app)/laeufe/[id]/page.tsx` und `src/server/queries/druck.ts`) prüfen den Lauf vorher, und
die Kommentare sagen das auch — es ist also heute kein Fehler.
Es steht aber gegen die Regel aus `CLAUDE.md` und `docs/architektur.md` §2: Lesemodelle liegen in
`queries/*` und **wenden ihre Sicht selbst an, damit kein Aufrufer sie vergessen kann**. Zwei
Aufrufer in zwei Schichten gibt es schon; der dritte ist der, der es vergisst — und er wird nicht
auffallen, weil die zwei bestehenden korrekt sind.
- [x] Nach `src/server/queries/anhaenge.ts` ziehen, mit `HandelnderBenutzer` als Parameter
- [x] Mandanten- und Rollensicht dort anwenden, nicht beim Aufrufer
- [x] Test: ein Benutzer, der auf andere Kunden beschränkt ist, bekommt eine leere Liste — **ohne**
dass der Aufrufer vorher geprüft hat
- [x] Gegenprobe: die Sicht entfernen, der Test muss rot werden
## 2. `/meine-geraete` fehlt in der 390-px-Prüfung
Die Seite ist das Ergebnis von #035 und ausdrücklich mit „am Handy kein horizontales Scrollen“
abgenommen — von Hand. In `tests/e2e/mobil.spec.ts` steht sie nicht, und sie kann dort nicht
einfach ergänzt werden: sie verlangt das Recht `selbstauskunft`, und das hat nur `MITARBEITER`. Der
Ablauf meldet sich als Admin an und würde die Verweigerungsseite messen — die scrollt garantiert
nicht.
- [x] Ein `MITARBEITER`-Konto für die Abläufe: lokales Passwort, verknüpft mit einer
`Mitarbeiter`-Zeile (der CHECK `Benutzer_mitarbeiter_nur_bei_rolle` verlangt die Verknüpfung)
- [x] Im Seed oder im E2E-Vorlauf? **Im Vorlauf**, denn ein Konto mit bekanntem Passwort gehört
nicht in einen Seed, der auch produktiv läuft
- [x] `/meine-geraete` in die Seitenliste von `mobil.spec.ts`
- [x] Dabei prüfen, was ein `MITARBEITER` auf den **anderen** Seiten sieht: die Rollenmatrix ist in
Vitest vollständig, aber kein Ablauf klickt sie durch
## Akzeptanzkriterien
- [x] `anhaengeDesLaufs` liegt in `queries/`, und ein Test belegt die Sicht ohne Vorprüfung des
Aufrufers
- [x] `/meine-geraete` wird bei 390 px gemessen, mit einem echten `MITARBEITER`
- [x] Beide Gegenproben werden rot
## Beim Umsetzen dazugekommen
- **Das Konto liegt im E2E-Vorlauf, nicht im Seed** — wie im Issue vorgezeichnet, und der Grund hat
sich beim Schreiben bestätigt: ein Konto mit bekanntem Passwort gehört nicht in einen Seed, der
auch produktiv läuft. Es trägt `ZZ` im Namen und fällt damit unter das Aufräumen aus
[#027](027-e2e-reste-vorlagen.md); der Nachlauf meldet es mit („1 Benutzer“).
- **Die Person wird gewählt, nicht gesetzt:** die erste aktive mit mindestens einem Gerät. Eine
feste Kennung wäre eine Annahme über den Seed, und ohne Gerät zeigte die Seite nur ihren
Leerzustand — der Test hätte dann nichts gemessen. Der Vorlauf bricht mit einer Erklärung ab, wenn
es keine solche Person gibt.
- **Ein zweiter Test dazu, den das Issue nicht verlangte:** dass ein `MITARBEITER` auf
`/meine-geraete` **keinen** Link in die Verwaltung angeboten bekommt. Die Rollenmatrix ist in
Vitest vollständig, und seit #035 nennt jeder Navigationseintrag sein Recht — dass das im
**gerenderten** DOM ankommt, prüfte nichts. Gegenprobe: ein Eintrag ohne `recht`, und der Test
wird rot.
## Was dabei schiefging, und warum es hier steht
Für das zweite Konto brauchte `anmelden` eine Variante, die nicht auf `/` wartet — ein
`MITARBEITER` landet auf `/meine-geraete`, weil `startseite(rolle)` das Ziel an der Rolle
entscheidet (#035). Dabei habe ich das Warten für **alle** gelockert: „irgendeine Adresse außer
`/anmelden`“.
Das sah harmlos aus und machte **zwei unverwandte Abläufe** unzuverlässig — und zwar nur im vollen
Durchgang, nicht beim Einzellauf: der nächste Schritt begann, bevor die Übersicht stand. Einer davon
war `laeufe.spec.ts` („verlangt für nicht zutreffend eine Begründung“), der mit dem Thema nichts zu
tun hat.
Behoben: `anmelden` wartet wieder streng auf `/`, `anmeldenMit` nimmt das Ziel als Parameter, und
beide warten zusätzlich auf `load` — ein `waitForURL` endet an der Adresse, nicht am geladenen
Dokument. **Die Lehre:** ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ihn für
einen neuen Fall aufzuweichen kostet die Zuverlässigkeit aller alten, und das zeigt sich erst im
vollen Lauf.
-92
View File
@@ -1,92 +0,0 @@
# #038 Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, test, infra
**Geschätzt:** 3 h **Tatsächlich:** 1 h 35 min
## Ziel
Beim Abschluss von [#027](027-e2e-reste-vorlagen.md) lief `pnpm test:e2e` erstmals seit dem
26. September wieder vollständig. **Sieben Abläufe waren rot**, und niemand hatte es gemerkt.
Keiner der sieben war ein Produktfehler — und genau das ist das Problem. Vier Ursachen:
| Ablauf | Ursache | Seit |
|---|---|---|
| `laeufe.spec.ts` | zählt die Felder des Zustands auf, `anzahlAnhaenge` fehlte | #033 |
| `stammdaten.spec.ts` | „kommt aus dem Verzeichnis“ steht seit #020 zweimal auf der Seite | #020 |
| `vorlagen-editor.spec.ts` | zieht am Handy feste 300 px, der Abstand ist 532 px | #030/#031 |
| `onboarding.spec.ts` | die **Entwicklungsdaten** wichen vom Seed ab | unbekannt |
Alle vier sind behoben. Das Muster bleibt: **die E2E-Schicht trägt Zusagen, die das Commit-Tor nicht
fährt** — Rückfall auf Polling, Herzschlag, 390 px, die Auslieferung eines Anhangs über seine
Adresse. Steht eine Zusage nur in Playwright, und läuft Playwright nur gelegentlich, dann ist sie
eine Notiz und keine Prüfung.
## Zu entscheiden
- [x] **`pnpm test:e2e` ins Tor vor dem Commit?** → **Nein.** Dagegen: es verlangt einen **gebauten,
laufenden** Stand (`pnpm build && pnpm start`), dauert über vier Minuten und braucht eine
Datenbank mit Seed-Bestand. Vier Minuten vor jedem Commit ist ein Tor, das umgangen wird.
- [x] **Gewählt: eine kleinere, verbindliche Regel.** E2E läuft vor jedem Merge nach `main` — das wären
acht Läufe in Phase 2 gewesen und hätte jeden der vier Befunde am Tag seiner Entstehung
gezeigt. Das ist die Empfehlung.
- [x] **Nicht im CI** (`.gitea/workflows/ci.yml`). Dort ist kein Runner registriert; das zu ändern
ist eine eigene Entscheidung und gehört nicht in dieses Issue.
## Aufgaben
- [x] Die gewählte Regel in `CLAUDE.md` aufschreiben, mit **gemessener** Dauer und der Begründung,
warum sie nicht im Commit-Tor steht
- [x] Die Voraussetzungen, die `tests/e2e/global-setup.ts` prüft, in derselben Reihenfolge
dokumentieren — der Vorlauf ist inzwischen die ehrlichste Beschreibung dessen, was E2E braucht
- [x] Prüfen, welche Zusagen **ausschließlich** in E2E stehen, und für jede entscheiden: gehört sie
zusätzlich in einen Vitest-Test, der den Handler direkt fährt? (Das Muster steht in
`tests/scim.test.ts`, `tests/anhang-route.test.ts` und `tests/lauf-ereignisse.test.ts`.)
- [x] Mindestens die Rechteprüfung der beiden Phase-2-Handler ist damit schon abgedeckt — bleiben
Rückfall auf Polling, Herzschlag am echten Browser und 390 px
## Akzeptanzkriterien
- [x] In `CLAUDE.md` steht, wann E2E läuft, und die Angabe ist gemessen, nicht geschätzt
- [x] Eine Liste der Zusagen, die nur in E2E stehen — und zu jeder eine Entscheidung
## Die Entscheidung, und was sie gekostet hat
**Nicht im Commit-Tor.** Gemessen am 2026-10-11: Bau 14 s, Start 7 s, Abläufe **256 s** — zusammen
4 min 37 s, zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor,
das umgangen wird, und ein umgangenes Tor prüft nichts.
**Vor jedem Merge nach `main`.** In Phase 2 wären das acht Läufe gewesen, und jeder der vier
Befunde aus #027 wäre am Tag seiner Entstehung aufgefallen. Die Regel steht in `CLAUDE.md` mit den
gemessenen Zahlen und der Begründung; `docs/architektur.md` §5b trägt sie mit.
**Nicht im CI**, weil dort kein Runner registriert ist. Das zu ändern ist eine eigene Entscheidung
und gehört nicht in dieses Issue.
## Die Liste der Zusagen — und was sich davon holen ließ
Geprüft wurde jeder der 16 Abläufe gegen die Frage „gibt es dafür ein Vitest-Gegenstück?“. Das
Ergebnis steht als Tabelle in `CLAUDE.md`. Die Kurzfassung:
**Nichts davon lässt sich nach Vitest holen** — alle brauchen einen echten Browser: Layout bei
390 px, `BarcodeDetector`, Touch-Ereignisse, zwei Browser-Kontexte gleichzeitig, Zwischenablage,
Print-CSS, gerendertes DOM, und überhaupt die Hydrierung. Die Antwort auf #038 ist deshalb nicht
„verlagern“, sondern „regelmäßig laufen lassen“.
**Was sich holen ließ, ist geholt** — und das war mehr als erwartet:
- Die **Rechteprüfung der Route Handler** steht seit [#036](036-reviewbefunde-phase-2.md) in
`tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts`: die
Handler werden mit echten `Request`-Objekten gefahren, nicht über die Oberfläche.
- Neu in diesem Issue: `tests/zugang.test.ts` prüft **strukturell**, dass jede `page.tsx` unter
`(app)` und jeder `route.ts` sein Recht selbst prüft. Die Ausnahmen (Auth, Healthcheck) stehen
dort namentlich samt Begründung, und ein Test prüft auch die **Gegenrichtung**: eine Ausnahme für
einen Handler, den es nicht mehr gibt, verdeckt beim nächsten Mal einen echten Befund.
Dass das nötig war, ist der eigentliche Fund: die Zusage „jede Seite prüft selbst“ steht seit #007
in `CLAUDE.md`, und sie hielt bis hierher **nur durch Nachsehen von Hand**. Der Review von Phase 2
hat alle 27 Seiten durchgesehen — das gilt für den Tag, an dem es lief. Beide Gegenproben (eine
Seite, ein Handler ohne Prüfung) werden jetzt rot.
-164
View File
@@ -1,164 +0,0 @@
# #039 M23 – Bedingte Schritte und Abschnitte
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 12 h **Tatsächlich:** 5 h 20 min
## Ziel
Eine Checkliste enthält heute **alles**, und was nicht zutrifft, wird von Hand auf
`NICHT_ZUTREFFEND` gesetzt. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt **beide**
Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit 5 Schritten und „Konto anlegen (Entra
ID)“ mit 4 —, obwohl in jedem einzelnen Lauf nur einer gilt. Die Variable `konto_typ` sagt es
bereits; niemand wertet sie aus.
Neun Schritte, die ein Techniker bei jedem Onboarding wegklickt. Die Abschnitts-Aktion aus #013
macht es erträglich, aber sie ist ein Pflaster: der Fortschrittsbalken zählt die Schritte mit, und
im Archiv steht später eine Checkliste mit einem Abschnitt, den niemand je gebraucht hat.
Setzt [#031](031-abteilungsabschnitte.md) voraus: `geltendeAbschnitte` und `nurGeltende` sind die
Stelle, an der das sitzt.
## Zu entscheiden
- [x] **Wann wird ausgewertet?** Das ist die Entscheidung, an der alles hängt.
Für Abteilungen (#031) wird beim **Start festgeschrieben** — die Abteilung des Laufs steht
dann fest, und ein archivierter Lauf muss genau die Schritte zeigen, die damals galten.
Für Bedingungen reicht das **nicht**: eine Bedingung auf einer Variable (`konto_typ`) ließe
sich beim Start auswerten, eine Bedingung auf einem **Schrittergebnis** („wenn Schritt 3 =
nein, dann Abschnitt B“) nicht — die Antwort entsteht erst während der Arbeit.
Empfehlung: **laufend auswerten, Ergebnis mitschreiben.** Die Auswertung läuft bei jeder
Änderung an einem Schrittergebnis; was dabei herauskommt, wird in den Lauf geschrieben (wie
`geltendeAbschnitte`). Damit gilt für das Archiv weiter „der Lauf weiß, was galt“, und der
Ereignisstrom aus #033 trägt die Änderung ohnehin schon zur zweiten Sitzung.
- [x] **Woran darf eine Bedingung hängen?** Empfehlung: an einer **Variable** des Laufs und an
einem **Schrittergebnis** derselben Version. Nicht an Gerätetyp, Abteilung oder Rolle — für
Abteilungen gibt es #031, und je mehr Quellen, desto schwerer zu erklären, warum ein Schritt
fehlt.
- [x] **Wie wird sie gespeichert?** Empfehlung: ein Json-Feld `bedingung` an `VorlagenAbschnitt`
und `VorlagenSchritt`, mit einem engen Schema — `{ art: "variable", schluessel, operator,
wert }` oder `{ art: "schritt", schrittId, zustand }`. Json, weil `konfig` es schon so macht
und ein neuer Operator dann keine Migration braucht.
**Eine Bedingung, kein Ausdrucksbaum.** Kein `und`/`oder`, keine Verschachtelung: das wäre
eine kleine Sprache, die niemand im Editor pflegen kann, und ein Abschnitt, der aus
unerklärlichen Gründen fehlt, ist schlimmer als einer zu viel.
- [x] **Was passiert mit einem Schrittergebnis, das schon Daten trägt**, wenn die Bedingung
umschlägt? Empfehlung: die Zeile **bleibt** und wird nur ausgeblendet. Löschen hieße, eine
Notiz zu verlieren, die jemand geschrieben hat; und schlägt die Bedingung zurück, ist sie
wieder da.
## Aufgaben
- [x] Migration: `bedingung Json?` an `VorlagenAbschnitt` und `VorlagenSchritt`, dazu ein CHECK, der
sicherstellt, dass eine Bedingung auf einen Schritt **derselben Version** zeigt (sonst zeigt
sie nach dem Veröffentlichen ins Leere)
- [x] `src/lib/bedingungen.ts`, framework-frei: Schema, Auswertung, und die Liste der Schritte und
Abschnitte, die gelten. **Dieselbe Stelle für alle drei Schichten**, wie
`src/lib/geltende-abschnitte.ts`
- [x] Auswertung bei jedem Schreiben an einem `Schrittergebnis`; das Ergebnis in den Lauf
- [x] Abschlussregel: ein ausgeblendeter Pflichtschritt hält den Abschluss **nicht** auf (dieselbe
Regel wie bei #031, dieselbe Funktion)
- [x] Fortschrittsbalken und Zählwerte rechnen nur mit dem, was gilt
- [x] Editor: Bedingung an Abschnitt und Schritt setzen, mit Auswahl der Variable bzw. des Schritts
**aus derselben Version**; eine Vorschau, die sagt „gilt, wenn …“
- [x] Warnung im Editor bei einer Bedingung auf einen Schritt, der **nach** dem bedingten steht —
sie wäre beim Abarbeiten noch offen, und der Abschnitt erschiene erst rückwirkend
- [x] Abarbeitung: ausgeblendete Abschnitte verschwinden, ohne die Nummerierung zu verschieben
(`01 · Vorbereitung` kommt aus der Position und wird nie gespeichert)
- [x] Druckansicht und Archiv zeigen, was galt
- [x] Export/Import: die Bedingung wandert mit, und ein Verweis auf einen Schritt wird dabei auf die
**neue** Kennung umgeschrieben — sonst zeigt sie in der zweiten Installation ins Leere
- [x] Seed: „Mitarbeiter-Onboarding“ bekommt die Bedingung auf `konto_typ`, die beiden
Konto-Abschnitte bleiben drin und schließen sich gegenseitig aus. Das ist die Gegenprobe am
echten Fall.
- [x] Anleitung Kapitel 04 (Vorlagen): wann eine Bedingung das richtige Mittel ist und wann eine
zweite Vorlage
## Akzeptanzkriterien
- [x] Test: ein Lauf mit `konto_typ = AD` hat die 5 AD-Schritte und **keinen** der 4 Entra-Schritte
- [x] Test: dasselbe umgekehrt, und bei nicht gesetzter Variable gelten **beide** — eine Bedingung,
die nicht entschieden werden kann, blendet nichts aus
- [x] Test: eine Bedingung auf einem Schrittergebnis schlägt um, **während** der Lauf läuft, und der
Abschnitt erscheint
- [x] Test: ein ausgeblendeter Pflichtschritt hält den Abschluss nicht auf
- [x] Test: ein Schrittergebnis mit Notiz bleibt stehen, wenn seine Bedingung umschlägt, und ist
danach wieder da
- [x] Test: Export und Import einer Vorlage mit Bedingungen ergeben dieselbe Struktur, und der
Schritt-Verweis zeigt auf den richtigen Schritt der **neuen** Version
- [x] Test: ein archivierter Lauf zeigt genau die Schritte, die bei seinem Abschluss galten
- [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Die Entscheidungen aus dem Issue sind so gefallen wie empfohlen**, und zwei davon haben sich im
Code als richtig erwiesen:
- **Laufend auswerten, nicht beim Start festschreiben.** Eine Bedingung auf einer Variable ließe
sich wie #031 beim Start festhalten; eine auf einem Schrittergebnis nicht — und genau die ist der
interessante Fall (ein Abschnitt „Nacharbeit“, der erscheint, wenn der Restore-Test
fehlschlägt).
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte —
genau wie vor diesem Meilenstein. Eine erfundene Antwort wäre die Verschlechterung.
- **`OFFEN` ist eine Antwort, keine Lücke.** Der Unterschied zur Variable musste scharf gezogen
werden: eine nicht gesetzte Variable ist nicht entscheidbar, ein offener Schritt dagegen klar
„noch nicht erfüllt“. Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an
sichtbar — das Gegenteil des Zwecks.
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen**, und das war die größte Einzelerkenntnis.
**Vier** Wege schreiben eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
`entwurfSpeichern`. Eine Bedingung auf einer Kennung überlebt keinen davon. `bedingungAufloesen`
ist für alle vier dieselbe Stelle; dass eine Bedingung auf **dieselbe Version** zeigt, hält
zusätzlich ein Trigger.
- **Ein struktureller Test bewacht die drei Schichten.** Abschlussregel, Abarbeitung und Druck
müssen alle `geltendeStruktur` rufen; vergisst eine es, sieht der Techniker andere Schritte als
der Ausdruck. Der Test liest die drei Dateien — dieselbe Art Prüfung wie „jede Seite prüft selbst“
(#038).
- **Zwei Schemas für dieselbe Form liefen fast auseinander.** Austauschformat und Editor-Struktur
brauchen die Pfad-Form; ich hatte sie zweimal geschrieben, und TypeScript meldete es erst, als die
beiden Typen aufeinandertrafen. Jetzt steht `PfadBedingungSchema` in `src/lib/bedingungen.ts`, und
`vorlagen-format.ts` importiert es.
## Zwei stille Datenverluste, gefunden bei der Klickprobe
Beides hätte keine Suite gezeigt, und das zweite war **älter als dieser Meilenstein**:
- **Der Import verwarf `nurFuerAbteilungen`.** Der Export schreibt die Abteilungseinschränkung seit
[#031](031-abteilungsabschnitte.md), das Schema nimmt sie an — und der Import setzte sie nicht.
Eine Vorlage, die jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
sie galten dort für **alle**. Also mehr Schritte statt weniger, und niemand merkt es.
- **Die tiefe Kopie verwarf sie ebenfalls — und seit heute auch die Bedingung.** „Bearbeiten“ einer
Vorlage erzeugt eine tiefe Kopie als Entwurf (#009); `vorlageDuplizieren` dasselbe. Beide hatten
die Kopie einzeln ausgeschrieben, und beide vergaßen dieselben Felder. Damit verlor **jede
Bearbeitung** alle Abteilungseinschränkungen.
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
als die Abfrage liefert; die Felder waren da, der Code konnte sie nicht sehen. Jetzt ist der Typ
aus Prisma abgeleitet — ein neues Feld am Modell ist dort automatisch vorhanden.
Gefunden hat es die **Klickprobe am gebauten Stand**: der Editor zeigte „Bedingung gesetzt“ in der
veröffentlichten Version, und nach „Bearbeiten“ war sie weg. Das ist genau der Grund, warum diese
Probe in `CLAUDE.md` steht.
Bewacht wird es jetzt von einem Test, der Kopie und Original **Feld für Feld** vergleicht — nicht
stichprobenweise. So fällt auch das nächste Feld auf, das jemand ergänzt und beim Kopieren
vergisst.
- **Ein Mangel an der neuen Komponente**, aufgefallen beim Klicken: die Auswahlfelder standen in
einem umhüllenden `<label>`, und damit schluckte ihr zugänglicher Name den Text der gewählten
Option mit („VariableKontotyp“). Für einen Screenreader ist das eine Bezeichnung, die sich beim
Umschalten ändert. Jetzt `htmlFor`/`id` — derselbe Fehler wie bei der Adresse im Verzeichnis-Formular
(#016).
## Was offen bleibt
- **Kein `und`/`oder`**, bewusst. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte.
- **Ein Baustein-Schritt hat keine Bedingung** (der Abschnitt, in dem er steht, darf eine haben): ein
Baustein ist wiederverwendbar, und eine Bedingung auf einer Variable der Zielvorlage wäre dort
nicht entscheidbar.
- **Zwei Schritte mit demselben Titel im selben Abschnitt** sind über den Titel-Pfad nicht
unterscheidbar; die Bedingung zeigt dann auf den ersten. Benannt in `src/lib/bedingungen.ts` —
zwei gleich benannte Schritte nebeneinander kann auch ein Mensch nicht unterscheiden.
-127
View File
@@ -1,127 +0,0 @@
# #040 M24 – Vier-Augen-Freigabe
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** 3 h 30 min
## Ziel
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
Restore-Test, das Löschen eines Kontos. Heute kann jeder `TECHNIKER` jeden Lauf abschließen und
archivieren.
Gebraucht wird: eine Vorlage kann sagen „dieser Lauf braucht eine Freigabe“, und dann schließt ihn
ein **Zweiter** ab — nachweisbar im Protokoll.
## Zu entscheiden
- [x] **Freigabe am Lauf oder am Schritt?** Empfehlung: **am Lauf**, gesetzt von der Vorlage.
Am Schritt wäre feiner und verführt zu Checklisten, in denen an fünf Stellen jemand warten
muss; am Lauf ist es eine Aussage über die Arbeit als Ganzes und erklärt sich von selbst.
Schritt-Freigaben bleiben möglich, wenn sich der Bedarf zeigt — `VorlagenSchritt.konfig` ist
ein Json.
- [x] **Wer darf freigeben?** Empfehlung: ein eigenes Recht `freigeben`, standardmäßig bei `ADMIN`
und `REDAKTEUR` — **nicht** bei `TECHNIKER`. Begründung: die Vier-Augen-Regel soll nicht
davon abhängen, dass zufällig ein zweiter Techniker greifbar ist; und wer sie umgehen will,
braucht dann ein zweites Konto mit mehr Rechten, was im Protokoll steht.
- [x] **Darf der Bearbeiter sich selbst freigeben?** **Nein**, und das ist der ganze Sinn. Geprüft
wird gegen `Schrittergebnis.aktualisiertVonId` **und** `Lauf.erstelltVonId`: wer irgendetwas
daran getan hat, gibt nicht frei. Ein `ADMIN`, der allein im Haus ist, kommt damit nicht
weiter — das ist eine bewusste Härte, und die Anleitung sagt es.
- [x] **Was, wenn die Freigabe verweigert wird?** Empfehlung: ein Zustand „zurückgewiesen“ mit
Pflichtnotiz. Der Lauf geht zurück auf `IN_ARBEIT`, die Notiz steht im Protokoll und an der
Abarbeitungsansicht. Kein eigener `LaufStatus` — das wäre eine Änderung an einem Enum, das
vier Jahre halten soll; es genügt ein Feld plus Protokolleintrag.
## Aufgaben
- [x] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`,
`freigegebenAm` und `freigabeNotiz` am `Lauf`; CHECK, dass `freigegebenVonId` und
`freigegebenAm` zusammen gesetzt sind (wie bei `abgeschlossenAm`)
- [x] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank,
nicht nur in den Dienst (CLAUDE.md)
- [x] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern
- [x] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer,
notiz)`; Abschluss verlangt die Freigabe, wenn die Version sie fordert
- [x] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein`
- [x] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht —
sonst wartet ein Lauf, von dem niemand weiß
- [x] Oberfläche: Hinweis am Lauf („wartet auf Freigabe durch einen Zweiten“), Knöpfe für Freigeben
und Zurückweisen, Notizfeld; die Übersicht bekommt einen Filter „wartet auf meine Freigabe“
- [x] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version
- [x] Druckansicht und PDF nennen Freigeber und Zeitpunkt
- [x] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein
Einzelkämpfer sich nicht selbst freigeben kann
## Akzeptanzkriterien
- [x] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht
- [x] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN`
- [x] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst
- [x] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action
- [x] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT`
- [x] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle
- [x] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und
Vorlagen merken nichts
- [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Die vier Entscheidungen sind so gefallen wie empfohlen.** Zwei haben sich im Code als besonders
tragend erwiesen:
- **Die Freigabe hängt an der Version, nicht an der Vorlage.** Eine veröffentlichte Version ist
unveränderlich — ein laufender Lauf kann seine Regel damit nicht unter den Füßen verlieren. In
der Versionsliste steht an jeder Fassung, ob sie eine Freigabe verlangt.
- **Kein fünfter `LaufStatus` für „zurückgewiesen“.** Der Lauf geht auf `IN_ARBEIT`, die
Begründung steht in `freigabeNotiz` und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad
betroffen, der heute vier Fälle kennt.
- **Als „gearbeitet“ zählt dreierlei** — angelegt, ein Schrittergebnis gesetzt, abgeschlossen.
Der **Zuständige** gilt bewusst nicht als Bearbeiter: eine Zuweisung ist noch keine Arbeit, und
wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Die Prüfung sitzt in
`hatDaranGearbeitet`, an einer Stelle — wie `laufMussVeraenderbarSein`.
- **Die Aufforderung entsteht außerhalb der Transaktion**, und das war die einzige knifflige Stelle:
der Abschlussversuch scheitert mit einem eigenen Fehlertyp `FreigabeFehlt`, den
`laufAbschliessen` außen fängt, um die Meldung einzureihen und dann weiterzuwerfen. Innerhalb
würde sie mit dem Fehler zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand
weiß. Dafür musste die Transaktion in eine eigene Funktion (`abschlussVersuchen`).
- **Ein anderer Empfängerkreis als bei jeder anderen Meldung**: nicht der Zuständige, sondern jeder
mit dem Recht `freigeben` — ohne die, die gearbeitet haben. Eine Aufforderung zu etwas, das der
Dienst abweist, ist schlimmer als keine.
- **Ein partieller Unique-Index** (`Zustellung_freigabe_einmal`) hält die Mahnung bei einem Lauf
und Empfänger auf einmal. Ein Techniker versucht es mehrmals; ohne den Index wäre jede Mail für
sich korrekt und in Summe eine Belästigung — derselbe Fall wie „überfällig“ in #034, nur
schneller erreicht.
- **Die Freigabepflicht wandert bei „Bearbeiten“ und „Duplizieren“ mit.** Das war kein Zufall,
sondern die unmittelbare Lehre aus #039: dort hatte die tiefe Kopie `nurFuerAbteilungen` und
`bedingung` stillschweigend verworfen. Eine Vorlage soll ihre Freigabepflicht nicht durch eine
Textkorrektur verlieren.
## Was die Klickprobe gezeigt hat
Elf Prüfungen am gebauten Stand, alle grün — von „Freigabe-Karte sichtbar“ über „Zurückweisen
verlangt eine Begründung“ bis „Häkchen aus der Vorversion übernommen“ und 390 px.
**Drei rote Punkte waren meine eigenen Testfehler**, und der dritte ist festzuhalten:
1. Ein Selektor traf den **versteckten** Protokolltext „Lauf abgeschlossen“ statt der Erfolgsmeldung.
2. Ein Suchmuster `/freigeb/` traf nichts: in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge
gar nicht vor. Dreimal gesucht, bis es auffiel.
3. **Ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand.** Die Action
revalidiert den Pfad, und der Benutzer sieht das Ergebnis ohne Neuladen — ein Reload im selben
Kontext trifft dagegen den Router-Cache von Next. Das ist Next und kein Fehler dieser Anwendung,
aber es ist auch nicht, was ein Mensch tut. Die Probe prüft jetzt das echte Verhalten.
Nachgewiesen, bevor etwas geändert wurde: in einem frischen Browser-Kontext stand alles richtig
da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
## Was offen bleibt
- **Freigaben an einzelnen Schritten** gibt es nicht, bewusst. Am Lauf ist es eine Aussage über die
Arbeit als Ganzes und erklärt sich von selbst; am Schritt verführte es zu Checklisten, in denen an
fünf Stellen jemand warten muss. `VorlagenSchritt.konfig` ist ein Json — wenn sich der Bedarf
zeigt, ist der Platz da.
- **Ein Einzelkämpfer kommt nicht weiter**: wer allein ist und an dem Lauf gearbeitet hat, kann ihn
nicht freigeben, auch nicht als Administrator. Das ist die Härte, die die Regel ausmacht, und die
Anleitung sagt es ausdrücklich.
-68
View File
@@ -1,68 +0,0 @@
# #041 M25 – PDF-Übergabeprotokoll mit Unterschrift
**Status:** offen
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** –
## Ziel
Wer ein Notebook übergibt, braucht ein Blatt, das der Empfänger unterschreibt — und das danach
auffindbar ist. Heute gibt es die Druckansicht eines Laufs (`/drucken/lauf/[id]`, #015): sie ist
hell, vollständig und als Vorstufe genau dafür gebaut. Was fehlt, ist die Datei.
Ein Browser-Ausdruck genügt nicht: er hängt am Drucker-Dialog des Betrachters, trägt dessen
Kopfzeilen und lässt sich nicht an ein Ticket anhängen.
## Zu entscheiden
- [ ] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie
`scripts/anleitung-pdf.ts` es für die Anleitung tut. Keine neue Abhängigkeit, keine zweite
Auslegung des Layouts — gedruckt wird dieselbe Seite, die die Druckansicht zeigt.
**Grenze, die genannt gehört:** damit braucht der Server einen Chromium. Im Compose-Stand ist
das eine Ergänzung am Image, nativ ein Paket. Die Alternative (`pdf-lib` oder ein
Dienst) wäre ein zweites Layout, das auseinanderläuft — der Preis ist höher.
- [ ] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.**
Zuerst ein Unterschriftsfeld im PDF (eine Linie, Name, Datum) — das löst den Fall heute und
kostet fast nichts. Eine Unterschrift mit dem Finger auf dem Handy ist ein eigener Schritt-Typ
und gehört zu [#042](042-weitere-schritt-typen.md); sie landet dann als Bild im PDF.
Eine digitale **Signatur** im kryptografischen Sinn ist ausdrücklich **nicht** gemeint und
wäre eine eigene Phase.
- [ ] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf
erzeugt**, nicht gespeichert. Ein gespeichertes PDF ist eine zweite Wahrheit neben dem Lauf;
wird eine Notiz später berichtigt, stimmt es nicht mehr. Für den Fall „wir brauchen das Blatt
von damals“ gibt es das Archiv, das den Lauf unveränderlich hält.
**`{{generiertes_passwort}}` erscheint auch hier nicht** — die Druckansicht zeigt
`[Passwort – nicht gespeichert]`, und das PDF übernimmt das.
## Aufgaben
- [ ] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst**
(`tests/zugang.test.ts` erzwingt das seit #038)
- [ ] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde,
Lauf, Vorlage, Version), Inhalt und Fuß (Bearbeiter, Freigeber aus
[#040](040-vier-augen-freigabe.md), Unterschriftsfeld)
- [ ] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz,
die nach Leerlauf schließt — und eine Obergrenze gleichzeitiger Erzeugungen, wie
`SSE_MAX_STROEME` in #033
- [ ] Knopf „Protokoll als PDF“ an Lauf und Archiv
- [ ] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im
Changelog — eine Ergänzung am Image, die niemand bemerkt, ist eine böse Überraschung beim
nächsten Rollout
- [ ] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt:
**eine Meldung, kein stilles leeres PDF**
- [ ] Anleitung Kapitel 02 und 10 (Archiv)
## Akzeptanzkriterien
- [ ] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen
und Kommentare — geprüft am extrahierten Text, nicht am Vorhandensein der Datei
- [ ] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen
- [ ] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036)
- [ ] Test: ohne Recht `401`, mit falschem Kunden `404`
- [ ] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF
- [ ] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die
Obergrenze gilt)
- [ ] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht
als solche im Devlog
-62
View File
@@ -1,62 +0,0 @@
# #042 M26 – Weitere Schritt-Typen
**Status:** offen
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 6 h **Tatsächlich:** –
## Ziel
`SchrittTyp` hat heute sechs Werte: `HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL`. Das Enum und das
Json-Feld `konfig` sind seit #006 genau dafür gebaut, dass neue Typen ohne Migration der
bestehenden dazukommen.
Die Frage ist nicht, **wie** ein Typ dazukommt, sondern **welche** — und das Issue nennt sie
absichtlich noch nicht als beschlossen: ein Typ, der im Editor steht und in keiner Vorlage
auftaucht, ist ungenutzter und damit ungeprüfter Code.
## Zu entscheiden
- [ ] **Welche Typen?** Vorschlag, nach Nutzen geordnet, jeder mit dem Fall, der ihn verlangt:
| Typ | Der Fall | Ohne ihn |
|---|---|---|
| `DATEI` | „Foto des Typenschilds“ als **Pflicht** | ein Anhang ist seit #032 möglich, aber nie verlangt — ein Lauf schließt ohne ihn ab |
| `UNTERSCHRIFT` | Übergabe mit dem Finger auf dem Handy | das Blatt muss gedruckt und wieder eingescannt werden ([#041](041-pdf-uebergabeprotokoll.md)) |
| `MEHRFACHAUSWAHL` | „welche Lizenzen zugewiesen“ | heute ein Textfeld, also nicht auswertbar |
| `URL` | „Link zum Ticket“ | ein Textfeld ohne Prüfung und ohne Klick |
| `UEBERSCHRIFT` | eine Zwischenüberschrift ohne Zustand | ein `HAKEN`, den jemand abhaken muss, damit der Lauf fertig wird |
**Empfehlung: `DATEI` und `UNTERSCHRIFT`**, und die drei anderen nur, wenn eine Seed-Vorlage
sie wirklich benutzt. `UEBERSCHRIFT` ist der zweifelhafteste: ein Schritt ohne Zustand bricht
die Annahme „jeder Schritt hat ein `Schrittergebnis`“, und die steckt an vielen Stellen.
- [ ] **Bleibt `Schrittergebnis.wert` ein Json?** Ja — es ist seit #006 eines, genau dafür. Eine
Mehrfachauswahl ist ein Array, eine Unterschrift ein Verweis auf einen `Anhang`.
- [ ] **Wie verträgt sich `DATEI` mit dem Archiv-Schreibschutz?** Ein Pflicht-Anhang muss vor dem
Abschluss da sein; danach kommt keiner mehr weg (#032). Das ist bereits so und braucht nur
einen Test.
## Aufgaben
- [ ] Je gewählten Typ: Enum-Wert, Zod-Schema für `konfig` und `wert`, Darstellung im Editor,
Darstellung in der Abarbeitung, Darstellung im Druck und im PDF
- [ ] Die Abschlussregel kennt den neuen Typ: was zählt als „erledigt“? Bei `DATEI` ein Anhang, bei
`UNTERSCHRIFT` ein Bild, bei `MEHRFACHAUSWAHL` mindestens eine Wahl
- [ ] `UNTERSCHRIFT`: Zeichenfläche am Handy (Pointer-Ereignisse, nicht Touch-spezifisch), Ergebnis
als PNG über den Anhänge-Weg aus #032 — **keine neue Ablage**
- [ ] Export/Import und die Vorlagen-Austauschform um die neuen Typen erweitern, nachsichtig: eine
Datei mit einem unbekannten Typ wird abgewiesen **mit Nennung des Typs**, nicht stillschweigend
verändert
- [ ] Mindestens eine Seed-Vorlage benutzt jeden neuen Typ — sonst ist er ungeprüfter Code
- [ ] Anleitung Kapitel 04, mit dem Satz, wann ein Typ das richtige Mittel ist
## Akzeptanzkriterien
- [ ] Test je Typ: anlegen, abarbeiten, abschließen, drucken
- [ ] Test: ein `DATEI`-Pflichtschritt ohne Anhang hält den Abschluss auf
- [ ] Test: eine Unterschrift landet als `Anhang` und nicht als Json-Blob in `wert`
- [ ] Test: ein Lauf einer **älteren** Version verhält sich unverändert — die neuen Typen tauchen
dort nicht auf, und keine Migration hat Daten angefasst
- [ ] Test: eine Austauschdatei mit unbekanntem Typ wird abgewiesen und nennt den Typ
- [ ] Am Handy bedienbar, bei 390 px geprüft — besonders die Zeichenfläche
+6 -13
View File
@@ -28,23 +28,16 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min | | #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min |
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | erledigt | v0.2.0 | 4 h | 1 h 50 min | | #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | erledigt | v0.2.0 | 4 h | 1 h 50 min |
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.3.0 | 2 h | – | | #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – |
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min | | #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min | | #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | erledigt | v0.2.0 | 1 h | 2 h 10 min | | #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min | | #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | erledigt | v0.2.0 | 12 h | 4 h 05 min | | #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min | | #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min | | #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min | | #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 | | #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 | | #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) | erledigt | v0.2.0 | 8 h | 2 h 50 min | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min | | | **Summe** | | | **229 h** | **53 h 35 min** |
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
| #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min |
| #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | erledigt | v0.3.0 | 8 h | 3 h 30 min |
| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – |
| #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – |
| | **Summe** | | | **272 h** | **82 h 45 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "laufzettel", "name": "laufzettel",
"version": "0.2.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags", "description": "Checklisten fuer wiederkehrende IT-Arbeiten: versionierte Vorlagen, Laeufe, Archiv, QR-Tags",
-6
View File
@@ -11,12 +11,6 @@ export default defineConfig({
* erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018). * erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018).
*/ */
globalSetup: "./tests/e2e/global-setup.ts", globalSetup: "./tests/e2e/global-setup.ts",
/*
* Raeumt weg, was die Ablaeufe angelegt haben (#027). Laeuft auch nach
* fehlgeschlagenen Ablaeufen; ein harter Abbruch wird vom Vorlauf gefangen,
* der dasselbe tut.
*/
globalTeardown: "./tests/e2e/global-teardown.ts",
// Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander. // Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander.
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
@@ -1,86 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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();
@@ -1,97 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Quellenarten und Microsoft-Graph-Quelle (#028).
CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM');
/*
* **Die Reihenfolge der drei Schritte ist nicht beliebig.**
*
* Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen
* Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der
* CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch
* nicht erfuellen koennen.
*
* Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen:
* vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt
* den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb
* nicht — aber die Pruefung unten steht trotzdem da.
*/
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP';
-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen.
ALTER TABLE "Verzeichnisquelle"
ALTER COLUMN "url" DROP NOT NULL,
ALTER COLUMN "bindDn" DROP NOT NULL,
ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL,
ALTER COLUMN "basisDn" DROP NOT NULL,
ALTER COLUMN "filter" DROP NOT NULL;
-- Und die Felder einer Graph-Quelle.
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "tenantId" TEXT,
ADD COLUMN "clientId" TEXT,
ADD COLUMN "clientSecretVerschluesselt" TEXT;
/*
* **Welche Felder eine Art braucht, haelt die Datenbank fest.**
*
* Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei
* Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt.
* Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung.
*
* `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe
* Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es
* nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben
* werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab.
*/
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
CHECK (
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
AND "filter" IS NOT NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
OR
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
AND "clientSecretVerschluesselt" IS NOT NULL
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL)
OR
("art" = 'SCIM')
);
-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt"
CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.');
-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in
-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist.
CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art");
/*
* Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine
* Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen —
* aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu
* pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
ohne_art INTEGER;
BEGIN
SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL;
IF ohne_art > 0 THEN
RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art;
END IF;
END $$;
@@ -1,95 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- SCIM-Endpunkt als Alternative zum Abgleich (#029).
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "scimTokenHash" TEXT,
ADD COLUMN "scimTokenGesetztAm" TIMESTAMP(3),
ADD COLUMN "letzteScimZustellungAm" TIMESTAMP(3);
/*
* **Der CHECK je Art wird ersetzt, nicht ergaenzt.**
*
* Bis #028 liess er `SCIM` ohne jede Bedingung durch — die Art hatte noch keine
* Felder. Jetzt hat sie welche, und dieselbe Regel gilt in beide Richtungen: was
* eine Art braucht, und was sie **nicht** haben darf. Ohne den zweiten Teil
* waere eine SCIM-Quelle mit `basisDn` moeglich, und bei der koennte niemand
* mehr sagen, was gilt.
*
* **Das Token steht nicht in der Bedingung.** Eine SCIM-Quelle entsteht ohne
* Token und bekommt es danach — der Administrator legt die Quelle an, erzeugt
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an: der Handler findet keinen Hash zum Vergleichen und antwortet mit
* 401. Das ist der richtige Zustand, nicht ein verbotener.
*/
ALTER TABLE "Verzeichnisquelle" DROP CONSTRAINT "Verzeichnisquelle_felder_je_art";
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
CHECK (
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
AND "filter" IS NOT NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL
AND "scimTokenHash" IS NULL)
OR
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
AND "clientSecretVerschluesselt" IS NOT NULL
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL
AND "scimTokenHash" IS NULL)
OR
("art" = 'SCIM'
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
);
-- Das Token als argon2-Hash, nicht im Klartext. `$argon2` ist das Praefix der
-- Bibliothek; ein Token, das versehentlich roh gespeichert wird, faellt hier auf.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_scimtoken_gehasht"
CHECK ("scimTokenHash" IS NULL OR "scimTokenHash" LIKE '$argon2%');
-- Ein Token gehoert zu einem Zeitpunkt, in beide Richtungen: ohne ihn liesse
-- sich nicht sagen, seit wann es gilt, und ein Zeitpunkt ohne Token behauptete
-- ein Token, das es nicht gibt.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_scimtoken_mit_zeitpunkt"
CHECK (("scimTokenHash" IS NULL) = ("scimTokenGesetztAm" IS NULL));
/*
* Der Index, auf dem der SCIM-Handler arbeitet.
*
* Jede Anfrage bringt ein Token mit und muss daraus die Quelle finden. Ein Hash
* laesst sich nicht nachschlagen — argon2 ist je Aufruf anders —, also werden
* die SCIM-Quellen gelesen und einzeln geprueft. Bei zwei Quellen ist das
* gleichgueltig; der Index haelt es auch bei zwanzig schnell und sagt zugleich,
* dass hier nach `art` gesucht wird.
*/
CREATE INDEX "Verzeichnisquelle_art_aktiv_idx" ON "Verzeichnisquelle"("art", "aktiv");
/*
* Gegenprobe: der ersetzte CHECK muss die bestehenden Zeilen noch zulassen.
* `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich aus — aber eine
* Migration, die ihre eigene Annahme nicht nachrechnet, ist genau die Art
* Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
anzahl INTEGER;
BEGIN
SELECT count(*) INTO anzahl FROM "Verzeichnisquelle" WHERE "art" NOT IN ('LDAP', 'GRAPH', 'SCIM');
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartete Quellenart in % Zeilen.', anzahl;
END IF;
END $$;
@@ -1,161 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Bedingte Abschnitte und Schritte (#039).
ALTER TABLE "VorlagenAbschnitt" ADD COLUMN "bedingung" JSONB;
ALTER TABLE "VorlagenSchritt" ADD COLUMN "bedingung" JSONB;
/*
* Die **Form** der Bedingung, in der Datenbank.
*
* Das vollstaendige Schema steht in `src/lib/bedingungen.ts` und wird beim
* Schreiben angewandt. Hier steht, was auch dann gelten muss, wenn jemand an
* der Service-Schicht vorbei schreibt (CLAUDE.md) — und das ist mehr als
* Formsache: eine Bedingung mit unbekannter `art` wuerde beim Lesen als „keine
* Bedingung“ gelten, der Abschnitt waere also sichtbar. Das ist die harmlose
* Richtung, aber es waere **stillschweigend** falsch, und niemand koennte sagen,
* warum ein Abschnitt, der eine Bedingung hat, immer erscheint.
*
* Geprueft wird deshalb: `art` ist einer der beiden Werte, und die Felder, die
* diese Art braucht, sind da. Nicht geprueft wird der Inhalt von `zustaende` —
* ein Array-Element einzeln zu pruefen ginge nur mit einer Unterabfrage, und
* die gehoert nicht in einen CHECK.
*/
ALTER TABLE "VorlagenAbschnitt"
ADD CONSTRAINT "VorlagenAbschnitt_bedingung_form"
CHECK (
"bedingung" IS NULL
OR (
"bedingung" ->> 'art' = 'variable'
AND "bedingung" ->> 'schluessel' IS NOT NULL
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
AND (
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
)
)
OR (
"bedingung" ->> 'art' = 'schritt'
AND "bedingung" ->> 'schrittId' IS NOT NULL
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
)
);
ALTER TABLE "VorlagenSchritt"
ADD CONSTRAINT "VorlagenSchritt_bedingung_form"
CHECK (
"bedingung" IS NULL
OR (
"bedingung" ->> 'art' = 'variable'
AND "bedingung" ->> 'schluessel' IS NOT NULL
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
AND (
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
)
)
OR (
"bedingung" ->> 'art' = 'schritt'
AND "bedingung" ->> 'schrittId' IS NOT NULL
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
)
);
/*
* **Eine Schritt-Bedingung zeigt auf einen Schritt derselben Version.**
*
* Das ist die eigentliche Regel, und ein CHECK kann sie nicht halten: sie
* verlangt einen Blick in zwei andere Tabellen. Ein Fremdschluessel auf
* `VorlagenSchritt` ginge auch nicht — der Wert steckt in einem Json, und
* „derselbe Version“ steht dort nicht.
*
* Ohne diese Pruefung zeigt eine Bedingung nach dem Veroeffentlichen ins Leere:
* „Bearbeiten“ erzeugt eine **tiefe Kopie** mit neuen Kennungen (CLAUDE.md), und
* ein Verweis auf die alte Kennung waere dann nicht mehr entscheidbar. Die
* Auswertung zeigt in diesem Fall an, der Abschnitt erscheint also immer — und
* niemand koennte sagen, warum die Bedingung nicht wirkt. Genau die Art Fehler,
* die eine gruene Suite nicht sieht.
*
* Geprueft wird beim Schreiben der **Bedingung**, nicht beim Loeschen des
* Ziels: einen Schritt zu loeschen, auf den eine Bedingung zeigt, ist nur im
* Entwurf moeglich, und dort faengt es der Dienst mit einer besseren Meldung.
*/
CREATE OR REPLACE FUNCTION laufzettel_bedingung_gleiche_version() RETURNS TRIGGER AS $$
DECLARE
eigene_version TEXT;
ziel_version TEXT;
ziel_id TEXT;
BEGIN
IF NEW."bedingung" IS NULL OR NEW."bedingung" ->> 'art' <> 'schritt' THEN
RETURN NEW;
END IF;
ziel_id := NEW."bedingung" ->> 'schrittId';
-- Die eigene Version: am Abschnitt steht sie direkt, am Schritt eine Ebene hoeher.
IF TG_TABLE_NAME = 'VorlagenAbschnitt' THEN
eigene_version := NEW."versionId";
ELSE
SELECT "versionId" INTO eigene_version
FROM "VorlagenAbschnitt" WHERE id = NEW."abschnittId";
END IF;
SELECT a."versionId" INTO ziel_version
FROM "VorlagenSchritt" s
JOIN "VorlagenAbschnitt" a ON a.id = s."abschnittId"
WHERE s.id = ziel_id;
IF ziel_version IS NULL THEN
RAISE EXCEPTION
'Die Bedingung zeigt auf den Schritt %, den es nicht gibt.', ziel_id
USING ERRCODE = 'integrity_constraint_violation';
END IF;
IF ziel_version <> eigene_version THEN
RAISE EXCEPTION
'Die Bedingung zeigt auf einen Schritt einer anderen Version. Nach dem Veroeffentlichen waere sie nicht mehr entscheidbar.'
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "VorlagenAbschnitt_bedingung_gleiche_version"
BEFORE INSERT OR UPDATE ON "VorlagenAbschnitt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
CREATE TRIGGER "VorlagenSchritt_bedingung_gleiche_version"
BEFORE INSERT OR UPDATE ON "VorlagenSchritt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
/*
* Gegenprobe: die neuen Spalten sind ueberall leer, und die CHECKs lassen die
* bestehenden Zeilen zu. `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich
* aus — aber eine Migration, die ihre eigene Annahme nicht nachrechnet, ist
* genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
mit_bedingung INTEGER;
BEGIN
SELECT count(*) INTO mit_bedingung FROM "VorlagenAbschnitt" WHERE "bedingung" IS NOT NULL;
IF mit_bedingung > 0 THEN
RAISE EXCEPTION 'Unerwartet % Abschnitte mit Bedingung vor der Migration.', mit_bedingung;
END IF;
SELECT count(*) INTO mit_bedingung FROM "VorlagenSchritt" WHERE "bedingung" IS NOT NULL;
IF mit_bedingung > 0 THEN
RAISE EXCEPTION 'Unerwartet % Schritte mit Bedingung vor der Migration.', mit_bedingung;
END IF;
END $$;
@@ -1,97 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Vier-Augen-Freigabe (#040).
/*
* Ein neuer Meldeanlass. Ohne ihn wartet ein Lauf auf eine Freigabe, von der
* niemand weiss — und das ist der Fall, der die ganze Regel wertlos macht:
* sie haelt den Abschluss auf, und keiner merkt es.
*/
ALTER TYPE "Meldeanlass" ADD VALUE IF NOT EXISTS 'LAUF_FREIGABE_OFFEN' BEFORE 'ABGLEICH_FEHLGESCHLAGEN';
ALTER TABLE "VorlagenVersion" ADD COLUMN "freigabePflicht" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Lauf"
ADD COLUMN "freigegebenVonId" TEXT,
ADD COLUMN "freigegebenAm" TIMESTAMP(3),
ADD COLUMN "freigabeNotiz" TEXT;
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_freigegebenVonId_fkey"
FOREIGN KEY ("freigegebenVonId") REFERENCES "Benutzer"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "Lauf_freigegebenVonId_idx" ON "Lauf"("freigegebenVonId");
/*
* Freigeber und Zeitpunkt gehoeren zusammen, in beide Richtungen — wie bei
* `abgeschlossenAm`. Ohne den Zeitpunkt liesse sich nicht sagen, wann die
* Freigabe erteilt wurde, und ein Zeitpunkt ohne Freigeber behauptete eine
* Freigabe, die niemand gegeben hat.
*/
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_freigabe_mit_zeitpunkt"
CHECK (("freigegebenVonId" IS NULL) = ("freigegebenAm" IS NULL));
/*
* **Der Kern der Vier-Augen-Regel, in der Datenbank.**
*
* Wer den Lauf angelegt hat, gibt ihn nicht frei. Das prueft auch der Dienst,
* und zwar strenger: er sieht ausserdem nach, wer an den Schrittergebnissen
* gearbeitet hat (`Schrittergebnis.aktualisiertVonId`). Dafuer braeuchte ein
* CHECK eine Unterabfrage, und die gehoert nicht hinein.
*
* Diese Haelfte steht trotzdem hier, weil sie die wichtigere ist: der Ersteller
* ist der naheliegende Fall, und die Regel muss auch dann gelten, wenn jemand an
* der Service-Schicht vorbei schreibt (CLAUDE.md). Eine Freigabe, die in der
* Datenbank steht, ist damit nachweislich nicht vom Ersteller.
*/
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_freigabe_nicht_selbst"
CHECK ("freigegebenVonId" IS NULL OR "freigegebenVonId" <> "erstelltVonId");
/*
* **Die Freigabe-Aufforderung geht je Lauf und Empfaenger genau einmal hinaus.**
*
* Derselbe Grund wie bei „ueberfaellig“ (#034): sie entsteht bei einem
* gescheiterten Abschlussversuch, und ein Techniker versucht es mehrmals — ohne
* diesen Index bekaeme jeder Freigabeberechtigte bei jedem Versuch eine Mail,
* und jede davon waere fuer sich korrekt.
*
* Partiell, weil es nur fuer diesen einen Anlass gilt: nach einer Zurueckweisung
* und erneuter Fertigstellung soll **nicht** wieder gemahnt werden — der
* Zurueckweisende weiss ja, dass er wartet. Dafuer traegt die Zurueckweisung
* ihre eigene Notiz an der Abarbeitung.
*/
CREATE UNIQUE INDEX "Zustellung_freigabe_einmal"
ON "Zustellung"("laufId", "empfaenger")
WHERE "anlass" = 'LAUF_FREIGABE_OFFEN';
/*
* Gegenprobe: die neuen Spalten sind ueberall leer, und keine bestehende Version
* verlangt eine Freigabe. `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich
* aus — aber eine Migration, die ihre eigene Annahme nicht nachrechnet, ist
* genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
anzahl INTEGER;
BEGIN
SELECT count(*) INTO anzahl FROM "Lauf" WHERE "freigegebenVonId" IS NOT NULL;
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartet % Laeufe mit Freigabe vor der Migration.', anzahl;
END IF;
SELECT count(*) INTO anzahl FROM "VorlagenVersion" WHERE "freigabePflicht";
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartet % Versionen mit Freigabepflicht vor der Migration.', anzahl;
END IF;
END $$;
+5 -102
View File
@@ -31,12 +31,6 @@ enum Rolle {
REDAKTEUR REDAKTEUR
TECHNIKER TECHNIKER
LESER 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 { enum Betriebsmodus {
@@ -162,8 +156,6 @@ enum Meldeanlass {
LAUF_ZUGEWIESEN LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN LAUF_ABGESCHLOSSEN
/// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
LAUF_FREIGABE_OFFEN
ABGLEICH_FEHLGESCHLAGEN ABGLEICH_FEHLGESCHLAGEN
} }
@@ -262,20 +254,10 @@ model Benutzer {
/// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein /// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein
/// Dienstkonto will keine Post. /// Dienstkonto will keine Post.
meldungenAktiv Boolean @default(true) 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()) erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon") abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon") archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon") erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn") zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
@@ -286,7 +268,6 @@ model Benutzer {
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[] anhaenge Anhang[]
zustellungen Zustellung[] zustellungen Zustellung[]
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull)
@@index([aktiv]) @@index([aktiv])
@@index([rolle]) @@index([rolle])
@@ -317,56 +298,18 @@ model BenutzerKunde {
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene. /// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen /// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs. /// Abgleich zu Kunden-ADs.
/// Woher eine Verzeichnisquelle liest (#028).
///
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
enum QuellenArt {
LDAP
GRAPH
SCIM
}
model Verzeichnisquelle { model Verzeichnisquelle {
id String @id @default(cuid()) id String @id @default(cuid())
kundeId String kundeId String
name String name String
aktiv Boolean @default(false) aktiv Boolean @default(false)
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art. url String
art QuellenArt @default(LDAP) bindDn String
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
url String?
bindDn String?
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt /// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test. /// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
bindPasswortVerschluesselt String? bindPasswortVerschluesselt String
basisDn String? basisDn String
filter String? filter String
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
tenantId String?
clientId String?
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
/// keine Antwort und keinen Protokolleintrag erreichen.
clientSecretVerschluesselt String?
/// Ab hier: **nur bei `art = SCIM`** (#029).
///
/// Das Token als **argon2-Hash**, nicht verschluesselt — anders als die
/// Geheimnisse darueber. Der Unterschied ist der Zweck: ein Bind-Passwort
/// muss Laufzettel **benutzen**, also wieder lesbar machen. Ein SCIM-Token
/// muss es nur **wiedererkennen**, und was man nie lesen muss, speichert man
/// so, dass es niemand lesen kann. Gezeigt wird es genau einmal.
scimTokenHash String?
/// Wann das Token erzeugt wurde — fuer „seit wann gilt das“ in der Oberflaeche.
scimTokenGesetztAm DateTime?
/// Wann zuletzt etwas geschickt wurde. Das Gegenstueck zu `letzterAbgleichAm`:
/// bei SCIM holt Laufzettel nicht, es bekommt.
letzteScimZustellungAm DateTime?
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben. /// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft. /// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.
@@ -433,9 +376,6 @@ model Mitarbeiter {
untergebene Mitarbeiter[] @relation("Vorgesetzter") untergebene Mitarbeiter[] @relation("Vorgesetzter")
geraete Geraet[] geraete Geraet[]
laeufe Lauf[] 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 /// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht. /// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
@@ -581,13 +521,6 @@ model VorlagenVersion {
vorlageId String vorlageId String
version Int version Int
status VersionStatus @default(ENTWURF) status VersionStatus @default(ENTWURF)
/// Verlangt dieser Lauf eine Freigabe durch einen Zweiten (#040)?
///
/// An der **Version** und nicht an der Vorlage: eine veroeffentlichte Version
/// ist unveraenderlich, also kann ein laufender Lauf seine Regel nicht unter
/// den Fuessen verlieren. Wer die Pflicht aendert, veroeffentlicht eine neue
/// Fassung — bestehende Laeufe behalten ihre.
freigabePflicht Boolean @default(false)
aenderungsnotiz String? aenderungsnotiz String?
veroeffentlichtAm DateTime? veroeffentlichtAm DateTime?
veroeffentlichtVonId String? veroeffentlichtVonId String?
@@ -646,17 +579,6 @@ model VorlagenAbschnitt {
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites /// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
/// Feld dafuer waere eine zweite Wahrheit. /// Feld dafuer waere eine zweite Wahrheit.
bausteinVersionId String? bausteinVersionId String?
/// Wann dieser Abschnitt gilt (#039). Leer heisst: immer.
///
/// Form und Auswertung stehen in `src/lib/bedingungen.ts`; ein CHECK haelt die
/// Form auch gegen Schreiben an der Dienstschicht vorbei fest, und ein Trigger
/// haelt fest, dass eine Schritt-Bedingung auf **dieselbe Version** zeigt —
/// nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
/// entscheidbar, und der Abschnitt erschiene stillschweigend immer.
///
/// **Nicht entscheidbar heisst: gilt.** Ein Abschnitt zu viel ist sichtbar und
/// korrigierbar; ein fehlender faellt niemandem auf.
bedingung Json?
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict) bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
@@ -740,9 +662,6 @@ model VorlagenSchritt {
/// Parameter fuer Schritte vom Typ AKTION. /// Parameter fuer Schritte vom Typ AKTION.
konfig Json @default("{}") konfig Json @default("{}")
dokuUrl String? dokuUrl String?
/// Wann dieser Schritt gilt (#039). Leer heisst: immer. Siehe
/// `VorlagenAbschnitt.bedingung` — dieselbe Form, dieselben Regeln.
bedingung Json?
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade) abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
@@ -795,20 +714,6 @@ model Lauf {
gestartetAm DateTime? gestartetAm DateTime?
abgeschlossenAm DateTime? abgeschlossenAm DateTime?
abgeschlossenVonId String? abgeschlossenVonId String?
/// Wer diesen Lauf freigegeben hat (#040), und wann.
///
/// **Nicht der Ersteller** — das haelt ein CHECK fest
/// (`Lauf_freigabe_nicht_selbst`). Der Dienst prueft strenger: er sieht auch
/// nach, wer an den Schrittergebnissen gearbeitet hat; dafuer braeuchte ein
/// CHECK eine Unterabfrage.
///
/// `freigabeNotiz` traegt die Begruendung — bei einer Freigabe freiwillig, bei
/// einer Zurueckweisung Pflicht. Sie bleibt stehen, wenn der Lauf danach
/// wieder freigegeben wird: das Protokoll haelt beide Vorgaenge fest, und die
/// letzte Notiz ist die, die jemand an der Abarbeitung lesen soll.
freigegebenVonId String?
freigegebenAm DateTime?
freigabeNotiz String?
archiviertAm DateTime? archiviertAm DateTime?
archiviertVonId String? archiviertVonId String?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
@@ -822,7 +727,6 @@ model Lauf {
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict) erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict) zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict) abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict)
freigegebenVon Benutzer? @relation("LaufFreigegebenVon", fields: [freigegebenVonId], references: [id], onDelete: Restrict)
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict) archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
@@ -837,7 +741,6 @@ model Lauf {
@@index([kundeId, status]) @@index([kundeId, status])
@@index([geraetId, status]) @@index([geraetId, status])
@@index([mitarbeiterId]) @@index([mitarbeiterId])
@@index([freigegebenVonId])
@@index([zugewiesenAnId, status]) @@index([zugewiesenAnId, status])
@@index([status, faelligAm]) @@index([status, faelligAm])
@@index([vorlagenVersionId]) @@index([vorlagenVersionId])
+5 -51
View File
@@ -1,5 +1,4 @@
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync } from "node:fs";
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format"; import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
@@ -200,32 +199,13 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
}); });
} }
/*
* Erst alles anlegen, dann die Bedingungen (#039) — wie beim Import und
* aus demselben Grund: eine Schritt-Bedingung verweist ueber einen
* Titel-Pfad, und der laesst sich erst aufloesen, wenn die Schritte
* stehen. Die Seed-Vorlagen benutzen bisher nur Variablen-Bedingungen;
* der zweite Durchgang steht trotzdem hier, damit eine kuenftige Vorlage
* mit Schritt-Bedingung nicht stillschweigend ohne sie landet.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: string[] = [];
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) { for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({ const zeile = await tx.vorlagenAbschnitt.create({
data: { data: { versionId: version.id, titel: abschnitt.titel, position },
versionId: version.id,
titel: abschnitt.titel,
position,
// Wandert mit, auch wenn bisher keine Seed-Vorlage eine hat (#031).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
}); });
abschnittsZeilen.push(zeile.id); if (abschnitt.schritte.length === 0) continue;
await tx.vorlagenSchritt.createMany({
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) { data: abschnitt.schritte.map((schritt, schrittPosition) => ({
const angelegt = await tx.vorlagenSchritt.create({
data: {
abschnittId: zeile.id, abschnittId: zeile.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -237,34 +217,8 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
konfig: schritt.konfig as object, konfig: schritt.konfig as object,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition, position: schrittPosition,
}, })),
}); });
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
}
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const abschnittId = abschnittsZeilen[position];
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as object },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as object },
});
}
}
} }
await tx.vorlagenVersion.update({ await tx.vorlagenVersion.update({
@@ -94,12 +94,6 @@
}, },
{ {
"titel": "Konto anlegen (Active Directory)", "titel": "Konto anlegen (Active Directory)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Active Directory"
},
"schritte": [ "schritte": [
{ {
"titel": "Konto anlegen", "titel": "Konto anlegen",
@@ -147,12 +141,6 @@
}, },
{ {
"titel": "Konto anlegen (Entra ID)", "titel": "Konto anlegen (Entra ID)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Entra ID"
},
"schritte": [ "schritte": [
{ {
"titel": "Konto anlegen", "titel": "Konto anlegen",
+1 -55
View File
@@ -141,7 +141,7 @@ function feld(text, name) {
// README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne // README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne
// den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu // den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu
// #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der // #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der
// Befehlsblock selbst, nicht seine Umgebung (#026). // Befehlsblock selbst, nicht seine Umgebung (#040).
{ {
/** Der erste eingezaeunte Block nach einer Ueberschrift. */ /** Der erste eingezaeunte Block nach einer Ueberschrift. */
const blockNach = (text, ueberschrift) => { const blockNach = (text, ueberschrift) => {
@@ -197,60 +197,6 @@ function feld(text, name) {
} }
} }
// ---- 7. Jeder (#NNN)-Verweis zeigt auf eine Issue-Datei --------------------
{
/*
* Verweise wie `(#032)` stehen in Kommentaren, im Changelog und in CLAUDE.md
* und sind die einzige Bruecke zwischen einer Codestelle und ihrer
* Begruendung. Zeigt einer ins Leere, ist die Begruendung verloren — und
* schlimmer: zeigt er auf die **falsche** Datei, fuehrt er jemanden in die
* Irre, der ihm vertraut.
*
* Genau das war passiert: vier Codestellen und sechs Changelog-Zeilen nannten
* dreistellig die **Tracker**-Nummern 39 und 40 — gemeint waren die
* Issue-Dateien 025 und 026. Solange es keine Dateien mit diesen Nummern gab,
* fiel es niemandem auf; mit Phase 3 waere es eine falsche Spur geworden.
* Verbindlich ist die **Dateinummer**; die Tracker-Nummer steht in der
* Issue-Datei unter „Im Tracker“.
*
* Diese Datei nennt deshalb selbst keine Nummer in der dreistelligen Form —
* sonst meldete die Pruefung ihren eigenen Kommentar.
*/
const verz = "docs/issues";
const nummern = new Set(
readdirSync(join(wurzel, verz))
.filter((n) => /^\d{3}-.+\.md$/.test(n))
.map((n) => n.slice(0, 3)),
);
const sammeln = (ordner) =>
readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => {
if (["node_modules", ".git", ".next", "generated", "test-results"].includes(eintrag.name)) {
return [];
}
const pfad = join(ordner, eintrag.name);
if (eintrag.isDirectory()) return sammeln(pfad);
return /\.(ts|tsx|mjs|md|sql)$/.test(eintrag.name) ? [pfad] : [];
});
for (const datei of sammeln(wurzel)) {
const kurz = datei.replace(`${wurzel}/`, "");
// Der Devlog zitiert Tracker-Nummern aus Pull Requests; dort gilt die Regel nicht.
if (kurz === "docs/devlog.md") continue;
const text = readFileSync(datei, "utf8");
const gemeldet = new Set();
for (const treffer of text.matchAll(/#(\d{3})\b/g)) {
const nummer = treffer[1];
if (nummern.has(nummer) || gemeldet.has(nummer)) continue;
gemeldet.add(nummer);
melde(
`${kurz} verweist auf #${nummer}, aber ${verz}/${nummer}-*.md gibt es nicht. ` +
`Gemeint ist vielleicht eine Tracker-Nummer — verbindlich ist die Dateinummer.`,
);
}
}
}
// ---- Ergebnis -------------------------------------------------------------- // ---- Ergebnis --------------------------------------------------------------
if (befunde.length > 0) { if (befunde.length > 0) {
console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`); console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`);
+9 -95
View File
@@ -1,13 +1,9 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AlertTriangle, CheckCircle2, Clock, Inbox, Server } from "lucide-react"; import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
import { Betriebsmodus } from "@/generated/prisma/enums"; import { Betriebsmodus } from "@/generated/prisma/enums";
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen"; import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular"; import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
import { ScimToken } from "@/components/verzeichnis/scim-token";
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen"; import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
import { basisAdresse } from "@/lib/adressen";
import { QUELLEN_ART_TEXT } from "@/lib/verzeichnis";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant"; import { betriebsmodus } from "@/server/mandant";
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis"; import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
@@ -34,13 +30,6 @@ const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
}); });
function Status({ quelle }: { quelle: QuellenSicht }) { function Status({ quelle }: { quelle: QuellenSicht }) {
/*
* **Bei SCIM sagt „noch nie abgeglichen“ etwas Falsches** (#029): dort wird
* nie abgeglichen, und das ist kein Rückstand. Was an diese Stelle gehört,
* steht im Token-Abschnitt — „letzte Zustellung“.
*/
if (quelle.art === "SCIM") return null;
if (quelle.letzterAbgleichAm === null) { if (quelle.letzterAbgleichAm === null) {
return ( return (
<p className="mt-2 text-sm text-nebentext"> <p className="mt-2 text-sm text-nebentext">
@@ -134,26 +123,11 @@ export default async function VerzeichnisSeite() {
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer. // daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
const handelnder = { id: benutzer.id, rolle: benutzer.rolle }; const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const quellen = await quellenListe(handelnder); const quellen = await quellenListe(handelnder);
/*
* Die Adresse, die beim Anbieter eingetragen wird. Über `basisAdresse`, weil
* sie jemand **behält** — ein Unterpfad aus `APP_URL` darf dabei nicht
* herausfallen, sonst trägt der Administrator eine Adresse ein, die ins Leere
* zeigt (derselbe Grund wie bei den Etiketten, #014).
*/
const kopfzeilen = await headers();
const scimAdresse = new URL(
"api/scim/v2",
basisAdresse(`http://${kopfzeilen.get("host") ?? "127.0.0.1:3000"}`),
).toString();
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen, // Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
// und das sind im Modus INTERN eine oder zwei. // und das sind im Modus INTERN eine oder zwei.
const logs = new Map( const logs = new Map(
await Promise.all( await Promise.all(
quellen quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
// SCIM-Quellen haben kein Abgleich-Log: sie werden nicht abgeglichen.
.filter((quelle) => quelle.art !== "SCIM")
.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
), ),
); );
@@ -161,10 +135,9 @@ export default async function VerzeichnisSeite() {
<> <>
<h1 className="text-2xl font-bold">Verzeichnis</h1> <h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext"> <p className="mt-2 max-w-prose text-nebentext">
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "} — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p> </p>
{quellen.length === 0 ? ( {quellen.length === 0 ? (
@@ -179,23 +152,7 @@ export default async function VerzeichnisSeite() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" /> <Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2> <h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{/* {quelle.aktiv ? (
* Bei SCIM gibt es keinen Zeitplan, und „kein Zeitplan“ wäre
* trotzdem die falsche Auskunft — es liest sich wie ein Mangel.
* Dort zählt, ob Zustellungen angenommen werden.
*/}
{quelle.art === "SCIM" ? (
<span
className={
quelle.aktiv
? "flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"
: "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"
}
>
<Inbox className="size-3" strokeWidth={2} aria-hidden="true" />
{quelle.aktiv ? "nimmt Zustellungen an" : "nimmt nichts an"}
</span>
) : quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"> <span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" /> <Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min alle {quelle.abgleichIntervallMinuten} min
@@ -207,65 +164,23 @@ export default async function VerzeichnisSeite() {
)} )}
</div> </div>
{/*
* Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei
* zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche
* aus Entra ID — und die Felder darunter heissen je Art anders.
*/}
<p className="mono mt-2 text-xs break-all text-nebentext"> <p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.art === "GRAPH" ? ( {quelle.url} · {quelle.basisDn}
<>
{QUELLEN_ART_TEXT.GRAPH} · {quelle.tenantId} · App {quelle.clientId}
</>
) : quelle.art === "SCIM" ? (
/*
* Bei SCIM steht hier die Adresse, die beim Anbieter
* eingetragen wird — die Angabe, die ein Administrator
* tatsächlich braucht, und die er sonst aus der Anleitung
* zusammensetzen müsste.
*/
<>
{QUELLEN_ART_TEXT.SCIM} · {scimAdresse}
</>
) : (
<>
{QUELLEN_ART_TEXT.LDAP} · {quelle.url} · {quelle.basisDn}
</>
)}
</p> </p>
<Status quelle={quelle} /> <Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
{/*
* **Zwei Arten, zwei Arbeitsweisen.** „Verbindung testen“ und
* „Jetzt abgleichen“ setzen voraus, dass Laufzettel holt; bei
* einem Push gibt es beides nicht, und ein Knopf, der nur eine
* Erklärung auswirft, ist kein Knopf.
*/}
{quelle.art === "SCIM" ? (
<ScimToken
quelleId={quelle.id}
vorhanden={quelle.scimTokenVorhanden}
gesetztAm={quelle.scimTokenGesetztAm}
letzteZustellungAm={quelle.letzteScimZustellungAm}
/>
) : (
<>
<VerbindungTesten quelleId={quelle.id} /> <VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} /> <JetztAbgleichen quelleId={quelle.id} />
</>
)}
</div> </div>
{quelle.art === "SCIM" ? null : (
<details className="mt-4 border-t border-linie pt-4"> <details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log Abgleich-Log
</summary> </summary>
<Log zeilen={logs.get(quelle.id) ?? []} /> <Log zeilen={logs.get(quelle.id) ?? []} />
</details> </details>
)}
<details className="mt-4 border-t border-linie pt-4"> <details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
@@ -283,9 +198,8 @@ export default async function VerzeichnisSeite() {
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5"> <section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2> <h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext"> <p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p> </p>
<QuelleFormular /> <QuelleFormular />
</section> </section>
@@ -20,7 +20,6 @@ const ANLASS_TEXT: Record<Meldeanlass, string> = {
LAUF_ZUGEWIESEN: "zugewiesen", LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig", LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen", LAUF_ABGESCHLOSSEN: "abgeschlossen",
LAUF_FREIGABE_OFFEN: "wartet auf Freigabe",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen", ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
}; };
+2 -9
View File
@@ -2,7 +2,6 @@ import Link from "next/link";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { ROLLEN_TEXT } from "@/lib/rollen-text"; import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; 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). * „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018).
@@ -31,7 +30,6 @@ export default async function KeinZugriffSeite({
// Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein // Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein
// Text, den jemand anderes auf diese Seite schreibt. // Text, den jemand anderes auf diese Seite schreibt.
const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined; const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined;
const einstieg = startseite(benutzer.rolle);
return ( return (
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10"> <div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10">
@@ -58,16 +56,11 @@ export default async function KeinZugriffSeite({
Anmeldung übernommen. Anmeldung übernommen.
</p> </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 <Link
href={einstieg} href="/"
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" 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"
> >
{einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"} Zur Übersicht
</Link> </Link>
</div> </div>
); );
+2 -33
View File
@@ -11,7 +11,6 @@ import {
KommentarSchreiben, KommentarSchreiben,
LaufAbschliessen, LaufAbschliessen,
LaufArchivieren, LaufArchivieren,
LaufFreigabe,
LaufBeginnen, LaufBeginnen,
LaufMitAdKontoVerknuepfen, LaufMitAdKontoVerknuepfen,
LaufZurueckholen, LaufZurueckholen,
@@ -21,7 +20,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand"; import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { anhaengeDesLaufs } from "@/server/services/anhaenge";
import { protokollDesLaufs } from "@/server/queries/protokoll"; import { protokollDesLaufs } from "@/server/queries/protokoll";
import { import {
kommentareDesLaufs, kommentareDesLaufs,
@@ -323,7 +322,7 @@ export default async function LaufSeite({
const laufProtokoll = await protokollDesLaufs(id); const laufProtokoll = await protokollDesLaufs(id);
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der // Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
// Anhaenge dieses Laufs (#032). // Anhaenge dieses Laufs (#032).
const anhaenge = await anhaengeDesLaufs(id, benutzer); const anhaenge = await anhaengeDesLaufs(id);
/* /*
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht. * jedem Seitenaufbau eine Abfrage, die niemand braucht.
@@ -379,36 +378,6 @@ export default async function LaufSeite({
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} /> <Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
{/*
* Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
* eine Entscheidung und kein Knopf unter anderen.
*
* Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
* noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
* `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
* verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
*/}
{lauf.freigabePflicht && !archiviert ? (
darf(benutzer.rolle, "freigeben") ? (
<div className="mt-4">
<LaufFreigabe
laufId={lauf.id}
bereitsFreigegeben={lauf.freigegebenAm !== null}
letzteNotiz={lauf.freigabeNotiz}
/>
</div>
) : (
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
{lauf.freigegebenAm
? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
: "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
"werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
"abzuschließen versuchst."}
{lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
</p>
)
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mt-4 flex flex-wrap items-center gap-2">
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null} {lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? ( {lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
-221
View File
@@ -1,221 +0,0 @@
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}
</>
);
}
+1 -13
View File
@@ -2,8 +2,7 @@ import Link from "next/link";
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react"; import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums"; import { LaufStatus } from "@/generated/prisma/enums";
import { fristVerstrichen } from "@/lib/fristen"; import { fristVerstrichen } from "@/lib/fristen";
import { darf, startseite } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { redirect } from "next/navigation";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
UEBERSICHT_JE_BLOCK, UEBERSICHT_JE_BLOCK,
@@ -224,17 +223,6 @@ function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
} }
export default async function Uebersichtsseite() { 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 { benutzer } = await sitzungErforderlich("lesen", "/");
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten"); const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
+1 -12
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowLeft, Eye, ShieldCheck } from "lucide-react"; import { ArrowLeft, Eye } from "lucide-react";
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten"; import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf"; import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
import { import {
@@ -50,17 +50,6 @@ function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfB
<span className="text-xs text-nebentext"> <span className="text-xs text-nebentext">
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"} {zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span> </span>
{/*
Steht an der **Version**, nicht an der Vorlage (#040): verschiedene
Versionen koennen es verschieden halten, und ein laufender Lauf behaelt
die Regel seiner Version.
*/}
{zeile.freigabePflicht ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
<ShieldCheck className="size-3" strokeWidth={2} aria-hidden="true" />
Freigabe nötig
</span>
) : null}
</div> </div>
{zeile.aenderungsnotiz ? ( {zeile.aenderungsnotiz ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p> <p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
@@ -58,7 +58,6 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
struktur: version.struktur, struktur: version.struktur,
stand: version.stand.toISOString(), stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
}} }}
lesend={veroeffentlicht || !darfBearbeiten} lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten} darfBearbeiten={darfBearbeiten}
-17
View File
@@ -22,23 +22,6 @@ const MELDUNGEN = new Map<string, string>(
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.", "oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.", "oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.", "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.",
}), }),
); );
+2 -21
View File
@@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen"; import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung"; import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer"; import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc"; import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
/** /**
@@ -35,27 +35,8 @@ export async function GET(anfrage: NextRequest): Promise<Response> {
`OIDC-Rueckruf fehlgeschlagen:`, `OIDC-Rueckruf fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler), 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( const antwort = NextResponse.redirect(
oeffentlicheAdresse(`/anmelden?fehler=${schluessel}`, anfrage.nextUrl.origin), oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin),
); );
antwort.cookies.delete(OIDC_FLUSS_COOKIE); antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort; return antwort;
+1 -1
View File
@@ -10,7 +10,7 @@ import { prisma } from "@/lib/db";
* Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im * Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im
* Protokoll des Prozesses (`docker compose logs web`, * Protokoll des Prozesses (`docker compose logs web`,
* `journalctl -u laufzettel`). Dasselbe Muster wie in * `journalctl -u laufzettel`). Dasselbe Muster wie in
* `src/server/actions/verzeichnis.ts` (#025). * `src/server/actions/verzeichnis.ts` (#039).
*/ */
export async function GET(): Promise<Response> { export async function GET(): Promise<Response> {
try { try {
+6 -35
View File
@@ -1,5 +1,4 @@
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import { laufZustand } from "@/server/queries/laeufe"; import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse"; import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse"; import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
@@ -41,14 +40,10 @@ export async function GET(
): Promise<Response> { ): Promise<Response> {
let benutzerId: string; let benutzerId: string;
let rolle: Parameters<typeof laufZustand>[1]["rolle"]; let rolle: Parameters<typeof laufZustand>[1]["rolle"];
// Die Sitzung wird festgehalten, weil der Strom sie spaeter noch braucht:
// `cookies()` gibt es in seinen Rueckrufen nicht mehr.
let sitzung: Sitzung;
try { try {
const zugang = await rechtErforderlich("lesen"); const { benutzer } = await rechtErforderlich("lesen");
benutzerId = zugang.benutzer.id; benutzerId = benutzer.id;
rolle = zugang.benutzer.rolle; rolle = benutzer.rolle;
sitzung = zugang.sitzung;
} catch (fehler) { } catch (fehler) {
if (fehler instanceof RechteFehler) { if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und // 401 und nicht 403: der Client soll die Aktualisierung einstellen und
@@ -59,7 +54,7 @@ export async function GET(
} }
const { id } = await params; const { id } = await params;
let benutzer = { id: benutzerId, rolle }; const benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer); const zustand = await laufZustand(id, benutzer);
if (!zustand) { if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
@@ -156,31 +151,7 @@ export async function GET(
return; return;
} }
/* herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
* **Der Herzschlag prueft auch die Sitzung nach** (Reviewbefund zu #029).
* Vorher wurde das Recht genau einmal geprueft, beim Aufbau, und das Paar
* aus Kennung und Rolle fuer die Lebensdauer des Stroms festgehalten — ein
* deaktiviertes Konto bekam weiter live Daten, bis jemand die Lasche
* schloss. Die Pruefung kostet eine Abfrage je offenem Strom und
* Herzschlag (25 s); dieselbe Abfrage macht jede gewoehnliche Anfrage
* ohnehin.
*
* Eine **Herabstufung** beendet den Strom nicht, sondern engt ihn ein: die
* neue Rolle gilt ab dem naechsten Ereignis. Das ist dieselbe Regel wie
* „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
*/
herzschlag = setInterval(() => {
void (async () => {
const noch = await sitzungNochGueltig(sitzung, "lesen");
if (!noch) {
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-angemeldet" })));
aufraeumen();
return;
}
if (noch.rolle !== benutzer.rolle) benutzer = { id: benutzerId, rolle: noch.rolle };
schicken(HERZSCHLAG);
})();
}, herzschlagMs());
anfrage.signal.addEventListener("abort", aufraeumen); anfrage.signal.addEventListener("abort", aufraeumen);
}, },
@@ -1,37 +0,0 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ResourceTypes` (#029).
*
* Nur `User`. **Keine `Group`** — und das ist eine Entscheidung, nicht eine
* Luecke: Laufzettel fuehrt keine Gruppen. Die Abteilung kommt als Attribut der
* Person, und die Rollen kommen aus den OIDC-Gruppen beim Anmelden, nicht aus
* einer Zuweisung. Wuerde `Group` hier stehen, schickte ein Anbieter
* Gruppenzuweisungen, die niemand verarbeitet.
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const typen = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
id: "User",
name: "User",
endpoint: "/Users",
description: "Personen, die Laufzettel als Mitarbeiter fuehrt.",
schema: SCHEMA_USER,
schemaExtensions: [{ schema: SCHEMA_ENTERPRISE, required: false }],
meta: { resourceType: "ResourceType", location: `${zugang.basis}/ResourceTypes/User` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: typen.length,
itemsPerPage: typen.length,
startIndex: 1,
Resources: typen,
});
}
-89
View File
@@ -1,89 +0,0 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/Schemas` (#029).
*
* Gemeldet werden **genau die Attribute, die Laufzettel fuehrt** — nicht der
* ganze Standard. Ein Anbieter liest das und schickt danach nur noch, was hier
* steht; ein zu grosszuegiges Schema fuehrt zu Feldern, die stillschweigend
* verfallen.
*/
function attribut(
name: string,
typ: "string" | "boolean" | "complex" | "reference",
zusatz: Record<string, unknown> = {},
): Record<string, unknown> {
return {
name,
type: typ,
multiValued: false,
required: false,
caseExact: false,
mutability: "readWrite",
returned: "default",
uniqueness: "none",
...zusatz,
};
}
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const schemas = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_USER,
name: "User",
description: "Eine Person, die Laufzettel als Mitarbeiter fuehrt.",
attributes: [
attribut("userName", "string", { required: true, uniqueness: "server" }),
attribut("externalId", "string"),
attribut("displayName", "string"),
attribut("title", "string"),
attribut("active", "boolean"),
attribut("name", "complex", {
subAttributes: [attribut("givenName", "string"), attribut("familyName", "string")],
}),
attribut("emails", "complex", {
multiValued: true,
subAttributes: [
attribut("value", "string"),
attribut("type", "string"),
attribut("primary", "boolean"),
],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_USER}` },
},
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_ENTERPRISE,
name: "EnterpriseUser",
description: "Die Erweiterung, aus der Laufzettel die Abteilung und den Vorgesetzten liest.",
attributes: [
attribut("department", "string"),
/*
* `manager` gehoert hierher, auch wenn Laufzettel daraus nur `value`
* braucht: ein Anbieter liest dieses Schema und schickt nur, was darin
* steht. Fehlte es, bliebe der Vorgesetzte dauerhaft leer — und zwar
* ohne Fehlermeldung auf beiden Seiten.
*/
attribut("manager", "complex", {
subAttributes: [attribut("value", "string"), attribut("$ref", "reference")],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_ENTERPRISE}` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: schemas.length,
itemsPerPage: schemas.length,
startIndex: 1,
Resources: schemas,
});
}
@@ -1,45 +0,0 @@
import { SCHEMA_PATCH, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ServiceProviderConfig` (#029).
*
* **Ohne diesen Endpunkt faengt Entra ID nicht an.** Er sagt, was der Anbieter
* erwarten darf — und das Entscheidende daran sind die **Verneinungen**: `bulk`,
* `sort` und `filter` stehen auf `false`, weil Laufzettel sie nicht kann. Ein
* Anbieter, der sie fuer unterstuetzt haelt, schickt Anfragen, die niemand
* beantwortet.
*
* `filter.supported: false` sieht dabei strenger aus, als es ist: der eine
* Filter `userName eq "…"` wird beantwortet. Ihn hier als unterstuetzt zu
* melden, hiesse aber, die ganze Filtersprache zu versprechen — und ein
* Anbieter, der das glaubt, schickt `emails[type eq "work"].value co "@"`.
*/
export async function GET(anfrage: Request): Promise<Response> {
// Auch dieser Endpunkt verlangt ein Token: er verraet, dass hier ein
// SCIM-Dienst laeuft, und das muss nicht jeder wissen.
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
return scimAntwort({
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
documentationUri: "https://git.wilde-server.de/florian.wilde/Laufzettel",
patch: { supported: true },
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
filter: { supported: false, maxResults: 200 },
changePassword: { supported: false },
sort: { supported: false },
etag: { supported: false },
authenticationSchemes: [
{
type: "oauthbearertoken",
name: "OAuth Bearer Token",
description: "Ein Token je Verzeichnisquelle, in Laufzettel erzeugt und einmal angezeigt.",
primary: true,
},
],
meta: { resourceType: "ServiceProviderConfig", location: `${zugang.basis}/ServiceProviderConfig` },
// Nur zur Orientierung: welche Schemas dieser Dienst kennt.
...{ _schemas: [SCHEMA_USER, SCHEMA_PATCH] },
});
}
-130
View File
@@ -1,130 +0,0 @@
import { benutzerLesen, patchAnwenden, patchLesen, personDarstellen } from "@/lib/scim";
import { alsScimAntwort, quelleAusAnfrage, rumpfLesen, scimAntwort, scimFehler } from "@/server/scim/handler";
import { personAendern, personDeaktivieren, personHolen, type ScimFehler } from "@/server/services/scim";
/**
* SCIM: `/Users/{id}` (#029).
*
* Vier Verben, und das wichtigste ist `DELETE`: es **deaktiviert**. An einer
* Person haengen Laeufe und Geraete; geloescht wird in diesem Projekt nichts.
* Der Anbieter bekommt `204` und haelt sie damit fuer entfernt — aus seiner
* Sicht richtig, denn sie wird ihm nicht mehr zugewiesen.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const { id } = await params;
const person = await personHolen(zugang.quelle, id);
if (!person) return scimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function PUT(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const { id } = await params;
/*
* `PUT` ersetzt die Darstellung vollstaendig: ein Feld, das nicht mitkommt,
* wird geleert. Genau das steht im gelesenen Rumpf, deshalb geht er
* unveraendert an den Dienst.
*/
const person = await personAendern(zugang.quelle, id, benutzerLesen(gelesen.rumpf));
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function PATCH(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const { id } = await params;
const schritte = patchLesen(gelesen.rumpf);
if (!schritte) {
return scimFehler(
400,
"Der Rumpf ist keine PatchOp-Anfrage: erwartet wird „Operations“ mit op add, replace oder remove.",
"invalidSyntax",
);
}
const vorhanden = await personHolen(zugang.quelle, id);
if (!vorhanden) return scimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
/*
* Gepatcht wird auf dem **vorhandenen** Stand, nicht auf einer leeren
* Eingabe: ein `PATCH` auf `active` darf die uebrigen Felder nicht leeren.
* Was danach herauskommt, ist der vollstaendige Zielzustand — ein `null`
* darin ist eine Leerung und keine Luecke, und so liest der Dienst es auch.
*/
const { eingabe, uebergangen } = patchAnwenden(schritte, {
externeId: vorhanden.externeId,
upn: vorhanden.upn,
vorname: vorhanden.vorname,
nachname: vorhanden.nachname,
anzeigename: vorhanden.anzeigename,
mail: vorhanden.mail,
abteilung: vorhanden.abteilung,
titel: vorhanden.titel,
aktiv: vorhanden.aktiv,
vorgesetzterScimId: vorhanden.vorgesetzterId,
});
if (uebergangen.length > 0) {
/*
* Uebergangene Pfade sind kein Fehler — Anbieter schicken Felder, die
* Laufzettel nicht fuehrt. Ins Protokoll des Prozesses gehoeren sie
* trotzdem: sonst sucht beim naechsten „warum steht das nicht drin“
* jemand an der falschen Stelle.
*/
console.info(`SCIM-PATCH: übergangene Pfade: ${uebergangen.join(", ")}`);
}
const person = await personAendern(zugang.quelle, id, eingabe);
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function DELETE(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const { id } = await params;
await personDeaktivieren(zugang.quelle, id);
// 204 ohne Rumpf – und die Zeile bleibt, siehe Kopfkommentar.
return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
} catch (fehler) {
return alsScimAntwort(fehler as ScimFehler);
}
}
-61
View File
@@ -1,61 +0,0 @@
import { benutzerLesen, benutzernameAusFilter, listeDarstellen, personDarstellen } from "@/lib/scim";
import { alsScimAntwort, quelleAusAnfrage, rumpfLesen, scimAntwort, scimFehler } from "@/server/scim/handler";
import { personAnlegen, personenSuchen } from "@/server/services/scim";
/**
* SCIM: `/Users` (#029).
*
* `GET` mit dem Filter `userName eq "…"` ist die Frage, die ein Anbieter **vor
* jedem Anlegen** stellt. `POST` legt an.
*
* Dieser Handler prueft sein Token selbst — die Huelle unter `(app)` liegt hier
* nicht darueber, und der Proxy laesst `/api/scim` ohne Cookie durch
* (CLAUDE.md).
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const filter = new URL(anfrage.url).searchParams.get("filter");
const gesucht = benutzernameAusFilter(filter);
if (gesucht === null) {
/*
* Ein Filter, den wir nicht verstehen, wird **abgewiesen** und nicht
* ignoriert. Ihn zu ignorieren hiesse, alle Personen zu liefern — und der
* Anbieter schloesse daraus, die gesuchte sei darunter. Er legte sie dann
* nicht an.
*/
return scimFehler(
400,
`Von diesem Endpunkt wird nur der Filter userName eq "…" unterstützt, nicht „${filter}“.`,
"invalidFilter",
);
}
const personen = await personenSuchen(zugang.quelle, gesucht);
return scimAntwort(listeDarstellen(personen, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function POST(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const person = await personAnlegen(zugang.quelle, benutzerLesen(gelesen.rumpf));
// 201 mit `Location`, wie RFC 7644 es verlangt.
return scimAntwort(personDarstellen(person, zugang.basis), 201, {
Location: `${zugang.basis}/Users/${person.id}`,
});
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
+2 -13
View File
@@ -4,7 +4,6 @@ import {
Building2, Building2,
FileText, FileText,
LayoutDashboard, LayoutDashboard,
Laptop,
ListChecks, ListChecks,
LogOut, LogOut,
Menu, Menu,
@@ -34,7 +33,6 @@ const SYMBOLE: Record<string, LucideIcon> = {
Building2, Building2,
Archive, Archive,
Settings, Settings,
Laptop,
}; };
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) { function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
@@ -186,15 +184,6 @@ export function Huelle({
aktiv?: string; aktiv?: string;
benutzer?: AngemeldeterBenutzer; 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 ( return (
<div className="min-h-dvh md:flex"> <div className="min-h-dvh md:flex">
{/* Handy: Kopfzeile mit Menue */} {/* Handy: Kopfzeile mit Menue */}
@@ -202,7 +191,7 @@ export function Huelle({
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center gap-3 px-4 py-3">
<Bildmarke className="size-6 shrink-0 text-akzent" /> <Bildmarke className="size-6 shrink-0 text-akzent" />
<Wortmarke className="text-lg" /> <Wortmarke className="text-lg" />
{darfScannen ? <ScannenKnopf className="ml-auto" /> : null} <ScannenKnopf className="ml-auto" />
<details className="relative"> <details className="relative">
<summary <summary
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext" className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
@@ -228,7 +217,7 @@ export function Huelle({
<Bildmarke className="size-7 shrink-0 text-akzent" /> <Bildmarke className="size-7 shrink-0 text-akzent" />
<Wortmarke className="text-xl" /> <Wortmarke className="text-xl" />
</Link> </Link>
{darfScannen ? <ScannenKnopf className="mb-6" /> : null} <ScannenKnopf className="mb-6" />
<nav aria-label="Hauptnavigation"> <nav aria-label="Hauptnavigation">
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} /> <NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
</nav> </nav>
+1 -104
View File
@@ -1,13 +1,11 @@
"use client"; "use client";
import { useActionState, useState } from "react"; import { useActionState, useState } from "react";
import { Archive, CheckCircle2, Link2, PlayCircle, ShieldCheck, Undo2 } from "lucide-react"; import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react";
import { import {
abschnittNichtZutreffendAktion, abschnittNichtZutreffendAktion,
kommentarHinzufuegenAktion, kommentarHinzufuegenAktion,
laufAbschliessenAktion, laufAbschliessenAktion,
laufFreigebenAktion,
laufZurueckweisenAktion,
laufArchivierenAktion, laufArchivierenAktion,
laufMitAdKontoVerknuepfenAktion, laufMitAdKontoVerknuepfenAktion,
laufOeffnenAktion, laufOeffnenAktion,
@@ -95,107 +93,6 @@ export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: num
); );
} }
/**
* Freigeben und Zurueckweisen (#040).
*
* **Eine Komponente fuer beides**, weil es dieselbe Entscheidung ist: der
* Freigebende sieht den Lauf an und sagt ja oder nein. Zwei getrennte Karten
* haetten dieselbe Notiz zweimal.
*
* Die Notiz ist bei der Freigabe freiwillig und bei der Zurueckweisung Pflicht —
* geprueft wird das in der Server Action und im Dienst, nicht hier: eine Regel,
* die nur das Formular kennt, gilt nicht fuer den naechsten Aufrufer.
*/
export function LaufFreigabe({
laufId,
bereitsFreigegeben,
letzteNotiz,
}: {
laufId: string;
bereitsFreigegeben: boolean;
/** Die letzte Begruendung – nach einer Zurueckweisung steht sie hier. */
letzteNotiz: string | null;
}) {
const [freigabe, freigeben, freigabeLaeuft] = useActionState<AktionsErgebnis, FormData>(
laufFreigebenAktion,
{},
);
const [rueckweisung, zurueckweisen, rueckweisungLaeuft] = useActionState<AktionsErgebnis, FormData>(
laufZurueckweisenAktion,
{},
);
const [notiz, setzeNotiz] = useState("");
return (
<div className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<p className="flex items-start gap-2 text-sm">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span>
{bereitsFreigegeben ? (
<>Dieser Lauf ist freigegeben. Zurückweisen nimmt die Freigabe wieder zurück.</>
) : (
<>
Dieser Lauf verlangt eine <strong>Freigabe durch einen Zweiten</strong>. Wer daran gearbeitet
hat, kann sie nicht erteilen.
</>
)}
</span>
</p>
{letzteNotiz ? (
<p className="max-w-prose rounded-karte bg-flaeche-hoch p-3 text-sm text-nebentext">
Letzte Begründung: {letzteNotiz}
</p>
) : null}
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Begründung (bei einer Zurückweisung Pflicht)</span>
<textarea
name="notiz"
rows={2}
value={notiz}
onChange={(ereignis) => setzeNotiz(ereignis.target.value)}
className={`${FELD} py-2`}
placeholder="Prüfsumme fehlt im Protokoll"
/>
</label>
<div className="flex flex-wrap gap-2">
{bereitsFreigegeben ? null : (
<form action={freigeben}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="notiz" value={notiz} />
<button
type="submit"
disabled={freigabeLaeuft || rueckweisungLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ShieldCheck className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{freigabeLaeuft ? "Wird freigegeben …" : "Freigeben"}
</button>
</form>
)}
<form action={zurueckweisen}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="notiz" value={notiz} />
<button
type="submit"
disabled={freigabeLaeuft || rueckweisungLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Undo2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{rueckweisungLaeuft ? "Wird zurückgewiesen …" : "Zurückweisen"}
</button>
</form>
</div>
<Meldung ergebnis={freigabe} />
<Meldung ergebnis={rueckweisung} />
</div>
);
}
/** /**
* Archivieren – die zweite, ausdrueckliche Handlung. * Archivieren – die zweite, ausdrueckliche Handlung.
* *
+8 -190
View File
@@ -5,10 +5,8 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular"; import { FELD, KAESTCHEN } from "@/lib/formular";
import { import {
AD_ZUORDNUNG, AD_ZUORDNUNG,
GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS, INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS, INTERVALL_MINDESTENS,
QUELLEN_ART_TEXT,
ZUORDNUNGS_BESCHRIFTUNG, ZUORDNUNGS_BESCHRIFTUNG,
ZUORDNUNGS_FELDER, ZUORDNUNGS_FELDER,
} from "@/lib/verzeichnis"; } from "@/lib/verzeichnis";
@@ -38,22 +36,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
const [zuordnungOffen, setzeZuordnungOffen] = useState(false); const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
const [adresse, setzeAdresse] = useState(quelle?.url ?? ""); const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
const klartext = adresse.trim().startsWith("ldap://"); const klartext = adresse.trim().startsWith("ldap://");
/*
* Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an
* ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
*/
const [art, setzeArt] = useState<"LDAP" | "GRAPH" | "SCIM">(quelle?.art ?? "LDAP");
const istGraph = art === "GRAPH";
/*
* SCIM ist die Art ohne Zugangsdaten: Laufzettel verbindet sich nicht, es wird
* beliefert (#029). Deshalb faellt hier alles weg, was zum Holen gehoert —
* Adresse, Geheimnis, Filter, Zeitplan und die Attributzuordnung. Letztere
* bestimmt bei einem Push der Anbieter, sie steht in RFC 7643.
*/
const istScim = art === "SCIM";
const istLdap = art === "LDAP";
return ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
@@ -70,116 +52,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
/> />
</label> </label>
{bearbeiten ? (
<>
<input type="hidden" name="art" value={art} />
<p className="text-sm text-nebentext">
Art: <strong className="text-text">{QUELLEN_ART_TEXT[art]}</strong> — sie lässt sich nicht ändern.
Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein Mitarbeiter
wiedererkannt.
</p>
</>
) : (
<fieldset className="flex flex-col gap-2 text-sm">
<legend className="mb-1 text-nebentext">Art</legend>
{(
[
{
wert: "LDAP" as const,
titel: "LDAP",
text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
},
{
wert: "GRAPH" as const,
titel: "Microsoft Graph",
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
},
{
wert: "SCIM" as const,
titel: "SCIM (wird geliefert)",
text:
"Der Anbieter schickt Änderungen, sobald sie dort entstehen — authentik, Entra ID, Okta. " +
"Laufzettel holt dabei nichts ab und braucht keine Zugangsdaten.",
},
] satisfies readonly { wert: "LDAP" | "GRAPH" | "SCIM"; titel: string; text: string }[]
).map((wahl) => (
<label
key={wahl.wert}
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-karte border border-linie p-3 md:min-h-10"
>
<input
type="radio"
name="art"
value={wahl.wert}
checked={art === wahl.wert}
onChange={() => setzeArt(wahl.wert)}
className="mt-0.5 size-4 accent-akzent"
/>
<span>
<span className="font-medium">{wahl.titel}</span>
<span className="block text-xs text-nebentext">{wahl.text}</span>
</span>
</label>
))}
</fieldset>
)}
{istGraph ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mandanten-ID</span>
<input
name="tenantId"
defaultValue={quelle?.tenantId ?? ""}
required
placeholder="contoso.onmicrosoft.com oder die GUID"
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anwendungs-ID</span>
<input
name="clientId"
defaultValue={quelle?.clientId ?? ""}
required
placeholder="GUID der App-Registrierung"
className={`${FELD} mono`}
/>
</label>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-secret" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Client-Secret
</label>
<input
id="quelle-secret"
name="clientSecret"
type="password"
autoComplete="new-password"
aria-describedby="quelle-secret-hinweis"
placeholder={quelle?.clientSecretVorhanden ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-secret-hinweis" className="text-xs text-nebentext">
{quelle?.clientSecretVorhanden
? "Leer lassen behält das gespeicherte Geheimnis."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</div>
<p className="max-w-prose text-xs text-nebentext">
Die App-Registrierung braucht die <strong>Anwendungsberechtigung</strong>{" "}
<span className="mono">User.Read.All</span> mit Administratoreinwilligung. Wie das eingerichtet
wird, steht in <span className="mono">docs/betrieb/entra-id.md</span>. Entra ID kennt keine OU,
deshalb bleibt <span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
</>
) : null}
{istLdap ? (
<>
{/* {/*
* Die Erklärung steht **außerhalb** des `label` und hängt über * Die Erklärung steht **außerhalb** des `label` und hängt über
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens: * `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
@@ -202,9 +74,9 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
className={`${FELD} mono`} className={`${FELD} mono`}
/> />
<span id="quelle-url-hinweis" className="text-xs text-nebentext"> <span id="quelle-url-hinweis" className="text-xs text-nebentext">
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
dann hochgestuft) und <span className="mono">ldap://</span>. hochgestuft) und <span className="mono">ldap://</span>.
</span> </span>
{klartext ? ( {klartext ? (
/* /*
@@ -214,8 +86,8 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<span role="status" className="flex items-start gap-2 text-xs text-faellig"> <span role="status" className="flex items-start gap-2 text-xs text-faellig">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" /> <ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span> <span>
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
</span> </span>
</span> </span>
) : null} ) : null}
@@ -278,42 +150,11 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
className={`${FELD} mono`} className={`${FELD} mono`}
/> />
<span id="quelle-filter-hinweis" className="text-xs text-nebentext"> <span id="quelle-filter-hinweis" className="text-xs text-nebentext">
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht. dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</span> </span>
</div> </div>
</>
) : null}
{istScim ? (
<>
{/*
* Das Intervall gilt fuer SCIM nicht — es wird nichts geholt. Es geht
* trotzdem mit, weil die Spalte einen Wert braucht; sichtbar waere es
* eine Einstellung, die nichts tut.
*/}
<input
type="hidden"
name="abgleichIntervallMinuten"
value={quelle?.abgleichIntervallMinuten ?? 60}
/>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Zustellungen annehmen
</label>
<p className="max-w-prose text-xs text-nebentext">
Ohne Haken antwortet der Endpunkt mit <span className="mono">401</span>, auch mit gültigem Token —
so lässt sich eine Quelle stilllegen, ohne das Token beim Anbieter zu löschen. Das Token selbst
erzeugst du nach dem Speichern an der Quelle.
</p>
</>
) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span> <span className="text-nebentext">Abgleich alle … Minuten</span>
@@ -337,7 +178,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
Nach Zeitplan abgleichen Nach Zeitplan abgleichen
</label> </label>
</div> </div>
)}
{/* {/*
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand * Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
@@ -345,21 +185,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die * OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
* Oberfläche nicht (Reviewbefund zu #016). * Oberfläche nicht (Reviewbefund zu #016).
*/} */}
{istScim ? (
<p className="max-w-prose text-xs text-nebentext">
Eine Attributzuordnung gibt es hier nicht: bei SCIM bestimmt der Anbieter die Feldnamen, und sie
stehen in RFC 7643 fest. Abteilung und Titel kommen aus der Erweiterung{" "}
<span className="mono">enterprise:2.0:User</span>; eine OU kennt SCIM nicht, deshalb bleibt{" "}
<span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
) : null}
{/*
* Bei SCIM gar nicht gerendert und nicht nur verborgen: ein verstecktes
* Feld wird mitgeschickt, und dann staende in der Datenbank eine
* Zuordnung, die nie etwas bedeutet.
*/}
{istScim ? null : (
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}> <details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen Attributzuordnung anpassen
@@ -375,7 +200,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<input <input
name={`attr.${feld}`} name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]} defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]} placeholder={AD_ZUORDNUNG[feld]}
className={`${FELD} mono`} className={`${FELD} mono`}
/> />
</label> </label>
@@ -386,13 +211,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an. Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p> </p>
</details> </details>
)}
{/*
* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber
* oeffentliches TLS, und bei SCIM baut Laufzettel keine Verbindung auf.
*/}
{istLdap ? (
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}> <details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10"> <summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen Eigenes CA-Zertifikat hinterlegen
@@ -435,7 +254,6 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</label> </label>
) : null} ) : null}
</details> </details>
) : null}
{ergebnis.fehler ? ( {ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler"> <p role="alert" className="max-w-prose text-sm text-fehler">
-181
View File
@@ -1,181 +0,0 @@
"use client";
import { useActionState, useState } from "react";
import { Check, Copy, KeyRound, ShieldAlert, Trash2 } from "lucide-react";
import {
scimTokenErzeugenAktion,
scimTokenWiderrufenAktion,
type TokenErgebnis,
} from "@/server/actions/verzeichnis";
/**
* Das SCIM-Token einer Quelle: erzeugen, einmal zeigen, widerrufen (#029).
*
* **Einmal zeigen ist die ganze Schwierigkeit.** Das Token steht in der
* Datenbank nur als argon2-Hash, es gibt also keinen zweiten Weg, es anzusehen —
* und das ist Absicht, denn es ist der einzige Mandantenbeweis, den eine
* Zustellung mitbringt. Wer es verliert, erzeugt ein neues; das alte gilt dann
* nicht mehr.
*
* Deshalb steht es hier vollständig und auswählbar da und nicht nur hinter einem
* Kopier-Knopf: ohne Recht auf die Zwischenablage wäre es sonst unerreichbar,
* und eine zweite Anzeige gibt es nicht.
*/
export function ScimToken({
quelleId,
vorhanden,
gesetztAm,
letzteZustellungAm,
}: {
quelleId: string;
vorhanden: boolean;
gesetztAm: Date | null;
letzteZustellungAm: Date | null;
}) {
const [erzeugt, erzeugen, erzeugtLaeuft] = useActionState<TokenErgebnis, FormData>(
scimTokenErzeugenAktion,
{},
);
const [widerrufen, widerrufenAktion, widerrufenLaeuft] = useActionState<TokenErgebnis, FormData>(
scimTokenWiderrufenAktion,
{},
);
const [kopiert, setzeKopiert] = useState(false);
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
async function kopieren(wert: string) {
try {
await navigator.clipboard.writeText(wert);
setzeKopiert(true);
window.setTimeout(() => setzeKopiert(false), 2000);
} catch {
setzeKopiert(false);
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1 text-sm">
<p className="flex flex-wrap items-center gap-2">
<KeyRound className="size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
{vorhanden ? (
<span className="text-nebentext">
Ein Token gilt{gesetztAm ? <> seit {zeitpunkt.format(gesetztAm)}</> : null}.
</span>
) : (
<span className="text-nebentext">
Kein Token. Ohne eines antwortet der Endpunkt auf jede Zustellung mit{" "}
<span className="mono">401</span>.
</span>
)}
</p>
{/*
* „Letzte Zustellung“ sitzt hier, wo bei LDAP „Verbindung testen“ steht —
* es ist die Auskunft, die an deren Stelle tritt. Bei einem Push gibt es
* nichts zu testen: ob die Einrichtung stimmt, zeigt erst die erste
* Zustellung.
*/}
<p className="text-nebentext">
{letzteZustellungAm ? (
<>Letzte Zustellung am {zeitpunkt.format(letzteZustellungAm)}.</>
) : (
<>
Noch keine Zustellung angekommen. Trag das Token beim Anbieter ein und weise dort eine Person zu
— danach steht hier der Zeitpunkt.
</>
)}
</p>
</div>
{/*
* Das Token steht nur in dieser einen Antwort. Ein Neuladen der Seite
* nimmt es mit, und es kommt nicht wieder — deshalb der Hinweis daneben
* und nicht darunter.
*/}
{erzeugt.token ? (
<div className="flex flex-col gap-2 rounded-karte border border-akzent bg-akzent-flaeche p-3">
<p className="flex items-start gap-2 text-sm text-text">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span>
Kopier es jetzt. Es ist nur hier zu sehen — gespeichert wird es als Hash, und ein zweites
Ansehen gibt es nicht.
</span>
</p>
<code className="mono rounded-karte bg-grund p-3 text-xs break-all text-text select-all">
{erzeugt.token}
</code>
<button
type="button"
onClick={() => void kopieren(erzeugt.token ?? "")}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-text md:min-h-10"
>
{kopiert ? (
<Check className="size-[18px] text-erledigt" strokeWidth={2} aria-hidden="true" />
) : (
<Copy className="size-[18px]" strokeWidth={2} aria-hidden="true" />
)}
{kopiert ? "Kopiert" : "Token kopieren"}
</button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<form action={erzeugen}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={erzeugtLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<KeyRound className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{erzeugtLaeuft ? "Wird erzeugt …" : vorhanden ? "Neues Token erzeugen" : "Token erzeugen"}
</button>
</form>
{vorhanden ? (
<form action={widerrufenAktion}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={widerrufenLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Trash2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{widerrufenLaeuft ? "Wird widerrufen …" : "Token widerrufen"}
</button>
</form>
) : null}
</div>
{vorhanden ? (
<p className="max-w-prose text-xs text-nebentext">
Ein neues Token ersetzt das bestehende: es gibt genau eines je Quelle. Bis es beim Anbieter
eingetragen ist, kommen dessen Zustellungen nicht an — und werden dort als Fehler vermerkt, nicht
verworfen.
</p>
) : null}
{[erzeugt, widerrufen].map((ergebnis, nummer) => (
<div key={nummer}>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</div>
))}
</div>
);
}
@@ -1,273 +0,0 @@
"use client";
import { useId } from "react";
import { AlertTriangle, GitBranch } from "lucide-react";
import { FELD } from "@/lib/formular";
import {
BEDINGUNGS_ZUSTAENDE,
type BedingungsZustand,
type PfadBedingung,
VARIABLEN_OPERATOREN,
type VariablenOperator,
} from "@/lib/bedingungen";
/**
* Eine Bedingung an einem Abschnitt oder Schritt setzen (#039).
*
* **Eine Bedingung, kein Ausdrucksbaum** — das ist die Entscheidung aus dem
* Dienst, und sie zeigt sich hier: es gibt kein „und“, kein „oder“ und keine
* zweite Zeile. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte. Ein
* Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre eine kleine
* Abfragesprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
* schlimmer als einer zu viel.
*
* **Verwiesen wird über Titel, nicht über Kennungen.** `entwurfSpeichern`
* löscht den Entwurf und schreibt ihn neu; eine Kennung überlebte das nicht.
* Titel sind außerdem das, was ein Redakteur ohnehin liest.
*/
const OPERATOR_TEXT: Record<VariablenOperator, string> = {
ist: "ist gleich",
istNicht: "ist nicht",
gesetzt: "ist ausgefüllt",
leer: "ist leer",
};
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
/** Ein Schritt, auf den sich eine Bedingung beziehen kann. */
export type WaehlbarerSchritt = {
readonly abschnitt: string;
readonly schritt: string;
/** Steht dieser Schritt **nach** dem bedingten Teil? Dann wird gewarnt. */
readonly spaeter: boolean;
};
export function BedingungWaehlen({
bedingung,
variablen,
schritte,
lesend,
setzen,
}: {
bedingung: PfadBedingung | undefined;
/** Die Variablen dieser Version: Schlüssel und Beschriftung. */
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
schritte: readonly WaehlbarerSchritt[];
lesend: boolean;
setzen: (bedingung: PfadBedingung | undefined) => void;
}) {
const kennung = useId();
const art = bedingung?.art ?? "keine";
/** Der gewählte Schritt, falls die Bedingung auf einen zeigt. */
const gewaehlterSchritt =
bedingung?.art === "schritt"
? schritte.find(
(eintrag) => eintrag.abschnitt === bedingung.abschnitt && eintrag.schritt === bedingung.schritt,
)
: undefined;
function artWechseln(neu: string): void {
if (neu === "keine") return setzen(undefined);
if (neu === "variable") {
const erste = variablen[0];
if (!erste) return setzen(undefined);
return setzen({ art: "variable", schluessel: erste.schluessel, operator: "ist", wert: "" });
}
const erster = schritte[0];
if (!erster) return setzen(undefined);
return setzen({
art: "schritt",
abschnitt: erster.abschnitt,
schritt: erster.schritt,
zustaende: ["ERLEDIGT"],
});
}
return (
<details className="rounded-karte border border-linie">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-3 text-sm text-nebentext hover:text-text md:min-h-10">
<GitBranch className="size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
{bedingung ? "Bedingung gesetzt" : "Bedingung (optional)"}
</summary>
<div className="flex flex-col gap-3 border-t border-linie p-3">
{/*
`htmlFor` und `id` statt eines umhuellenden `label`: der zugaengliche
Name eines `select` im Label schluckt sonst den Text der gewaehlten
Option mit („VariableKontotyp“). Fuer einen Screenreader ist das eine
Bezeichnung, die sich beim Umschalten aendert — und kein Werkzeug
findet das Feld noch ueber seinen Namen.
*/}
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-art`} className="text-nebentext">
Gilt
</label>
<select
id={`${kennung}-art`}
value={art}
disabled={lesend}
onChange={(ereignis) => artWechseln(ereignis.target.value)}
className={FELD}
>
<option value="keine">immer</option>
<option value="variable" disabled={variablen.length === 0}>
abhängig von einer Variable
</option>
<option value="schritt" disabled={schritte.length === 0}>
abhängig von einem Schritt
</option>
</select>
</div>
{variablen.length === 0 && schritte.length === 0 ? (
<p className="text-xs text-nebentext">
Für eine Bedingung braucht die Vorlage eine Variable oder einen anderen Schritt.
</p>
) : null}
{bedingung?.art === "variable" ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-variable`} className="text-nebentext">
Variable
</label>
<select
id={`${kennung}-variable`}
value={bedingung.schluessel}
disabled={lesend}
onChange={(ereignis) => setzen({ ...bedingung, schluessel: ereignis.target.value })}
className={FELD}
>
{variablen.map((variable) => (
<option key={variable.schluessel} value={variable.schluessel}>
{variable.beschriftung}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-vergleich`} className="text-nebentext">
Vergleich
</label>
<select
id={`${kennung}-vergleich`}
value={bedingung.operator}
disabled={lesend}
onChange={(ereignis) =>
setzen({ ...bedingung, operator: ereignis.target.value as VariablenOperator })
}
className={FELD}
>
{VARIABLEN_OPERATOREN.map((operator) => (
<option key={operator} value={operator}>
{OPERATOR_TEXT[operator]}
</option>
))}
</select>
</div>
{/*
Nur bei „ist“ und „ist nicht“: `gesetzt` und `leer` vergleichen mit
nichts, und ein Feld, das nichts tut, verwirrt.
*/}
{bedingung.operator === "ist" || bedingung.operator === "istNicht" ? (
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-wert`} className="text-nebentext">
Wert
</label>
<input
id={`${kennung}-wert`}
value={bedingung.wert ?? ""}
disabled={lesend}
onChange={(ereignis) => setzen({ ...bedingung, wert: ereignis.target.value })}
placeholder="AD"
className={FELD}
/>
</div>
) : null}
</div>
) : null}
{bedingung?.art === "schritt" ? (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-schritt`} className="text-nebentext">
Schritt
</label>
<select
id={`${kennung}-schritt`}
value={`${bedingung.abschnitt}\u0000${bedingung.schritt}`}
disabled={lesend}
onChange={(ereignis) => {
const [abschnitt, schritt] = ereignis.target.value.split("\u0000");
if (abschnitt && schritt) setzen({ ...bedingung, abschnitt, schritt });
}}
className={FELD}
>
{schritte.map((eintrag) => (
<option
key={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
value={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
>
{eintrag.abschnitt} · {eintrag.schritt}
</option>
))}
</select>
</div>
<fieldset className="flex flex-col gap-2 text-sm">
<legend className="mb-1 text-nebentext">… ist</legend>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{BEDINGUNGS_ZUSTAENDE.map((zustand) => (
<label key={zustand} className="flex min-h-11 items-center gap-2 md:min-h-10">
<input
type="checkbox"
id={`${kennung}-${zustand}`}
checked={bedingung.zustaende.includes(zustand)}
disabled={lesend}
onChange={(ereignis) => {
const naechste = ereignis.target.checked
? [...bedingung.zustaende, zustand]
: bedingung.zustaende.filter((eintrag) => eintrag !== zustand);
/*
* Mindestens einer bleibt gesetzt: eine leere Liste wäre
* eine Bedingung, die nie zutrifft — ein Abschnitt, der
* für immer unsichtbar bleibt.
*/
if (naechste.length > 0) setzen({ ...bedingung, zustaende: naechste });
}}
className="size-4 accent-akzent"
/>
{ZUSTAND_TEXT[zustand]}
</label>
))}
</div>
</fieldset>
{gewaehlterSchritt?.spaeter ? (
/*
* Eine Warnung und kein Verbot: erlaubt ist es, nur fast immer ein
* Versehen. Der bedingte Teil wäre zunächst unsichtbar und
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat —
* also rückwärts, und damit rechnet niemand.
*/
<p role="status" className="flex items-start gap-2 text-xs text-faellig">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Dieser Schritt kommt <strong>später</strong> als der bedingte Teil. Er wäre beim Abarbeiten
zunächst unsichtbar und erschiene erst rückwärts. Meist ist ein Schritt davor gemeint.
</span>
</p>
) : null}
</div>
) : null}
</div>
</details>
);
}
@@ -3,7 +3,6 @@
import { useState } from "react"; import { useState } from "react";
import { Blocks } from "lucide-react"; import { Blocks } from "lucide-react";
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen"; import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder"; import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren"; import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt"; import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
@@ -33,8 +32,6 @@ export function AbschnittKarte({
anzahl, anzahl,
lesend, lesend,
abteilungen, abteilungen,
variablen,
alleSchritte,
zuletztAngelegt, zuletztAngelegt,
anfasser, anfasser,
aendern, aendern,
@@ -51,14 +48,6 @@ export function AbschnittKarte({
lesend: boolean; lesend: boolean;
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */ /** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
abteilungen: readonly string[]; abteilungen: readonly string[];
/** Die Variablen dieser Version – für eine Bedingung (#039). */
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
/** Alle Schritte der Version in Abarbeitungsreihenfolge, mit laufender Nummer. */
alleSchritte: readonly {
readonly abschnitt: string;
readonly schritt: string;
readonly stelle: number;
}[];
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */ /** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null; zuletztAngelegt: string | null;
anfasser: React.ReactNode; anfasser: React.ReactNode;
@@ -73,12 +62,6 @@ export function AbschnittKarte({
const [offen, setzeOffen] = useState(true); const [offen, setzeOffen] = useState(true);
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt; const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
const anzahlSchritte = abschnitt.schritte.length; const anzahlSchritte = abschnitt.schritte.length;
/*
* Die laufende Nummer des ersten Schritts dieses Abschnitts. Alles ab hier
* gilt als „spaeter“ — der Abschnitt beginnt dort.
*/
const ersteStelleDiesesAbschnitts =
alleSchritte.find((eintrag) => eintrag.abschnitt === abschnitt.titel)?.stelle ?? alleSchritte.length;
const baustein = abschnitt.baustein; const baustein = abschnitt.baustein;
return ( return (
@@ -176,27 +159,6 @@ export function AbschnittKarte({
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })} setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
/> />
{/*
Die Bedingung gehoert wie die Abteilungseinschraenkung zur **Vorlage**
und nicht zum Baustein — deshalb steht sie auch bei einem
Baustein-Abschnitt (#039, wie #031).
Als „spaeter“ gilt ein Schritt, der nach dem **ersten** Schritt dieses
Abschnitts kommt: der Abschnitt beginnt dort, und was danach steht, wuerde
ihn rueckwaerts erscheinen lassen.
*/}
<BedingungWaehlen
bedingung={abschnitt.bedingung}
variablen={variablen}
schritte={alleSchritte.map((eintrag) => ({
abschnitt: eintrag.abschnitt,
schritt: eintrag.schritt,
spaeter: eintrag.stelle >= ersteStelleDiesesAbschnitts,
}))}
lesend={lesend}
setzen={(bedingung) => aendern({ bedingung })}
/>
{baustein ? null : anzahlSchritte === 0 ? ( {baustein ? null : anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p> <p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : ( ) : (
@@ -213,9 +175,6 @@ export function AbschnittKarte({
<SchrittKarte <SchrittKarte
schritt={schritt} schritt={schritt}
index={stelle} index={stelle}
variablen={variablen}
alleSchritte={alleSchritte}
eigeneStelle={ersteStelleDiesesAbschnitts + stelle}
anzahl={anzahlSchritte} anzahl={anzahlSchritte}
lesend={lesend} lesend={lesend}
neu={schritt.kennung === zuletztAngelegt} neu={schritt.kennung === zuletztAngelegt}
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
import { import {
Auswahlfeld, Auswahlfeld,
Flaechenfeld, Flaechenfeld,
@@ -139,9 +138,6 @@ export function SchrittKarte({
anzahl, anzahl,
lesend, lesend,
neu, neu,
variablen,
alleSchritte,
eigeneStelle,
anfasser, anfasser,
aendern, aendern,
loeschen, loeschen,
@@ -153,23 +149,6 @@ export function SchrittKarte({
lesend: boolean; lesend: boolean;
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */ /** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
neu: boolean; neu: boolean;
/**
* Die Variablen dieser Version und alle Schritte – für eine Bedingung (#039).
*
* **Fehlen sie, gibt es keine Bedingung**, und das ist der Fall im
* Baustein-Editor: ein Baustein ist wiederverwendbar, und eine Bedingung auf
* einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht
* entscheidbar. Der Abschnitt, in dem er eingesetzt wird, darf eine haben; der
* Schritt im Baustein nicht.
*/
variablen?: readonly { readonly schluessel: string; readonly beschriftung: string }[];
alleSchritte?: readonly {
readonly abschnitt: string;
readonly schritt: string;
readonly stelle: number;
}[];
/** Die laufende Nummer **dieses** Schritts – alles danach gilt als „später“. */
eigeneStelle?: number;
anfasser: React.ReactNode; anfasser: React.ReactNode;
aendern: (teil: Partial<EditorSchritt>) => void; aendern: (teil: Partial<EditorSchritt>) => void;
loeschen: () => void; loeschen: () => void;
@@ -275,26 +254,6 @@ export function SchrittKarte({
vorschlag="https://intern.example/anleitung" vorschlag="https://intern.example/anleitung"
hinweis="Nur http- oder https-Adressen." hinweis="Nur http- oder https-Adressen."
/> />
{/*
Eine Bedingung **am einzelnen Schritt** (#039): sie blendet nur ihn aus,
nicht den Abschnitt. Der eigene Schritt steht in der Liste mit; eine
Bedingung auf sich selbst waere nie entscheidbar, und genau deshalb gilt
er als „spaeter“ und wird angewarnt.
*/}
{variablen && alleSchritte ? (
<BedingungWaehlen
bedingung={schritt.bedingung}
variablen={variablen}
schritte={alleSchritte.map((eintrag) => ({
abschnitt: eintrag.abschnitt,
schritt: eintrag.schritt,
spaeter: eintrag.stelle >= (eigeneStelle ?? 0),
}))}
lesend={lesend}
setzen={(bedingung) => aendern({ bedingung })}
/>
) : null}
</KlappKarte> </KlappKarte>
); );
} }
-45
View File
@@ -26,7 +26,6 @@ import {
type EditorVariable, type EditorVariable,
} from "@/lib/editor-aufbau"; } from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter"; import { unbekanntePlatzhalter } from "@/lib/platzhalter";
import { KAESTCHEN } from "@/lib/formular";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format"; import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen"; import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
@@ -53,8 +52,6 @@ export type EditorVersion = {
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */ /** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string; readonly stand: string;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
}; };
/** /**
@@ -211,20 +208,6 @@ export function Editor({
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte; const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
/*
* Die Schritte, auf die eine Bedingung zeigen kann (#039) — **alle**, und zu
* jedem die Auskunft, ob er spaeter kommt. Einmal berechnet und an jede Karte
* gegeben: jede Karte selbst rechnen zu lassen waere dieselbe Schleife je
* Abschnitt, und die Reihenfolge ist fuer alle dieselbe.
*
* Die laufende Nummer entscheidet ueber „spaeter“, nicht die Position im
* Abschnitt: ein Schritt in Abschnitt 1 kommt vor jedem in Abschnitt 2.
*/
const reihenfolge = aufbau.abschnitte.flatMap((abschnitt) =>
abschnitt.schritte.map((schritt) => ({ abschnitt: abschnitt.titel, schritt: schritt.titel })),
);
const waehlbareSchritte = reihenfolge.map((eintrag, stelle) => ({ ...eintrag, stelle }));
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<header className="flex flex-col gap-3"> <header className="flex flex-col gap-3">
@@ -357,8 +340,6 @@ export function Editor({
index={index} index={index}
anzahl={aufbau.abschnitte.length} anzahl={aufbau.abschnitte.length}
abteilungen={abteilungen} abteilungen={abteilungen}
variablen={aufbau.variablen}
alleSchritte={waehlbareSchritte}
lesend={lesend} lesend={lesend}
zuletztAngelegt={zuletztAngelegt} zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser} anfasser={anfasser}
@@ -470,32 +451,6 @@ export function Editor({
</p> </p>
<form action={veroeffentlichen} className="flex flex-col gap-3"> <form action={veroeffentlichen} className="flex flex-col gap-3">
<input type="hidden" name="versionId" value={version.id} /> <input type="hidden" name="versionId" value={version.id} />
{/*
**Die Freigabepflicht gehoert ans Veroeffentlichen** (#040), nicht
ans Speichern: sie ist eine Aussage ueber die **Version**, und eine
veroeffentlichte Version ist unveraenderlich. Ein laufender Lauf
kann seine Regel damit nicht unter den Fuessen verlieren.
Vorbelegt mit dem Stand des Entwurfs — er ist beim Bearbeiten von
der Vorversion mitgewandert.
*/}
<label className="flex min-h-11 items-start gap-2 text-sm md:min-h-10">
<input
type="checkbox"
name="freigabePflicht"
value="1"
defaultChecked={version.freigabePflicht}
className={KAESTCHEN}
/>
<span>
Läufe dieser Version brauchen eine <strong>Freigabe durch einen Zweiten</strong>
<span className="block text-xs text-nebentext">
Wer am Lauf gearbeitet hat, kann sie nicht erteilen — auch nicht als Administrator.
Benachrichtigt werden die Freigabeberechtigten, sobald jemand den Lauf abzuschließen
versucht.
</span>
</span>
</label>
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Änderungsnotiz (optional)</span> <span className="text-nebentext">Änderungsnotiz (optional)</span>
<textarea <textarea
-426
View File
@@ -1,426 +0,0 @@
import { z } from "@/lib/validierung";
/**
* Bedingte Abschnitte und Schritte (#039).
*
* Framework-frei und ohne Prisma, wie `src/lib/geltende-abschnitte.ts` — und aus
* demselben Grund: **drei Schichten** brauchen die Auswertung. Der Dienst beim
* Schreiben eines Schrittergebnisses und beim Abschluss, die Lesemodelle der
* Abarbeitung und der Druckansicht, und der Editor für seine Vorschau.
*
* ## Was eine Bedingung ist — und was sie nicht ist
*
* **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine
* Verschachtelung. Das wäre eine kleine Abfragesprache, die im Editor niemand
* pflegen kann — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
* schlimmer als einer zu viel. Wer zwei Bedingungen braucht, nimmt zwei
* Abschnitte oder zwei Vorlagen.
*
* Zwei Arten, und die Wahl entscheidet, **wann** die Antwort entsteht:
*
* - `variable` — hängt an einem Wert, der beim Start erfasst wird. Der echte
* Fall: die Onboarding-Vorlage trägt beide Konto-Abschnitte, und `konto_typ`
* sagt längst, welcher gilt.
* - `schritt` — hängt am Zustand eines anderen Schritts derselben Version. Die
* Antwort entsteht erst **während** der Arbeit; deshalb wird laufend
* ausgewertet und nicht beim Start festgeschrieben wie bei #031.
*/
/* ------------------------------------------------------------------ */
/* Form */
/* ------------------------------------------------------------------ */
/**
* Die Vergleiche auf einer Variable.
*
* `ist` und `istNicht` vergleichen als **Text**, ohne Rücksicht auf Groß- und
* Kleinschreibung und mit getrimmten Rändern: die Werte kommen aus einem
* Auswahlfeld oder aus einem Formular, und „AD“ ist derselbe Wert wie „ad“.
* Ein Zahlenvergleich (`groesser`, `kleiner`) fehlt absichtlich — er hatte
* bisher keinen Fall, und ungenutzter Code ist ungeprüfter Code.
*/
export const VARIABLEN_OPERATOREN = ["ist", "istNicht", "gesetzt", "leer"] as const;
export type VariablenOperator = (typeof VARIABLEN_OPERATOREN)[number];
/** Die Zustände, auf die eine Schritt-Bedingung zeigen kann. */
export const BEDINGUNGS_ZUSTAENDE = ["OFFEN", "ERLEDIGT", "NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const;
export type BedingungsZustand = (typeof BEDINGUNGS_ZUSTAENDE)[number];
const VariablenBedingungSchema = z.object({
art: z.literal("variable"),
schluessel: z.string().trim().min(1),
operator: z.enum(VARIABLEN_OPERATOREN),
/*
* Nur bei `ist` und `istNicht` von Bedeutung. Bewusst **nicht** über
* `discriminatedUnion` je Operator aufgeteilt: das ergäbe vier Schemas für
* einen Unterschied, den ein `refine` in einer Zeile sagt.
*/
wert: z.string().optional(),
});
const SchrittBedingungSchema = z.object({
art: z.literal("schritt"),
schrittId: z.string().trim().min(1),
/*
* Eine **Liste** von Zuständen, nicht einer. Das ist ein `in`-Vergleich und
* kein verstecktes `oder`: „erledigt oder nicht zutreffend“ ist eine Frage,
* nicht zwei. Leer wäre eine Bedingung, die nie zutrifft — deshalb mindestens
* einer.
*/
zustaende: z.array(z.enum(BEDINGUNGS_ZUSTAENDE)).min(1),
});
export const BedingungSchema = z
.discriminatedUnion("art", [VariablenBedingungSchema, SchrittBedingungSchema])
.refine(
(bedingung) =>
bedingung.art !== "variable" ||
(bedingung.operator !== "ist" && bedingung.operator !== "istNicht") ||
(bedingung.wert ?? "").trim().length > 0,
{ message: "Für „ist“ und „ist nicht“ gehört ein Wert dazu." },
);
export type Bedingung = z.infer<typeof BedingungSchema>;
/**
* Liest eine gespeicherte Bedingung aus dem Json-Feld.
*
* **Nachsichtig beim Lesen**: was nicht zum Schema passt, gilt als *keine*
* Bedingung — der Abschnitt wird dann angezeigt. Hier zu werfen hieße, dass eine
* einzige unlesbare Zeile eine ganze Vorlage unbenutzbar macht; und die
* Richtung, in die der Fehler dann fällt, ist die harmlose: ein Abschnitt zu
* viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
*
* Dass gar nichts Unlesbares hineinkommt, hält das Schema beim **Schreiben**.
*/
export function bedingungLesen(roh: unknown): Bedingung | null {
if (roh === null || roh === undefined) return null;
const gelesen = BedingungSchema.safeParse(roh);
return gelesen.success ? gelesen.data : null;
}
/* ------------------------------------------------------------------ */
/* Auswertung */
/* ------------------------------------------------------------------ */
/** Was zur Auswertung bekannt sein muss. */
export type Umgebung = {
/** Die Werte der Variablen dieses Laufs (`Lauf.variablen`). */
readonly variablen: Readonly<Record<string, unknown>>;
/** Der Zustand je Schritt-Kennung, soweit es ein Schrittergebnis gibt. */
readonly zustaende: ReadonlyMap<string, BedingungsZustand>;
};
function alsText(wert: unknown): string {
if (wert === null || wert === undefined) return "";
if (typeof wert === "string") return wert.trim();
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return "";
}
const gleich = (a: string, b: string): boolean =>
a.toLocaleLowerCase("de") === b.trim().toLocaleLowerCase("de");
/**
* Trifft diese Bedingung zu?
*
* `undefined` heißt **nicht entscheidbar**, und das ist nicht dasselbe wie
* „nein“. Der Aufrufer zeigt in diesem Fall an (siehe `geltendeTeile`).
*
* **Der Unterschied zwischen den beiden Arten ist wichtig:**
*
* - Eine Bedingung auf einer Variable, die **nicht gesetzt** ist, ist bei `ist`
* und `istNicht` nicht entscheidbar. Ein Lauf, bei dem niemand `konto_typ`
* angegeben hat, soll **beide** Konto-Abschnitte zeigen: ausblenden hieße,
* eine Antwort zu erfinden, die niemand gegeben hat.
* - Eine Bedingung auf einem Schritt ist **immer** entscheidbar, sobald es den
* Schritt gibt: jedes Schrittergebnis hat einen Zustand, und `OFFEN` ist eine
* Antwort und keine Lücke. Ein Abschnitt, der erst nach einem erledigten
* Schritt erscheint, ist der Sinn der Sache.
* Nur ein Schritt, den es in diesem Lauf **gar nicht** gibt (ausgefiltert
* durch #031, oder ein Verweis aus einer anderen Version), ist nicht
* entscheidbar.
*/
export function bedingungErfuellt(bedingung: Bedingung, umgebung: Umgebung): boolean | undefined {
if (bedingung.art === "variable") {
const wert = alsText(umgebung.variablen[bedingung.schluessel]);
switch (bedingung.operator) {
case "gesetzt":
return wert.length > 0;
case "leer":
return wert.length === 0;
case "ist":
return wert.length === 0 ? undefined : gleich(wert, bedingung.wert ?? "");
case "istNicht":
return wert.length === 0 ? undefined : !gleich(wert, bedingung.wert ?? "");
default: {
// Ein neuer Operator faellt beim Uebersetzen auf und nicht im Betrieb.
const unbekannt: never = bedingung.operator;
return void unbekannt as undefined;
}
}
}
const zustand = umgebung.zustaende.get(bedingung.schrittId);
if (zustand === undefined) return undefined;
return bedingung.zustaende.includes(zustand);
}
/** Ein Abschnitt oder Schritt, wie die Auswertung ihn braucht. */
export type MitBedingung = {
readonly id: string;
readonly bedingung: unknown;
};
/**
* Welche Teile gelten.
*
* **Nicht entscheidbar heißt: gilt.** Das ist die Richtung, in die der Zweifel
* fallen soll — ein Abschnitt zu viel ist sichtbar, und jemand setzt ihn auf
* `NICHT_ZUTREFFEND`. Ein fehlender Abschnitt fällt niemandem auf, und genau
* das ist bei einer Checkliste der teure Fall.
*/
export function geltendeTeile(teile: readonly MitBedingung[], umgebung: Umgebung): readonly string[] {
return teile
.filter((teil) => {
const bedingung = bedingungLesen(teil.bedingung);
if (!bedingung) return true;
return bedingungErfuellt(bedingung, umgebung) !== false;
})
.map((teil) => teil.id);
}
/**
* Die Umgebung aus einem Lauf bauen.
*
* Eine eigene Funktion, weil **jede** Schicht sie gleich bauen muss: aus den
* Variablen des Laufs und den Zuständen seiner Schrittergebnisse. Zweimal
* geschrieben lief es auseinander — dieselbe Lehre wie bei den Grenzen des
* Abgleich-Intervalls (#016).
*/
export function umgebungBauen(
variablen: unknown,
ergebnisse: readonly {
readonly vorlagenSchrittId: string;
readonly zustand: string;
}[],
): Umgebung {
const werte =
typeof variablen === "object" && variablen !== null && !Array.isArray(variablen)
? (variablen as Record<string, unknown>)
: {};
const zustaende = new Map<string, BedingungsZustand>();
for (const ergebnis of ergebnisse) {
if ((BEDINGUNGS_ZUSTAENDE as readonly string[]).includes(ergebnis.zustand)) {
zustaende.set(ergebnis.vorlagenSchrittId, ergebnis.zustand as BedingungsZustand);
}
}
return { variablen: werte, zustaende };
}
/**
* Die Struktur eines Laufs, auf das Geltende eingedampft.
*
* **Die Reihenfolge ist nicht beliebig, und sie ist der Grund für diese
* Funktion.** Zwei Filter wirken übereinander:
*
* 1. `nurGeltende` aus #031 — die Abteilungsabschnitte, **beim Start
* festgeschrieben** in `Lauf.geltendeAbschnitte`.
* 2. die Bedingungen von hier — **laufend** ausgewertet, weil eine Bedingung auf
* einem Schrittergebnis erst während der Arbeit eine Antwort hat.
*
* Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
* zweiten anwendet, umgeht die Abteilungsregel. Beide zusammen gehören an **eine**
* Stelle, sonst vergisst sie eine der drei Schichten — genau wie bei #031, und
* `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine es tut.
*
* Ein Abschnitt, der gilt, kann Schritte enthalten, die **nicht** gelten: eine
* Bedingung steht an beiden.
*/
export function geltendeStruktur<
S extends MitBedingung,
A extends MitBedingung & { readonly schritte: readonly S[] },
>(abschnitte: readonly A[], umgebung: Umgebung): readonly A[] {
const erlaubteAbschnitte = new Set(geltendeTeile(abschnitte, umgebung));
return abschnitte
.filter((abschnitt) => erlaubteAbschnitte.has(abschnitt.id))
.map((abschnitt) => {
const erlaubteSchritte = new Set(geltendeTeile(abschnitt.schritte, umgebung));
return {
...abschnitt,
schritte: abschnitt.schritte.filter((schritt) => erlaubteSchritte.has(schritt.id)),
};
});
}
/* ------------------------------------------------------------------ */
/* Titel-Pfade: Austausch und Entwurf */
/* ------------------------------------------------------------------ */
/**
* Der Schlüssel eines Schritts in einer Struktur, aus Abschnitts- und
* Schritt-Titel.
*
* **Warum Titel und nicht Kennungen.** Zwei Wege schreiben eine Struktur neu und
* geben dabei neue Kennungen: der Import einer Austauschdatei und
* `entwurfSpeichern`, das den Entwurf löscht und wieder anlegt. Eine Bedingung
* auf einer Kennung überlebt keinen von beiden. Ein Titel-Pfad überlebt beide —
* und eine von Hand geschriebene Datei kann ihn setzen.
*
* **Die Grenze, und sie ist benannt:** zwei Schritte mit demselben Titel im
* selben Abschnitt sind so nicht unterscheidbar; die Bedingung zeigt dann auf
* den ersten. Das ist ein Grenzfall, den eine Checkliste nicht braucht — zwei
* gleich benannte Schritte nebeneinander kann auch ein Mensch nicht
* unterscheiden.
*
* `\u0000` als Trenner, weil es in keinem Titel vorkommt: ein `·` oder `/` schon.
*/
export function schrittPfad(abschnittsTitel: string, schrittTitel: string): string {
return `${abschnittsTitel}\u0000${schrittTitel}`;
}
/**
* Eine Bedingung in der Pfad-Form — über Titel statt Kennungen.
*
* **Als Zod-Schema und an genau einer Stelle.** Austauschformat und
* Editor-Struktur brauchen dieselbe Form; zwei Schemas dafür liefen auseinander,
* und TypeScript hätte es erst gemeldet, als die beiden Typen aufeinandertrafen.
* `src/lib/vorlagen-format.ts` importiert es von hier.
*/
const PFAD_TEXT_MAX = 500;
export const PfadBedingungSchema = z.discriminatedUnion("art", [
z.object({
art: z.literal("variable"),
schluessel: z.string().trim().min(1).max(PFAD_TEXT_MAX),
operator: z.enum(VARIABLEN_OPERATOREN),
wert: z.string().trim().max(PFAD_TEXT_MAX).optional(),
}),
z.object({
art: z.literal("schritt"),
abschnitt: z.string().trim().min(1).max(PFAD_TEXT_MAX),
schritt: z.string().trim().min(1).max(PFAD_TEXT_MAX),
zustaende: z.array(z.enum(BEDINGUNGS_ZUSTAENDE)).min(1),
}),
]);
export type PfadBedingung = z.infer<typeof PfadBedingungSchema>;
/**
* Aus einer Pfad-Bedingung die gespeicherte Form.
*
* `null`, wenn der Pfad in dieser Struktur nicht vorkommt. Der Aufrufer lässt
* die Bedingung dann **weg**, statt eine mit einem Verweis ins Leere zu
* schreiben: die wäre nicht entscheidbar, der Abschnitt erschiene also immer —
* und niemand könnte sagen, warum seine Bedingung nicht wirkt.
*/
export function bedingungAufloesen(
bedingung: PfadBedingung,
kennungen: ReadonlyMap<string, string>,
): Bedingung | null {
if (bedingung.art === "variable") {
return {
art: "variable",
schluessel: bedingung.schluessel,
operator: bedingung.operator,
...(bedingung.wert === undefined ? {} : { wert: bedingung.wert }),
};
}
const schrittId = kennungen.get(schrittPfad(bedingung.abschnitt, bedingung.schritt));
if (!schrittId) return null;
return { art: "schritt", schrittId, zustaende: [...bedingung.zustaende] };
}
/**
* Die Gegenrichtung: aus der gespeicherten Form eine Pfad-Bedingung.
*
* `null`, wenn der Schritt nicht in der Karte steht — dann gibt es keinen Pfad,
* den man aufschreiben könnte, und die Bedingung wird weggelassen.
*/
export function bedingungZuPfad(
roh: unknown,
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): PfadBedingung | null {
const bedingung = bedingungLesen(roh);
if (!bedingung) return null;
if (bedingung.art === "variable") {
return {
art: "variable",
schluessel: bedingung.schluessel,
operator: bedingung.operator,
...(bedingung.wert === undefined ? {} : { wert: bedingung.wert }),
};
}
const pfad = pfade.get(bedingung.schrittId);
if (!pfad) return null;
return {
art: "schritt",
abschnitt: pfad.abschnitt,
schritt: pfad.schritt,
zustaende: [...bedingung.zustaende],
};
}
/* ------------------------------------------------------------------ */
/* Für den Editor */
/* ------------------------------------------------------------------ */
/**
* Zeigt diese Bedingung auf einen Schritt, der **später** kommt?
*
* Eine Warnung und kein Fehler: so etwas ist nicht verboten, nur fast immer ein
* Versehen. Der bedingte Abschnitt wäre beim Abarbeiten zunächst unsichtbar und
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also
* rückwärts, und niemand rechnet damit.
*
* `undefined`, wenn die Bedingung nicht auf einen Schritt zeigt oder der Schritt
* nicht gefunden wird; dann gibt es nichts zu warnen.
*/
export function zeigtNachVorne(
bedingung: Bedingung,
/** Alle Schritte der Version, in Abarbeitungsreihenfolge. */
reihenfolge: readonly string[],
/** Die Kennung des bedingten Teils — ein Schritt, oder der erste eines Abschnitts. */
eigeneId: string,
): boolean | undefined {
if (bedingung.art !== "schritt") return undefined;
const ziel = reihenfolge.indexOf(bedingung.schrittId);
const selbst = reihenfolge.indexOf(eigeneId);
if (ziel < 0 || selbst < 0) return undefined;
return ziel > selbst;
}
/** Eine Bedingung als Satz, für Editor und Vorschau. */
export function bedingungText(
bedingung: Bedingung,
/** Beschriftung je Variablenschlüssel bzw. Titel je Schritt-Kennung. */
beschriftung: (kennung: string) => string,
): string {
if (bedingung.art === "variable") {
const name = beschriftung(bedingung.schluessel);
switch (bedingung.operator) {
case "gesetzt":
return `gilt, wenn „${name}“ ausgefüllt ist`;
case "leer":
return `gilt, wenn „${name}“ leer ist`;
case "ist":
return `gilt, wenn „${name}“ gleich „${bedingung.wert ?? ""}“ ist`;
case "istNicht":
return `gilt, wenn „${name}“ nicht „${bedingung.wert ?? ""}“ ist`;
default: {
const unbekannt: never = bedingung.operator;
return String(unbekannt);
}
}
}
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
const zustaende = bedingung.zustaende.map((zustand) => ZUSTAND_TEXT[zustand]).join(" oder ");
return `gilt, wenn „${beschriftung(bedingung.schrittId)}“ ${zustaende} ist`;
}
-14
View File
@@ -1,4 +1,3 @@
import type { PfadBedingung } from "@/lib/bedingungen";
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter"; import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import { import {
SCHRITT_TYPEN, SCHRITT_TYPEN,
@@ -36,8 +35,6 @@ export type EditorSchritt = {
readonly pflicht: boolean; readonly pflicht: boolean;
readonly konfig: SchrittKonfig; readonly konfig: SchrittKonfig;
readonly dokuUrl: string; readonly dokuUrl: string;
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
readonly bedingung?: PfadBedingung;
}; };
export type EditorAbschnitt = { export type EditorAbschnitt = {
@@ -46,13 +43,6 @@ export type EditorAbschnitt = {
readonly schritte: readonly EditorSchritt[]; readonly schritte: readonly EditorSchritt[];
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */ /** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
readonly nurFuerAbteilungen: readonly string[]; readonly nurFuerAbteilungen: readonly string[];
/**
* Wann dieser Abschnitt gilt (#039). Fehlt: immer.
*
* Verweist ueber **Titel**, nicht ueber Kennungen: `entwurfSpeichern` loescht
* den Entwurf und schreibt ihn neu, eine Kennung ueberlebte das nicht.
*/
readonly bedingung?: PfadBedingung;
/** /**
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030). * Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
* *
@@ -318,7 +308,6 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer. // Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel), titel: titelOhneNummer(abschnitt.titel),
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie // Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030). // getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
...(abschnitt.bausteinVersionId === undefined ...(abschnitt.bausteinVersionId === undefined
@@ -332,7 +321,6 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: schritt.konfig, konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "", dokuUrl: schritt.dokuUrl ?? "",
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
})), })),
})), })),
}; };
@@ -364,7 +352,6 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
nurFuerAbteilungen: [ nurFuerAbteilungen: [
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)), ...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
], ],
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
/* /*
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim * Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur * Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
@@ -380,7 +367,6 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: schritt.konfig, konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(), dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
})), })),
})), })),
}; };
+1 -20
View File
@@ -113,28 +113,9 @@ export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): M
]), ]),
}; };
case Meldeanlass.LAUF_FREIGABE_OFFEN:
return {
betreff: `Laufzettel wartet auf Freigabe: „${kopf.titel}“`,
rumpf: zusammensetzen([
/*
* **Der Satz nennt die Regel**, denn der Empfaenger ist oft nicht
* derjenige, der gearbeitet hat — und soll nicht raten muessen, warum
* er gefragt wird.
*/
kopf.handelnder
? `${kopf.handelnder} hat diesen Laufzettel abgearbeitet. Er verlangt eine Freigabe ` +
"durch einen Zweiten, der nicht daran gearbeitet hat."
: "Dieser Laufzettel verlangt eine Freigabe durch einen Zweiten, der nicht daran " +
"gearbeitet hat.",
daten,
link,
]),
};
default: { default: {
/* /*
* Kaeme ein weiterer Anlass dazu, der zu einem Lauf gehoert, ist diese * Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile * Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
* totem Code im Ergebnis. * totem Code im Ergebnis.
*/ */
+4 -28
View File
@@ -30,26 +30,15 @@ export type NavEintrag = {
readonly ausIssue?: number; 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[] = [ export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", recht: "lesen", verfuegbar: true }, { pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", recht: "lesen", verfuegbar: true }, { pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", recht: "lesen", verfuegbar: true }, { pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{ {
pfad: "/mitarbeiter", pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte", beschriftung: "Mitarbeiter & Geräte",
beschriftungDienstleistung: "Personen & Geräte", beschriftungDienstleistung: "Personen & Geräte",
symbol: "Users", symbol: "Users",
recht: "lesen",
verfuegbar: true, verfuegbar: true,
}, },
{ {
@@ -57,23 +46,10 @@ export const NAVIGATION: readonly NavEintrag[] = [
beschriftung: "Kunden", beschriftung: "Kunden",
nurModus: "DIENSTLEISTUNG", nurModus: "DIENSTLEISTUNG",
symbol: "Building2", symbol: "Building2",
recht: "lesen",
verfuegbar: true, verfuegbar: true,
}, },
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", recht: "lesen", verfuegbar: true }, { pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: true },
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", 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,
},
]; ];
/** /**
-8
View File
@@ -13,7 +13,6 @@ export const ROLLEN_TEXT: Record<Rolle, string> = {
[Rolle.REDAKTEUR]: "Redakteur", [Rolle.REDAKTEUR]: "Redakteur",
[Rolle.TECHNIKER]: "Techniker", [Rolle.TECHNIKER]: "Techniker",
[Rolle.LESER]: "Leser", [Rolle.LESER]: "Leser",
[Rolle.MITARBEITER]: "Mitarbeiter (Selbstauskunft)",
}; };
/** /**
@@ -25,11 +24,4 @@ export const ROLLEN_RANG: Record<Rolle, number> = {
[Rolle.REDAKTEUR]: 30, [Rolle.REDAKTEUR]: 30,
[Rolle.TECHNIKER]: 20, [Rolle.TECHNIKER]: 20,
[Rolle.LESER]: 10, [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,
}; };
-324
View File
@@ -1,324 +0,0 @@
/**
* SCIM 2.0: Formen und Zuordnung (#029).
*
* Framework-frei, weil hier zwei Dinge zusammenkommen, die beide einzeln
* prüfbar sein müssen: **was ein Anbieter schickt** und **was RFC 7644 als
* Antwort verlangt**. Beides ist reine Textarbeit; ein Test dafür braucht keinen
* Server und keine Datenbank.
*
* **Umgesetzt ist nicht der ganze Standard, sondern was die Anbieter schicken.**
* authentik, Entra ID und Okta benutzen einen kleinen, gut dokumentierten
* Ausschnitt. Der Rest — komplexe Filter, Sortierung, `bulk` — wäre Code ohne
* Aufrufer, und ungenutzter Code ist ungeprüfter Code.
*/
export const SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
export const SCHEMA_ENTERPRISE = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
export const SCHEMA_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
export const SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
export const SCHEMA_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
/** Der Medientyp, den SCIM verlangt. Nicht `application/json`. */
export const SCIM_TYP = "application/scim+json";
/**
* Eine Person, wie Laufzettel sie nach außen zeigt.
*
* Bewusst **nicht** die ganze `Mitarbeiter`-Zeile: ein Identitätsanbieter hat
* die Daten selbst geschickt, er braucht sie nicht zurück, und was hier steht,
* steht auch in jedem Protokoll des Anbieters.
*/
export type ScimPerson = {
readonly id: string;
readonly externeId: string | null;
readonly upn: string | null;
readonly vorname: string;
readonly nachname: string;
readonly anzeigename: string;
readonly mail: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean;
/** Die Laufzettel-Kennung des Vorgesetzten, wenn er bekannt ist. */
readonly vorgesetzterId: string | null;
readonly erstelltAm: Date;
readonly geaendertAm: Date;
};
/** Was aus einer eingehenden Darstellung gelesen wird. Mehr nimmt Laufzettel nicht. */
export type ScimEingabe = {
readonly externeId: string | null;
readonly upn: string | null;
readonly vorname: string | null;
readonly nachname: string | null;
readonly anzeigename: string | null;
readonly mail: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean | null;
/**
* Der Vorgesetzte, wie der Anbieter ihn nennt: die `id`, die Laufzettel ihm
* beim Anlegen zurueckgegeben hat — **nicht** die Kennung im Verzeichnis des
* Anbieters. Sie aufzuloesen ist Sache des Dienstes, denn sie kann auf
* jemanden zeigen, der noch nicht zugewiesen wurde.
*/
readonly vorgesetzterScimId: string | null;
};
function text(wert: unknown): string | null {
return typeof wert === "string" && wert.trim() ? wert.trim() : null;
}
/**
* Die Kennung des Vorgesetzten aus `manager`.
*
* Drei Formen kommen vor, und alle drei stehen so in Mitschnitten der Anbieter:
* `{ value: "…" }` (RFC 7643), `{ $ref: "…/Users/…" }` und die nackte
* Zeichenkette. Die letzte ist nicht standardkonform, aber authentik schickt sie
* bei manchen Zuordnungen — und ein Vorgesetzter, der daran scheitert, fehlt
* stillschweigend.
*/
export function vorgesetzterAus(wert: unknown): string | null {
if (typeof wert === "string") return text(wert);
if (typeof wert !== "object" || wert === null) return null;
const eintrag = wert as Record<string, unknown>;
const direkt = text(eintrag["value"]);
if (direkt) return direkt;
// Aus `$ref` das letzte Pfadstueck: `https://…/Users/abc` → `abc`.
const verweis = text(eintrag["$ref"]);
return verweis ? (text(verweis.split("/").pop()) ?? null) : null;
}
/**
* Die primäre Mailadresse aus `emails`.
*
* `primary: true` gewinnt; ohne Kennzeichnung die erste. Das ist die Reihenfolge
* aus RFC 7643 — und die Anbieter halten sich unterschiedlich daran: authentik
* setzt `primary`, Entra ID schickt oft nur einen Eintrag ohne das Feld.
*/
export function mailAus(wert: unknown): string | null {
if (!Array.isArray(wert)) return null;
const eintraege = wert.filter((e): e is Record<string, unknown> => typeof e === "object" && e !== null);
const primaer = eintraege.find((e) => e["primary"] === true);
return text((primaer ?? eintraege[0])?.["value"]);
}
/**
* Liest eine eingehende Benutzer-Darstellung.
*
* Fehlende Felder werden `null` und **nicht** zu leeren Zeichenketten: der
* Unterschied entscheidet beim Aktualisieren darüber, ob ein Wert gelöscht oder
* nicht mitgeschickt wurde.
*/
export function benutzerLesen(rumpf: unknown): ScimEingabe {
const daten = (typeof rumpf === "object" && rumpf !== null ? rumpf : {}) as Record<string, unknown>;
const name = (typeof daten["name"] === "object" && daten["name"] !== null ? daten["name"] : {}) as Record<
string,
unknown
>;
const erweiterung = (
typeof daten[SCHEMA_ENTERPRISE] === "object" && daten[SCHEMA_ENTERPRISE] !== null
? daten[SCHEMA_ENTERPRISE]
: {}
) as Record<string, unknown>;
return {
externeId: text(daten["externalId"]),
upn: text(daten["userName"]),
vorname: text(name["givenName"]),
nachname: text(name["familyName"]),
anzeigename: text(daten["displayName"]),
mail: mailAus(daten["emails"]),
abteilung: text(erweiterung["department"]),
titel: text(daten["title"]) ?? text(erweiterung["jobTitle"]),
/*
* `active` fehlt bei `POST` manchmal. `null` heisst „nicht mitgeschickt“ —
* der Dienst legt dann aktiv an, aendert aber beim Aktualisieren nichts.
*/
aktiv: typeof daten["active"] === "boolean" ? daten["active"] : null,
vorgesetzterScimId: vorgesetzterAus(erweiterung["manager"]),
};
}
/**
* Eine Person als SCIM-Darstellung.
*
* `meta.location` ist Pflicht nach RFC 7644 und nicht Zierde: manche Anbieter
* folgen ihr, statt die Adresse selbst zu bauen.
*/
export function personDarstellen(person: ScimPerson, basis: string): Record<string, unknown> {
return {
schemas: [SCHEMA_USER, SCHEMA_ENTERPRISE],
id: person.id,
...(person.externeId ? { externalId: person.externeId } : {}),
userName: person.upn ?? person.id,
name: { givenName: person.vorname, familyName: person.nachname },
displayName: person.anzeigename,
...(person.mail ? { emails: [{ value: person.mail, primary: true, type: "work" }] } : {}),
...(person.titel ? { title: person.titel } : {}),
active: person.aktiv,
[SCHEMA_ENTERPRISE]: {
...(person.abteilung ? { department: person.abteilung } : {}),
...(person.vorgesetzterId
? { manager: { value: person.vorgesetzterId, $ref: `${basis}/Users/${person.vorgesetzterId}` } }
: {}),
},
meta: {
resourceType: "User",
created: person.erstelltAm.toISOString(),
lastModified: person.geaendertAm.toISOString(),
location: `${basis}/Users/${person.id}`,
},
};
}
/** Eine Liste nach RFC 7644. `startIndex` ist **1**-basiert, nicht 0. */
export function listeDarstellen(
personen: readonly ScimPerson[],
basis: string,
gesamt = personen.length,
startIndex = 1,
): Record<string, unknown> {
return {
schemas: [SCHEMA_LIST],
totalResults: gesamt,
itemsPerPage: personen.length,
startIndex,
Resources: personen.map((person) => personDarstellen(person, basis)),
};
}
/** Ein Fehler nach RFC 7644. `status` ist dort eine **Zeichenkette**. */
export function fehlerDarstellen(status: number, detail: string, scimType?: string): Record<string, unknown> {
return {
schemas: [SCHEMA_ERROR],
status: String(status),
...(scimType ? { scimType } : {}),
detail,
};
}
/**
* Liest den Filter `userName eq "wert"`.
*
* **Nur dieser eine Filter**, und das ist Absicht: er ist der einzige, den die
* Anbieter benutzen — sie fragen damit vor jedem Anlegen, ob die Person schon da
* ist. Einen allgemeinen Filter-Parser zu bauen hieße, eine kleine Abfragesprache
* zu stützen, die niemand schickt.
*
* `undefined`, wenn kein Filter dasteht; `null`, wenn einer dasteht, den wir
* nicht verstehen — der Aufrufer antwortet dann mit `400` statt stillschweigend
* alles zu liefern. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter
* schlösse daraus, die Person gäbe es schon.
*/
export function benutzernameAusFilter(filter: string | null): string | undefined | null {
if (!filter?.trim()) return undefined;
const treffer = /^\s*userName\s+eq\s+"([^"]*)"\s*$/iu.exec(filter);
return treffer?.[1] ?? null;
}
export type PatchSchritt = {
readonly op: "add" | "replace" | "remove";
readonly path: string | undefined;
readonly value: unknown;
};
/**
* Liest eine `PatchOp`-Anfrage.
*
* `op` ist nach RFC unabhängig von Groß- und Kleinschreibung, und die Anbieter
* nutzen das aus: authentik schickt `replace`, Entra ID `Replace`.
*/
export function patchLesen(rumpf: unknown): readonly PatchSchritt[] | null {
const daten = (typeof rumpf === "object" && rumpf !== null ? rumpf : {}) as Record<string, unknown>;
const rohe = daten["Operations"] ?? daten["operations"];
if (!Array.isArray(rohe)) return null;
const schritte: PatchSchritt[] = [];
for (const roh of rohe) {
if (typeof roh !== "object" || roh === null) return null;
const eintrag = roh as Record<string, unknown>;
const op = text(eintrag["op"])?.toLowerCase();
if (op !== "add" && op !== "replace" && op !== "remove") return null;
schritte.push({ op, path: text(eintrag["path"]) ?? undefined, value: eintrag["value"] });
}
return schritte;
}
/**
* Wendet die Patch-Schritte auf eine Eingabe an.
*
* **Die Pfade, die die Anbieter wirklich schicken** — nicht der ganze Standard.
* Der wichtigste ist `active`: so deaktiviert Entra ID ein Konto, und so
* aktiviert es es wieder.
*
* Ein Schritt ohne `path` trägt ein ganzes Objekt in `value` (so macht es
* authentik beim `replace`); dann wird es wie ein `PUT`-Rumpf gelesen.
*
* Ein unbekannter Pfad wird **übergangen** und nicht als Fehler gemeldet. Das
* ist die Stelle, an der Nachsicht richtig ist: Anbieter schicken Felder, die
* Laufzettel nicht führt (`preferredLanguage`, `addresses`), und ein `400`
* darauf brächte die ganze Zuweisung zum Stehen — wegen eines Feldes, das
* niemand braucht.
*/
export function patchAnwenden(
schritte: readonly PatchSchritt[],
vorher: ScimEingabe,
): { readonly eingabe: ScimEingabe; readonly uebergangen: readonly string[] } {
let eingabe = { ...vorher };
const uebergangen: string[] = [];
for (const schritt of schritte) {
if (!schritt.path) {
// Ganzes Objekt: wie ein PUT-Rumpf lesen, aber nur die gesetzten Felder.
const gelesen = benutzerLesen(schritt.value);
eingabe = {
externeId: gelesen.externeId ?? eingabe.externeId,
upn: gelesen.upn ?? eingabe.upn,
vorname: gelesen.vorname ?? eingabe.vorname,
nachname: gelesen.nachname ?? eingabe.nachname,
anzeigename: gelesen.anzeigename ?? eingabe.anzeigename,
mail: gelesen.mail ?? eingabe.mail,
abteilung: gelesen.abteilung ?? eingabe.abteilung,
titel: gelesen.titel ?? eingabe.titel,
aktiv: gelesen.aktiv ?? eingabe.aktiv,
vorgesetzterScimId: gelesen.vorgesetzterScimId ?? eingabe.vorgesetzterScimId,
};
continue;
}
const pfad = schritt.path.toLowerCase();
const entfernen = schritt.op === "remove";
const wert = entfernen ? null : schritt.value;
if (pfad === "active") {
eingabe = {
...eingabe,
aktiv: entfernen ? false : wert === true || wert === "True" || wert === "true",
};
} else if (pfad === "username") {
eingabe = { ...eingabe, upn: text(wert) };
} else if (pfad === "displayname") {
eingabe = { ...eingabe, anzeigename: text(wert) };
} else if (pfad === "name.givenname") {
eingabe = { ...eingabe, vorname: text(wert) };
} else if (pfad === "name.familyname") {
eingabe = { ...eingabe, nachname: text(wert) };
} else if (pfad === "title") {
eingabe = { ...eingabe, titel: text(wert) };
} else if (pfad === "externalid") {
eingabe = { ...eingabe, externeId: text(wert) };
} else if (pfad.startsWith("emails")) {
// `emails[type eq "work"].value` und `emails` – beide kommen vor.
eingabe = { ...eingabe, mail: text(wert) ?? mailAus(wert) };
} else if (pfad.endsWith("manager") || pfad.endsWith("manager.value")) {
eingabe = { ...eingabe, vorgesetzterScimId: entfernen ? null : vorgesetzterAus(wert) };
} else if (pfad.endsWith("department")) {
eingabe = { ...eingabe, abteilung: text(wert) };
} else {
uebergangen.push(schritt.path);
}
}
return { eingabe, uebergangen };
}
+3 -95
View File
@@ -60,40 +60,6 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
externeId: "objectGUID", externeId: "objectGUID",
}; };
/**
* Dieselben Felder mit den Namen, die **Microsoft Graph** benutzt (#028).
*
* Vier Unterschiede zum AD, und jeder hat einen Grund:
*
* - `surname` statt `sn` — Graph schreibt die Namen aus.
* - `jobTitle` statt `title` — `title` gibt es bei Graph auch, es ist aber die
* Anrede („Dr.“), nicht die Funktion.
* - `onPremisesSamAccountName` statt `sAMAccountName` — der Wert kommt aus dem
* lokalen AD und ist **leer**, wenn der Mandant kein AD synchronisiert. Eine
* reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn; `{{sam}}`
* bleibt dort leer, statt dass etwas hineingedeutet wird.
* - `id` statt `objectGUID` — dieselbe GUID, nur schon als Text. Das Umsortieren
* der Bytes (`src/lib/ldap-guid.ts`) braucht es hier nicht.
*/
export const GRAPH_ZUORDNUNG: AttributZuordnung = {
vorname: "givenName",
nachname: "surname",
anzeigename: "displayName",
mail: "mail",
upn: "userPrincipalName",
sam: "onPremisesSamAccountName",
abteilung: "department",
titel: "jobTitle",
vorgesetzter: "manager",
externeId: "id",
};
/** Die Vorgaben je Quellenart. `SCIM` hat noch keine (#029). */
export const ZUORDNUNGS_VORGABE: Record<"LDAP" | "GRAPH", AttributZuordnung> = {
LDAP: AD_ZUORDNUNG,
GRAPH: GRAPH_ZUORDNUNG,
};
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */ /** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[]; export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
@@ -118,20 +84,6 @@ export const ZUORDNUNGS_BESCHRIFTUNG: Record<keyof AttributZuordnung, string> =
*/ */
export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u; export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
/**
* Wie eine Quellenart heisst, wo ein Mensch sie liest.
*
* Liegt in `src/lib`, weil beide Seiten sie brauchen: das Formular ist eine
* Client-Komponente, und die darf nichts aus `src/server/*` importieren
* (CLAUDE.md). „LDAP“ und „SCIM“ bleiben als Lehnwoerter stehen — so heissen sie
* im Haus und in den Einstellungen der Anbieter.
*/
export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
LDAP: "LDAP",
GRAPH: "Microsoft Graph",
SCIM: "SCIM",
};
/** /**
* Liest eine gespeicherte Zuordnung aus dem Json-Feld. * Liest eine gespeicherte Zuordnung aus dem Json-Feld.
* *
@@ -140,60 +92,16 @@ export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit * mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
* leerem Namen anfragen. * leerem Namen anfragen.
*/ */
export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): AttributZuordnung { export function zuordnungLesen(roh: unknown): AttributZuordnung {
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>; const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
/* const gelesen = { ...AD_ZUORDNUNG };
* Die Vorgabe haengt an der **Art** (#028). Ohne diesen Parameter bekaeme eine
* Graph-Quelle mit leerer Zuordnung die AD-Namen — und fragte dann bei Graph
* nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP`
* haelt jeden Aufrufer von vor #028 bei seinem bisherigen Verhalten.
*/
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
for (const schluessel of ZUORDNUNGS_FELDER) { for (const schluessel of ZUORDNUNGS_FELDER) {
const wert = felder[schluessel]; const wert = felder[schluessel];
if (typeof wert !== "string" || !wert.trim()) continue; if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
/*
* **Was kein Attributname ist, wird beim Lesen verworfen** und nicht
* weitergegeben. Hier zu werfen waere falsch: eine einzige kaputte Zeile in
* der Datenbank machte sonst die ganze Quellenliste unbenutzbar. Dass so
* etwas gar nicht erst hineinkommt, haelt `zuordnungPruefen` beim Schreiben.
*
* Der Grund fuer beides: aus diesen Namen entstehen eine LDAP-Attributliste
* und ein `$select` in der Graph-Adresse. `src/server/graph/leser.ts` berief
* sich im Kommentar auf diese Pruefung, waehrend sie nur in der Server Action
* stand — ein Name mit Komma und `$filter` kam durch den Dienst und landete
* in der Abfrage (Reviewbefund zu #029).
*/
if (!ATTRIBUT_MUSTER.test(wert.trim())) continue;
gelesen[schluessel] = wert.trim();
} }
return gelesen; return gelesen;
} }
/**
* Prueft eine Zuordnung, die **geschrieben** werden soll. Wirft nicht, sondern
* meldet — der Aufrufer entscheidet, ob daraus eine Fehlermeldung oder eine
* Ausnahme wird.
*
* Getrennt von `zuordnungAusEingabe`, weil die aus **Formularfeldern** liest und
* dabei leere Felder herausfallen laesst. Diese hier prueft eine fertige
* Zuordnung, egal woher sie kommt: Formular, Skript, Seed oder Import.
*/
export function zuordnungPruefen(zuordnung: Partial<Record<string, unknown>>): string | undefined {
for (const [schluessel, wert] of Object.entries(zuordnung)) {
if (wert === undefined || wert === null) continue;
if (typeof wert !== "string" || !ATTRIBUT_MUSTER.test(wert.trim())) {
const beschriftung =
(ZUORDNUNGS_BESCHRIFTUNG as Record<string, string | undefined>)[schluessel] ?? schluessel;
return (
`„${String(wert)}“ ist kein Attributname (${beschriftung}). ` +
"Erwartet werden Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben."
);
}
}
return undefined;
}
/** /**
* Die Attributzuordnung aus den Formularfeldern, geprueft. * Die Attributzuordnung aus den Formularfeldern, geprueft.
* *
-13
View File
@@ -1,6 +1,5 @@
import { istAlsVariableErlaubt } from "@/lib/platzhalter"; import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import { PfadBedingungSchema, type PfadBedingung } from "@/lib/bedingungen";
/** /**
* Aufbau einer Vorlagen-Version (#009). * Aufbau einer Vorlagen-Version (#009).
@@ -73,14 +72,6 @@ export const SchrittKonfigSchema = z.looseObject({
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>; export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
/*
* Die Form einer Bedingung steht in `src/lib/bedingungen.ts` — **an einer
* Stelle** (#039). Austauschformat und Editor-Struktur benutzen dieselbe, und
* zwei Schemas dafuer liefen auseinander.
*/
export const AustauschBedingungSchema = PfadBedingungSchema;
export type AustauschBedingung = PfadBedingung;
export const SchrittSchema = z.object({ export const SchrittSchema = z.object({
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text), titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */ /** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
@@ -93,8 +84,6 @@ export const SchrittSchema = z.object({
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein * und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei. * gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
*/ */
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
bedingung: AustauschBedingungSchema.optional(),
dokuUrl: leerAlsUndefiniert( dokuUrl: leerAlsUndefiniert(
z.url({ z.url({
protocol: /^https?$/, protocol: /^https?$/,
@@ -130,8 +119,6 @@ export const AbschnittSchema = z.object({
.array(z.string().trim().min(1).max(200)) .array(z.string().trim().min(1).max(200))
.max(50, "Höchstens 50 Abteilungen je Abschnitt.") .max(50, "Höchstens 50 Abteilungen je Abschnitt.")
.default([]), .default([]),
/** Wann dieser Abschnitt gilt (#039). Fehlt: immer. */
bedingung: AustauschBedingungSchema.optional(),
schritte: z schritte: z
.array(SchrittSchema) .array(SchrittSchema)
.max( .max(
+2 -19
View File
@@ -18,30 +18,13 @@ import { SITZUNG_COOKIE } from "@/lib/sitzung";
* den Proxy zu haengen. * den Proxy zu haengen.
*/ */
/** /** Pfade, die ohne Anmeldung erreichbar sein muessen. */
* Pfade, die ohne Anmeldung erreichbar sein muessen. const OEFFENTLICH: readonly string[] = [
*
* **Exportiert, damit der Test die Liste nicht abschreiben muss.** Vorher zaehlte
* `tests/zugang.test.ts` die Pfade von Hand auf, und seit #029 fehlte dort
* `/api/scim` — eine Aufzaehlung laeuft dem Code hinterher (Reviewbefund zu
* #029). Jetzt prueft ein Test, dass **jeder** Eintrag hier auch im Test steht:
* ein neuer oeffentlicher Pfad ist eine Sicherheitsentscheidung und soll jemanden
* zwingen, sie aufzuschreiben.
*/
export const OEFFENTLICH: readonly string[] = [
"/anmelden", "/anmelden",
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden. // Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
"/api/auth", "/api/auth",
// Healthcheck fuer Docker und den Reverse Proxy. // Healthcheck fuer Docker und den Reverse Proxy.
"/api/gesundheit", "/api/gesundheit",
/*
* Der SCIM-Endpunkt (#029). Ein Identitaetsanbieter hat kein Sitzungscookie —
* er weist sich mit einem Bearer-Token aus, und **jeder** Handler darunter
* prueft es selbst, samt der Quelle und damit des Kunden. Ohne diesen Eintrag
* bekaeme der Anbieter 401 vom Proxy, bevor sein Token ueberhaupt gelesen
* wird — und die Meldung waere „nicht angemeldet“ statt „Token ungueltig“.
*/
"/api/scim",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icon.svg", "/icon.svg",
]; ];
-53
View File
@@ -9,8 +9,6 @@ import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
LaufFehler, LaufFehler,
laufAbschliessen, laufAbschliessen,
laufFreigeben,
laufZurueckweisen,
laufAnlegen, laufAnlegen,
laufArchivieren, laufArchivieren,
laufMitAdKontoVerknuepfen, laufMitAdKontoVerknuepfen,
@@ -379,57 +377,6 @@ export async function laufAbschliessenAktion(
} }
} }
export async function laufFreigebenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
/*
* **`freigeben` und nicht `laeufeBearbeiten`** (#040): das ist der ganze Sinn
* des eigenen Rechts. Ob der Freigebende ausserdem nicht an dem Lauf
* gearbeitet hat, entscheidet der Dienst — hier wird genau ein Recht geprueft
* (CLAUDE.md).
*/
const zugang = await angemeldetOderMeldung("freigeben");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
const notiz = formular.get("notiz");
try {
await laufFreigeben(laufId.data, zugang.benutzer, typeof notiz === "string" ? notiz : undefined);
neuRendern(laufId.data);
return { erfolg: "Lauf freigegeben. Er lässt sich jetzt abschließen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht freigegeben werden.");
}
}
export async function laufZurueckweisenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("freigeben");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
const notiz = z
.string()
.trim()
.min(1, "Eine Zurückweisung braucht eine Begründung.")
.safeParse(formular.get("notiz"));
if (!notiz.success) {
return { fehler: notiz.error.issues[0]?.message ?? "Eine Zurückweisung braucht eine Begründung." };
}
try {
await laufZurueckweisen(laufId.data, zugang.benutzer, notiz.data);
neuRendern(laufId.data);
return { erfolg: "Lauf zurückgewiesen. Der Bearbeiter sieht deine Begründung." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgewiesen werden.");
}
}
export async function laufArchivierenAktion( export async function laufArchivierenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
+24 -156
View File
@@ -14,7 +14,6 @@ import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant"; import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich"; import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
import { import {
VerzeichnisFehler, VerzeichnisFehler,
quelleAnlegen, quelleAnlegen,
@@ -86,30 +85,8 @@ function zuordnungAusFormular(formular: FormData) {
); );
} }
/** const QuelleSchema = z.object({
* Was fuer **jede** Art gilt.
*
* Seit #028 haengen die uebrigen Felder an der Art (`QuelleSchema` je Art
* darunter). Zwei Schemas und nicht eines mit lauter `optional`: ein Schema, das
* alles zulaesst, pruefte nichts — und die Meldung „Bitte die Adresse angeben“
* fuer eine Graph-Quelle waere schlicht falsch.
*/
const GemeinsamSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."), name: z.string().trim().min(1, "Bitte einen Namen angeben."),
art: z.enum(["LDAP", "GRAPH", "SCIM"], {
message: "Bitte die Art der Quelle wählen: LDAP, Microsoft Graph oder SCIM.",
}),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
abgleichIntervallMinuten: z.coerce
.number()
.int()
.min(INTERVALL_MINDESTENS, intervallMeldung())
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
aktiv: z.boolean(),
});
const LdapSchema = z.object({
url: z url: z
.string() .string()
.trim() .trim()
@@ -122,46 +99,16 @@ const LdapSchema = z.object({
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."), bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."), basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
filter: z.string().trim().optional(), filter: z.string().trim().optional(),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
abgleichIntervallMinuten: z.coerce
.number()
.int()
.min(INTERVALL_MINDESTENS, intervallMeldung())
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
aktiv: z.boolean(),
}); });
const GraphSchema = z.object({
tenantId: z.string().trim().min(1, "Bitte die Mandanten-ID oder den Domänennamen angeben."),
clientId: z.string().trim().min(1, "Bitte die Anwendungs-ID der App-Registrierung angeben."),
});
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
function artFelder(
art: "LDAP" | "GRAPH" | "SCIM",
formular: FormData,
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
/*
* **SCIM hat keine.** Eine SCIM-Quelle braucht einen Namen und sonst nichts:
* Laufzettel verbindet sich nicht, es wird beliefert, und das Token entsteht
* erst danach (#029).
*/
if (art === "SCIM") return { werte: {} };
if (art === "LDAP") {
const gelesen = LdapSchema.safeParse({
url: text(formular, "url"),
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
});
return gelesen.success
? { werte: gelesen.data }
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const gelesen = GraphSchema.safeParse({
tenantId: text(formular, "tenantId"),
clientId: text(formular, "clientId"),
});
return gelesen.success
? { werte: gelesen.data }
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
export async function quelleAnlegenAktion( export async function quelleAnlegenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
@@ -169,9 +116,12 @@ export async function quelleAnlegenAktion(
const zugang = await angemeldetOderMeldung(); const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang; if ("fehler" in zugang) return zugang;
const eingabe = GemeinsamSchema.safeParse({ const eingabe = QuelleSchema.safeParse({
name: text(formular, "name"), name: text(formular, "name"),
art: text(formular, "art") || "LDAP", url: text(formular, "url"),
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
caZertifikatPem: text(formular, "caZertifikatPem") || undefined, caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60", abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
aktiv: formular.get("aktiv") === "1", aktiv: formular.get("aktiv") === "1",
@@ -180,45 +130,19 @@ export async function quelleAnlegenAktion(
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
} }
const felder = artFelder(eingabe.data.art, formular); const bindPasswort = text(formular, "bindPasswort");
if ("fehler" in felder) return felder; if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
/*
* Das Geheimnis heisst je Art anders, und die Meldung soll das auch sagen.
* „Ohne Passwort kommt keine Anmeldung zustande“ ist fuer eine
* App-Registrierung die falsche Auskunft.
*/
const geheimnis =
eingabe.data.art === "LDAP"
? { bindPasswort: text(formular, "bindPasswort") }
: eingabe.data.art === "GRAPH"
? { clientSecret: text(formular, "clientSecret") }
: {};
const geheimnisWert = Object.values(geheimnis)[0];
if (eingabe.data.art !== "SCIM" && !geheimnisWert) {
return {
fehler:
eingabe.data.art === "LDAP"
? "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande."
: "Ohne Client-Secret kommt keine Anmeldung am Mandanten zustande.",
};
}
const zuordnung = zuordnungAusFormular(formular); const zuordnung = zuordnungAusFormular(formular);
if ("fehler" in zuordnung) return zuordnung; if ("fehler" in zuordnung) return zuordnung;
try { try {
const quelle = await quelleAnlegen( const quelle = await quelleAnlegen(
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung }, { ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
zugang.benutzer, zugang.benutzer,
); );
revalidatePath("/admin/verzeichnis"); revalidatePath("/admin/verzeichnis");
return { return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
erfolg:
eingabe.data.art === "SCIM"
? `„${quelle.name}“ ist angelegt. Erzeug als Nächstes das Token und trag es beim Anbieter ein.`
: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.`,
};
} catch (fehler) { } catch (fehler) {
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden."); return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
} }
@@ -234,9 +158,12 @@ export async function quelleBearbeitenAktion(
const id = text(formular, "id"); const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." }; if (!id) return { fehler: "Es fehlt die Quelle." };
const eingabe = GemeinsamSchema.safeParse({ const eingabe = QuelleSchema.safeParse({
name: text(formular, "name"), name: text(formular, "name"),
art: text(formular, "art") || "LDAP", url: text(formular, "url"),
bindDn: text(formular, "bindDn"),
basisDn: text(formular, "basisDn"),
filter: text(formular, "filter") || undefined,
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das // Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
// Schema nur zwei. // Schema nur zwei.
caZertifikatPem: undefined, caZertifikatPem: undefined,
@@ -247,9 +174,6 @@ export async function quelleBearbeitenAktion(
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
} }
const felder = artFelder(eingabe.data.art, formular);
if ("fehler" in felder) return felder;
/* /*
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues * Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte * Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
@@ -276,15 +200,11 @@ export async function quelleBearbeitenAktion(
if ("fehler" in zuordnung) return zuordnung; if ("fehler" in zuordnung) return zuordnung;
try { try {
const clientSecret = text(formular, "clientSecret");
const quelle = await quelleBearbeiten( const quelle = await quelleBearbeiten(
id, id,
{ {
...eingabe.data, ...eingabe.data,
...felder.werte,
// Leer heisst unveraendert — bei beiden Geheimnissen.
...(bindPasswort ? { bindPasswort } : {}), ...(bindPasswort ? { bindPasswort } : {}),
...(clientSecret ? { clientSecret } : {}),
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}), ...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
attributZuordnung: zuordnung.zuordnung, attributZuordnung: zuordnung.zuordnung,
}, },
@@ -315,58 +235,6 @@ export async function quelleLoeschenAktion(
} }
} }
export type TokenErgebnis = AktionsErgebnis & {
/**
* Das Token – **genau dieses eine Mal**.
*
* Es steht in der Datenbank nur als argon2-Hash, es gibt also keinen zweiten
* Weg, es anzusehen. Wer es verliert, erzeugt ein neues; das alte gilt dann
* nicht mehr.
*/
readonly token?: string;
};
export async function scimTokenErzeugenAktion(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
const { token } = await scimTokenErzeugen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return {
token,
erfolg:
"Das Token gilt ab jetzt. Kopier es in die Einstellungen des Anbieters — danach ist es " +
"nicht mehr ansehbar.",
};
} catch (fehler) {
return alsErgebnis(fehler, "Das Token konnte nicht erzeugt werden.");
}
}
export async function scimTokenWiderrufenAktion(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
await scimTokenWiderrufen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return { erfolg: "Das Token ist widerrufen. Die Quelle nimmt ab jetzt nichts mehr an." };
} catch (fehler) {
return alsErgebnis(fehler, "Das Token konnte nicht widerrufen werden.");
}
}
export type TestErgebnis = AktionsErgebnis & { export type TestErgebnis = AktionsErgebnis & {
readonly treffer?: readonly GemappterEintrag[]; readonly treffer?: readonly GemappterEintrag[];
readonly gesamtGelesen?: number; readonly gesamtGelesen?: number;
@@ -393,7 +261,7 @@ export async function verbindungTestenAktion(
const ergebnis = await verbindungTesten(id, zugang.benutzer); const ergebnis = await verbindungTesten(id, zugang.benutzer);
return { return {
erfolg: erfolg:
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0 ergebnis.treffer.length === 0
? "Die Verbindung steht, aber der Filter findet niemanden." ? "Die Verbindung steht, aber der Filter findet niemanden."
: ergebnis.treffer.length === 0 : ergebnis.treffer.length === 0
? /* ? /*
+1 -9
View File
@@ -267,17 +267,10 @@ export async function veroeffentlichenAktion(
const zugang = await angemeldetOderMeldung(); const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang; if ("fehler" in zugang) return zugang;
const eingabe = z const eingabe = z
.object({ .object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
versionId: z.string().min(1),
aenderungsnotiz: z.string().trim().optional(),
freigabePflicht: z.boolean(),
})
.safeParse({ .safeParse({
versionId: formular.get("versionId"), versionId: formular.get("versionId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined, aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
// Ein nicht gesetztes Kaestchen schickt gar nichts mit — das ist `false`
// und nicht „unveraendert“: das Formular zeigt den aktuellen Stand an.
freigabePflicht: formular.get("freigabePflicht") === "1",
}); });
if (!eingabe.success) return { fehler: "Es fehlt die Version." }; if (!eingabe.success) return { fehler: "Es fehlt die Version." };
@@ -286,7 +279,6 @@ export async function veroeffentlichenAktion(
eingabe.data.versionId, eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null, eingabe.data.aenderungsnotiz ?? null,
zugang.benutzerId, zugang.benutzerId,
eingabe.data.freigabePflicht,
); );
revalidatePath("/vorlagen"); revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` }; return { erfolg: `Version ${version.version} ist veröffentlicht.` };
+5 -134
View File
@@ -5,7 +5,7 @@ import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { import {
kundenkuerzelAusGruppen, kundenkuerzelAusGruppen,
rolleAusGruppenGenau, rolleAusGruppen,
rollenZuordnungAktiv, rollenZuordnungAktiv,
type OidcAnspruecke, type OidcAnspruecke,
} from "@/server/auth/oidc"; } from "@/server/auth/oidc";
@@ -17,23 +17,10 @@ import {
* Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen. * 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 { export class AnmeldeFehler extends Error {
readonly grund: AnmeldeGrund | undefined; constructor(nachricht: string) {
constructor(nachricht: string, grund?: AnmeldeGrund) {
super(nachricht); super(nachricht);
this.name = "AnmeldeFehler"; this.name = "AnmeldeFehler";
this.grund = grund;
} }
} }
@@ -115,121 +102,6 @@ export async function passwortHashen(passwort: string): Promise<string> {
* Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass * Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass
* jemand hier nachpflegen muss. * 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" };
/*
* **Ohne `take`, damit die gemeldete Zahl die Zahl ist.** Vorher stand hier
* `take: 2` — fuer die Entscheidung genuegt das, aber `anzahl` ging damit in
* den Protokolleintrag „Anmeldung abgewiesen: UPN mehrdeutig“, und der sagte
* dann immer „2“, auch bei sieben Treffern (Reviewbefund zu #029). Wer das
* Protokoll liest, um den Fall aufzuloesen, bekam eine Zahl, die keine war.
*
* Die Abfrage laeuft gegen einen Index auf `upn` und liefert im Normalfall
* **einen** Treffer; mehrere sind ein Datenfehler, den jemand beheben muss.
*/
const passende = await prisma.mitarbeiter.findMany({
where: { upn: { equals: gesucht, mode: "insensitive" }, aktiv: true },
select: { id: true, kundeId: true },
});
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> { export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
// Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die // Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die
// Kennung beim Anbieter nicht. // Kennung beim Anbieter nicht.
@@ -261,8 +133,9 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
throw new AnmeldeFehler("Dieses Konto ist deaktiviert."); throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
} }
const zuweisung = await rolleUndPersonBestimmen(anspruecke, vorhanden); // Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
const { rolle, mitarbeiterId } = zuweisung; // waere jede Anmeldung eine stille Herabstufung auf LESER.
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
const benutzer = vorhanden const benutzer = vorhanden
? await prisma.benutzer.update({ ? await prisma.benutzer.update({
@@ -272,7 +145,6 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
email: anspruecke.email, email: anspruecke.email,
name: anspruecke.name, name: anspruecke.name,
rolle, rolle,
mitarbeiterId,
letzteAnmeldungAm: new Date(), letzteAnmeldungAm: new Date(),
}, },
}) })
@@ -282,7 +154,6 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
email: anspruecke.email, email: anspruecke.email,
name: anspruecke.name, name: anspruecke.name,
rolle, rolle,
mitarbeiterId,
aktiv: true, aktiv: true,
letzteAnmeldungAm: new Date(), letzteAnmeldungAm: new Date(),
}, },
+3 -26
View File
@@ -1,5 +1,5 @@
import * as oidc from "openid-client"; import * as oidc from "openid-client";
import { Rolle } from "@/generated/prisma/enums"; import type { Rolle } from "@/generated/prisma/enums";
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { staerksteRolle } from "@/server/auth/rollen"; import { staerksteRolle } from "@/server/auth/rollen";
@@ -139,16 +139,6 @@ export type OidcAnspruecke = {
readonly emailBestaetigt: boolean; readonly emailBestaetigt: boolean;
readonly name: string; readonly name: string;
readonly gruppen: readonly 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. */ /** Ziel, das vor der Anmeldung angefordert wurde. */
readonly ziel: string; readonly ziel: string;
}; };
@@ -209,7 +199,6 @@ export async function anmeldungAbschliessen(
emailBestaetigt: anspruecke["email_verified"] === true, emailBestaetigt: anspruecke["email_verified"] === true,
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email, name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
gruppen: gruppenAus(anspruecke["groups"]), gruppen: gruppenAus(anspruecke["groups"]),
upn: textAus(anspruecke["upn"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
ziel: fluss["ziel"] ?? "/", ziel: fluss["ziel"] ?? "/",
}; };
} }
@@ -234,15 +223,7 @@ 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])[] = [ const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
[umgebung("OIDC_GROUP_ADMIN"), "ADMIN"], [umgebung("OIDC_GROUP_ADMIN"), "ADMIN"],
[umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"], [umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"],
@@ -253,11 +234,7 @@ export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefi
const getroffen = zuordnung const getroffen = zuordnung
.filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe)) .filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe))
.map(([, rolle]) => rolle); .map(([, rolle]) => rolle);
return getroffen.length === 0 ? undefined : staerksteRolle(getroffen); return staerksteRolle(getroffen);
}
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
return rolleAusGruppenGenau(gruppen) ?? Rolle.LESER;
} }
/** /**
+7 -77
View File
@@ -20,24 +20,8 @@ import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
* des Cookies. * des Cookies.
*/ */
export const RECHTE = { 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"], 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. */ /** Laeufe starten, abarbeiten, abschliessen und archivieren. */
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */ /** Geraete und deren Stammdaten pflegen, Tags registrieren. */
@@ -54,19 +38,6 @@ export const RECHTE = {
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */ /** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"], vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
* Einen Lauf freigeben oder zurueckweisen, der eine Freigabe verlangt (#040).
*
* **Nicht `TECHNIKER`**, und das ist der ganze Sinn: die Vier-Augen-Regel soll
* nicht davon abhaengen, dass zufaellig ein zweiter Techniker greifbar ist.
* Wer sie umgehen will, braucht dann ein Konto mit mehr Rechten — und das
* steht im Protokoll.
*
* Das Recht allein genuegt nicht: wer an dem Lauf **gearbeitet** hat, gibt ihn
* auch mit diesem Recht nicht frei. Das prueft der Dienst, und ein CHECK in der
* Datenbank haelt es ebenfalls fest.
*/
freigeben: ["ADMIN", "REDAKTEUR"],
/** /**
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein * Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben. * abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
@@ -86,59 +57,18 @@ export function darf(rolle: Rolle, recht: Recht): boolean {
* Die staerkste der uebergebenen Rollen. * Die staerkste der uebergebenen Rollen.
* *
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die * 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`. * 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
* **Das gilt weiter fuer eine eingerichtete Zuordnung ohne Treffer in dieser * und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
* 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 { 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) { for (const rolle of rollen) {
if (staerkste === undefined || ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) { if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
staerkste = rolle; staerkste = rolle;
} }
} }
return staerkste ?? Rolle.LESER; return staerkste;
}
/**
* 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 // Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
-34
View File
@@ -5,7 +5,6 @@ import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rollen"; import { darf, type Recht } from "@/server/auth/rollen";
import type { Rolle } from "@/generated/prisma/enums";
/** /**
* Wer ist angemeldet und was darf er (#007). * Wer ist angemeldet und was darf er (#007).
@@ -56,39 +55,6 @@ export type Angemeldet = {
readonly sitzung: Sitzung; readonly sitzung: Sitzung;
}; };
/**
* Gilt diese Sitzung **jetzt noch**, und darf sie `recht`?
*
* Fuer langlebige Verbindungen. `angemeldet()` liest das Cookie und laeuft
* deshalb nur innerhalb einer Anfrage; ein Ereignisstrom lebt Minuten bis
* Stunden **nach** dem Ende der Anfrage weiter, und in seinen Rueckrufen gibt es
* keinen `cookies()`-Kontext mehr. Die Sitzung selbst ist zu dem Zeitpunkt
* laengst geprueft — nachzusehen ist, was sich seither in der Datenbank
* geaendert haben kann: `aktiv`, die Rolle und `sitzungenUngueltigAb`.
*
* **Warum das noetig ist:** Ohne diese Pruefung bekam eine offen gelassene
* Lasche weiter live Daten, nachdem ein Administrator das Konto deaktiviert und
* die Sitzungen beendet hatte — bis jemand die Lasche schloss. `docs/architektur.md`
* verspricht „ein deaktivierter Benutzer wird auch mit gueltigem Cookie
* abgewiesen“, und fuer einen offenen Strom galt das nicht (Reviewbefund zu #029).
*
* Die Rolle wird **mit**geliefert, nicht nur geprueft: wer waehrend eines Stroms
* herabgestuft wird, soll von da an die Sicht seiner neuen Rolle bekommen — das
* ist dieselbe Regel wie „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
*/
export async function sitzungNochGueltig(
sitzung: Sitzung,
recht: Recht,
): Promise<{ readonly rolle: Rolle } | undefined> {
const benutzer = await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId },
select: { id: true, aktiv: true, rolle: true, sitzungenUngueltigAb: true },
});
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return undefined;
if (!darf(benutzer.rolle, recht)) return undefined;
return { rolle: benutzer.rolle };
}
/** /**
* Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es * Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
* `sitzungErforderlich`. * `sitzungErforderlich`.
-308
View File
@@ -1,308 +0,0 @@
import type { AttributZuordnung } from "@/lib/verzeichnis";
import { ersatzName, type RoherEintrag, type Uebersprungen } from "@/server/ldap/zuordnung";
import { GraphFehler, graphBasis, tokenHolen, type GraphZugang } from "@/server/graph/zugang";
/**
* Benutzer aus Microsoft Graph lesen (#028).
*
* Derselbe Vertrag wie `src/server/ldap/leser.ts`: Seite für Seite, jeder Eintrag
* einzeln weitergegeben, und der Abgleich schreibt mit. Bei einem Mandanten mit
* einigen Tausend Konten ist das der Unterschied zwischen einem Abgleich und
* einem Speicherproblem.
*
* **Was Graph anders macht als LDAP, und was daraus folgt:**
*
* - Es gibt **keinen DN und keine OU**. `dn` bleibt `null`, und `{{ou}}` damit
* leer — statt dass etwas hineingedeutet wird, das kein Befehl brauchen kann.
* - Der Vorgesetzte kommt über `$expand` **direkt als Kennung** mit. Die
* DN-Auflösung des LDAP-Wegs entfällt; `vorgesetzterDirekt` sagt das dem
* Abgleich.
* - **`$filter=accountEnabled eq true` ersetzt den LDAP-Standardfilter.** Ein
* deaktiviertes Konto wird gar nicht geliefert, und was nicht geliefert wird,
* setzt der Abgleich auf `aktiv = false` — dieselbe Mechanik, nur mit anderer
* Abfrage. Serverseitig gefiltert und nicht hier beim Zuordnen: „übersprungen“
* heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden
* absichtlich **nicht** abgeschaltet.
*/
/**
* Wie viele Konten je Seite.
*
* 999 und nicht 1000: Graph lässt bei `/users` höchstens 999 zu und antwortet
* auf 1000 mit einem Fehler. Eine Zahl, die man einmal nachliest und dann
* aufschreibt.
*/
export const SEITENGROESSE = 999;
/** Wie viele Seiten höchstens, als Reißleine gegen eine Schleife. */
const SEITEN_HOECHSTENS = 1000;
export type GraphLeseBilanz = {
readonly gesamtGelesen: number;
readonly uebersprungen: readonly Uebersprungen[];
/** Wie viele Seiten geholt wurden — der Seitenabruf ist sonst unsichtbar. */
readonly seiten: number;
};
type GraphBenutzer = Record<string, unknown> & {
manager?: { id?: unknown } | null;
};
type GraphSeite = {
value?: unknown;
"@odata.nextLink"?: unknown;
error?: { message?: unknown; code?: unknown };
};
/** Ein einzelner Wert als Text, oder `null`. */
function textWert(nutzer: GraphBenutzer, feld: string): string | null {
const wert = nutzer[feld];
if (typeof wert === "string") return wert.trim() || null;
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return null;
}
/**
* Welche Felder angefragt werden – genau die zugeordneten, nichts sonst.
*
* Dieselbe Begründung wie bei LDAP: ohne `$select` liefert Graph ein breites
* Standardprofil. Gespeichert wird davon nichts, aber gelesen eben doch — es
* stünde im Speicher, in einer Fehlerausgabe, in einem Netzwerkmitschnitt.
*
* `manager` fliegt dabei heraus: der ist keine Eigenschaft, sondern eine
* Beziehung und kommt über `$expand`. Ihn in `$select` zu schreiben beantwortet
* Graph mit einem Fehler, und der ist schwer zu lesen.
*/
export function angefragteFelder(zuordnung: AttributZuordnung): string[] {
const felder = new Set<string>();
for (const [name, wert] of Object.entries(zuordnung)) {
if (name === "vorgesetzter") continue;
if (wert) felder.add(wert);
}
// Immer dabei: ohne `id` gibt es keine Wiedererkennung, ohne `accountEnabled`
// keine Unterscheidung zwischen gesperrt und gelöscht.
felder.add("id");
felder.add("accountEnabled");
return [...felder];
}
/**
* Der Filter, der deaktivierte Konten gar nicht erst liefert.
*
* **Das Gegenstück zum LDAP-Standardfilter** (`!(userAccountControl…=2)`), und
* aus demselben Grund: was nicht im Ergebnis steht, setzt der Abgleich auf
* `aktiv = false`. Ein gesperrtes Konto in Entra ID soll genauso behandelt
* werden wie ein gesperrtes im AD.
*
* **Warum serverseitig und nicht hier beim Zuordnen:** „übersprungen“ bedeutet
* im Abgleich „konnte nicht zugeordnet werden“, und solche Einträge werden
* absichtlich **nicht** deaktiviert — ein Konto ohne GUID soll nicht die Person
* abschalten. Ein deaktiviertes Konto als übersprungen zu melden hätte es
* deshalb aktiv gelassen. Gefunden vom Test „setzt aktiv = false“.
*/
export const GRAPH_FILTER = "accountEnabled eq true";
/**
* Die naechste Seitenadresse — **nur, wenn sie auf denselben Host zeigt**.
*
* Jede Seite wird mit `Authorization: Bearer <token>` geholt, und das Token gilt
* mandantenweit (`User.Read.All`). Der Antwort von Graph ueber TLS zu trauen ist
* vertretbar, aber `GRAPH_API_URL` ist absichtlich einstellbar — fuer nationale
* Clouds und fuer den Test-Doppelgaenger. Ein Tippfehler darin oder ein
* aufbrechender Firmenproxy dazwischen wuerde das Token sonst an eine fremde
* Adresse schicken (Reviewbefund zu #029).
*
* Die Pruefung kostet eine Zeile und schliesst den Fall. Ein `nextLink`, der
* woandershin zeigt, beendet den Abruf still: er ist kein Fehler des
* Verzeichnisses, und ein Abbruch mitten im Abgleich waere schlimmer als eine
* unvollstaendige Seite — der naechste Lauf holt sie.
*/
export function naechsteSeite(weiter: unknown, basis = graphBasis()): string | undefined {
if (typeof weiter !== "string" || !weiter.trim()) return undefined;
try {
if (new URL(weiter).origin !== new URL(basis).origin) {
console.error(`Graph: @odata.nextLink zeigt auf einen anderen Host als GRAPH_API_URL — Abruf beendet.`);
return undefined;
}
} catch {
// Keine brauchbare Adresse: dasselbe Ergebnis, nur ohne Erklaerung.
return undefined;
}
return weiter;
}
/**
* Die erste Adresse der Abfrage. Danach folgt der Abruf `@odata.nextLink`.
*
* Die Parameter werden **von Hand** zusammengesetzt und nicht mit
* `URLSearchParams`: das kodiert `$` zu `%24` und die Klammern in
* `manager($select=id)` zu `%28`/`%29`. Graph nimmt das teils an und antwortet
* teils mit „Invalid expand clause“ — und eine Adresse, die in einem Mitschnitt
* anders aussieht als in der Dokumentation, kostet bei der Fehlersuche eine
* Stunde. Die Werte selbst sind feste Feldnamen aus `ATTRIBUT_MUSTER`
* (Buchstaben, Ziffern, Bindestrich), also ist hier nichts zu maskieren.
*/
export function ersteAdresse(zuordnung: AttributZuordnung, seitengroesse = SEITENGROESSE): string {
const teile = [
`$select=${angefragteFelder(zuordnung).join(",")}`,
`$top=${seitengroesse}`,
`$filter=${encodeURIComponent(GRAPH_FILTER)}`,
"$expand=manager($select=id)",
];
return `${graphBasis()}/v1.0/users?${teile.join("&")}`;
}
/**
* Ein Graph-Benutzer wird ein `RoherEintrag`, oder er wird übersprungen.
*
* Übersprungen wird genau ein Fall, wie bei LDAP: ohne stabile Kennung gibt es
* nichts, woran ein zweiter Abgleich den Eintrag wiedererkennen könnte.
*
* Deaktivierte Konten kommen hier **nicht** an — dafür sorgt `GRAPH_FILTER` in
* der Abfrage. Kommt doch eines, ist das ein Fehler in der Abfrage, und die
* Funktion wirft; die Begründung steht unten an der Stelle.
*/
export function benutzerZuordnen(
nutzer: GraphBenutzer,
zuordnung: AttributZuordnung,
): { readonly eintrag: RoherEintrag } | { readonly uebersprungen: Uebersprungen } {
const externeId = textWert(nutzer, zuordnung.externeId);
/*
* Als Kennzeichen in der Übersprungen-Liste dient der UPN und nicht ein DN —
* den gibt es hier nicht. Die Liste soll sagen, **wer** übersprungen wurde;
* dafür ist der Anmeldename die brauchbarste Angabe.
*/
const kennzeichen = textWert(nutzer, zuordnung.upn) ?? externeId ?? "(ohne Kennung)";
if (!externeId) {
return {
uebersprungen: {
dn: kennzeichen,
grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
},
};
}
/*
* **Kein Fall für die Übersprungen-Liste.** Deaktivierte Konten filtert die
* Abfrage serverseitig heraus (`GRAPH_FILTER`); kommt hier trotzdem eines an,
* ist der Filter nicht angekommen. Dann ist das ein Fehler in der Abfrage und
* keine Eigenschaft des Kontos — und ein Eintrag in der Übersprungen-Liste
* hätte zur Folge, dass die Person **aktiv** bleibt, weil der Abgleich
* übersprungene Einträge absichtlich nicht abschaltet.
*/
if (nutzer["accountEnabled"] === false) {
throw new GraphFehler(
`Graph hat ein deaktiviertes Konto geliefert (${kennzeichen}), obwohl der Filter ` +
`„${GRAPH_FILTER}“ gesetzt war. Das ist ein Fehler in der Abfrage, nicht am Konto.`,
);
}
const vorname = textWert(nutzer, zuordnung.vorname);
const nachname = textWert(nutzer, zuordnung.nachname);
const vorgesetzterId = typeof nutzer.manager?.id === "string" ? nutzer.manager.id.trim() : "";
return {
eintrag: {
externeId,
vorname: vorname ?? "",
nachname: nachname ?? "",
anzeigename: textWert(nutzer, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, kennzeichen),
mail: textWert(nutzer, zuordnung.mail),
upn: textWert(nutzer, zuordnung.upn),
sam: textWert(nutzer, zuordnung.sam),
// Kein DN in Entra ID – siehe Kopfkommentar.
dn: null,
abteilung: textWert(nutzer, zuordnung.abteilung),
titel: textWert(nutzer, zuordnung.titel),
vorgesetzterDn: null,
vorgesetzterDirekt: vorgesetzterId || null,
},
};
}
/**
* Läuft über alle Seiten und gibt jeden Eintrag einzeln weiter.
*
* `grenze` zählt die **zugeordneten** Einträge, wie bei LDAP: „die ersten zehn
* Treffer zeigen“ meint zehn Zeilen in der Tabelle. Ist die Grenze erreicht,
* wird keine weitere Seite geholt.
*/
export async function graphEintraegeStroemen(
zugang: GraphZugang,
zuordnung: AttributZuordnung,
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
grenze?: number,
seitengroesse = SEITENGROESSE,
): Promise<GraphLeseBilanz> {
const token = await tokenHolen(zugang);
let adresse: string | undefined = ersteAdresse(zuordnung, seitengroesse);
let gesamtGelesen = 0;
let zugeordnet = 0;
let seiten = 0;
const uebersprungen: Uebersprungen[] = [];
while (adresse) {
seiten += 1;
if (seiten > SEITEN_HOECHSTENS) {
/*
* Reißleine. Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
* eine Schleife, die bis zum Speicherende läuft — und zwar still, weil
* jeder einzelne Abruf gelingt.
*/
throw new GraphFehler(
`Der Seitenabruf hat ${SEITEN_HOECHSTENS} Seiten überschritten. Das ist kein Verzeichnis ` +
"dieser Größe, sondern ein Fehler bei der Weiterleitung.",
);
}
let antwort: Response;
try {
antwort = await fetch(adresse, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: AbortSignal.timeout(60_000),
});
} catch (fehler) {
throw new GraphFehler(
`Graph war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
const seite = (await antwort.json().catch(() => ({}))) as GraphSeite;
if (!antwort.ok) {
const meldung = typeof seite.error?.message === "string" ? seite.error.message : antwort.statusText;
const code = typeof seite.error?.code === "string" ? ` (${seite.error.code})` : "";
/*
* 403 ist hier der häufigste Fehler und hat genau eine Ursache: die
* Administratoreinwilligung für `User.Read.All` fehlt. Das steht in der
* Meldung, weil es sonst niemand errät.
*/
const hinweis =
antwort.status === 403
? " Fehlt die Administratoreinwilligung für User.Read.All in der App-Registrierung?"
: "";
throw new GraphFehler(`Graph antwortete mit ${antwort.status}${code}: ${meldung}.${hinweis}`);
}
const nutzer = Array.isArray(seite.value) ? (seite.value as GraphBenutzer[]) : [];
for (const einer of nutzer) {
gesamtGelesen += 1;
const ergebnis = benutzerZuordnen(einer, zuordnung);
if ("uebersprungen" in ergebnis) {
uebersprungen.push(ergebnis.uebersprungen);
continue;
}
await verarbeiten(ergebnis.eintrag);
zugeordnet += 1;
if (grenze !== undefined && zugeordnet >= grenze) {
return { gesamtGelesen, uebersprungen, seiten };
}
}
const weiter = seite["@odata.nextLink"];
adresse = naechsteSeite(weiter);
}
return { gesamtGelesen, uebersprungen, seiten };
}
-173
View File
@@ -1,173 +0,0 @@
/**
* Zugang zu Microsoft Graph (#028).
*
* Gelesen wird mit dem **Client-Credentials-Flow**: die Anwendung meldet sich als
* sie selbst an, nicht im Namen eines Menschen. Das ist für einen Abgleich der
* richtige Weg — er läuft nachts, wenn niemand angemeldet ist.
*
* **Die Endpunkte sind einstellbar**, aus zwei Gründen. Erstens gibt es Graph
* nicht nur unter `graph.microsoft.com`: eine Behörde liegt in einer nationalen
* Cloud mit eigener Adresse. Zweitens prüft `tests/graph.test.ts` den ganzen
* Weg gegen einen HTTP-Doppelgänger — gegen einen echten Mandanten ist dieser
* Code **nicht** gelaufen, und das steht so auch im Issue.
*/
import { entschluesseln } from "@/lib/geheimnisse";
export class GraphFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "GraphFehler";
}
}
export type GraphZugang = {
readonly tenantId: string;
readonly clientId: string;
readonly clientSecret: string;
};
/** `https://login.microsoftonline.com` – umstellbar für nationale Clouds und Tests. */
export function loginBasis(): string {
return (process.env["GRAPH_LOGIN_URL"]?.trim() || "https://login.microsoftonline.com").replace(/\/+$/, "");
}
/** `https://graph.microsoft.com` – dito. */
export function graphBasis(): string {
return (process.env["GRAPH_API_URL"]?.trim() || "https://graph.microsoft.com").replace(/\/+$/, "");
}
/**
* Der Zugang aus einer Quellenzeile.
*
* Wirft, wenn Felder fehlen: bei `art = GRAPH` hält die Datenbank sie fest
* (CHECK `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer
* LDAP-Zeile wäre ein Programmierfehler, und der soll nicht als
* `undefined`-Zugang weiterlaufen.
*/
export function graphZugangAus(quelle: {
readonly art: string;
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVerschluesselt: string | null;
}): GraphZugang {
if (!quelle.tenantId || !quelle.clientId || !quelle.clientSecretVerschluesselt) {
throw new GraphFehler(
"Dieser Quelle fehlen die Graph-Zugangsdaten (Mandant, Anwendungs-ID oder Geheimnis).",
);
}
return {
tenantId: quelle.tenantId,
clientId: quelle.clientId,
clientSecret: entschluesseln(quelle.clientSecretVerschluesselt),
};
}
type Zwischenspeicher = { readonly token: string; readonly gueltigBis: number };
/*
* Ein Token je Mandant und Anwendung, an `globalThis` wie der Prisma-Client.
*
* **Warum überhaupt zwischenspeichern:** ein Token gilt eine Stunde, und ein
* Abgleich über 5000 Konten holt rund fünf Seiten — ohne Zwischenspeicher wäre
* das fünfmal dieselbe Anmeldung. Microsoft begrenzt die Zahl der
* Token-Anfragen, und ein Abgleich, der dagegen läuft, scheitert mitten im
* Schreiben.
*
* **Warum an `globalThis`:** Hot Reload lädt das Modul neu, und ein Token je
* Speichervorgang wäre dasselbe Problem im Kleinen.
*/
const global = globalThis as typeof globalThis & {
laufzettelGraphToken?: Map<string, Zwischenspeicher>;
};
function speicher(): Map<string, Zwischenspeicher> {
global.laufzettelGraphToken ??= new Map();
return global.laufzettelGraphToken;
}
/**
* Sicherheitsabstand zum Ablauf.
*
* Ein Token, das in zehn Sekunden abläuft, ist für eine Seite, die zwei Sekunden
* braucht, formal noch gültig — und in der Mitte der nächsten nicht mehr.
* Sechzig Sekunden Abstand kosten nichts und ersparen den Fehler, der am
* schwersten zu lesen ist: ein Abgleich, der nach der dritten Seite mit 401
* abbricht.
*/
const ABSTAND_MS = 60_000;
function schluessel(zugang: GraphZugang): string {
return `${zugang.tenantId}/${zugang.clientId}`;
}
/** Nur für Tests: den Zwischenspeicher leeren. */
export function tokenSpeicherLeeren(): void {
speicher().clear();
}
type TokenAntwort = { access_token?: unknown; expires_in?: unknown; error_description?: unknown };
/**
* Ein Zugriffstoken holen – aus dem Zwischenspeicher, sonst neu.
*
* Der Fehlerfall ist hier wichtiger als der Normalfall: Entra ID antwortet bei
* einem falschen Geheimnis mit einer **sehr** ausführlichen Meldung, die den
* Mandanten, die Anwendungs-ID und eine Ablaufverfolgungskennung enthält. Die
* gehört ins Protokoll des Prozesses, nicht in eine Antwort und nicht an die
* Quelle — `letzterAbgleichFehler` ist in der Oberfläche sichtbar.
*/
export async function tokenHolen(zugang: GraphZugang, jetzt = Date.now()): Promise<string> {
const vorhanden = speicher().get(schluessel(zugang));
if (vorhanden && vorhanden.gueltigBis - ABSTAND_MS > jetzt) return vorhanden.token;
const adresse = `${loginBasis()}/${encodeURIComponent(zugang.tenantId)}/oauth2/v2.0/token`;
const rumpf = new URLSearchParams({
grant_type: "client_credentials",
client_id: zugang.clientId,
client_secret: zugang.clientSecret,
scope: `${graphBasis()}/.default`,
});
let antwort: Response;
try {
antwort = await fetch(adresse, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: rumpf,
signal: AbortSignal.timeout(20_000),
});
} catch (fehler) {
throw new GraphFehler(
`Der Anmeldedienst war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
const daten = (await antwort.json().catch(() => ({}))) as TokenAntwort;
if (!antwort.ok) {
/*
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
* Healthcheck (#025): eine Meldung, die Mandant und Anwendungs-ID nennt,
* gehört nicht in eine Oberfläche.
*/
console.error(
"Graph-Anmeldung fehlgeschlagen:",
typeof daten.error_description === "string" ? daten.error_description : antwort.statusText,
);
throw new GraphFehler(
`Die Anmeldung am Mandanten ist fehlgeschlagen (${antwort.status}). Prüfe Mandant, ` +
"Anwendungs-ID und Geheimnis; Einzelheiten stehen im Protokoll des Dienstes.",
);
}
const token = typeof daten.access_token === "string" ? daten.access_token : undefined;
if (!token) {
throw new GraphFehler("Der Anmeldedienst hat kein Zugriffstoken geliefert.");
}
const sekunden = typeof daten.expires_in === "number" ? daten.expires_in : 3600;
speicher().set(schluessel(zugang), { token, gueltigBis: jetzt + sekunden * 1000 });
return token;
}
+7 -32
View File
@@ -45,15 +45,7 @@ export type GemappterEintrag = {
mail: string | null; mail: string | null;
upn: string | null; upn: string | null;
sam: string | null; sam: string | null;
/** dn: string;
* Der DN im Verzeichnis — **`null` bei einer Graph-Quelle** (#028).
*
* Entra ID hat keinen DN und keine OU. Ihn dort zu erfinden hiesse, `{{ou}}`
* mit etwas zu fuellen, das kein PowerShell-Befehl brauchen kann. `null` ist
* die ehrliche Antwort: `{{ou}}` bleibt dann leer und faellt auf die Ziel-OU
* der Abteilung zurueck, wenn eine gepflegt ist.
*/
dn: string | null;
abteilung: string | null; abteilung: string | null;
titel: string | null; titel: string | null;
vorgesetzterExterneId: string | null; vorgesetzterExterneId: string | null;
@@ -70,14 +62,6 @@ export type GemappterEintrag = {
*/ */
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & { export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
vorgesetzterDn: string | null; vorgesetzterDn: string | null;
/**
* Die Kennung des Vorgesetzten, falls sie schon beim Lesen feststeht (#028).
*
* Graph liefert sie ueber `$expand=manager($select=id)` direkt mit — dort gibt
* es keinen DN, der erst aufgeloest werden muesste. Ist dieses Feld gesetzt,
* uebergeht `vorgesetzteAufloesen` die DN-Tabelle.
*/
vorgesetzterDirekt?: string | null;
}; };
/** /**
@@ -165,9 +149,6 @@ export function eintragZuordnen(
zuordnung: AttributZuordnung, zuordnung: AttributZuordnung,
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } { ): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
const dn = eintrag.dn.trim(); const dn = eintrag.dn.trim();
// Bei LDAP steht immer ein DN; die Nullability von `dn` kommt von der
// Graph-Quelle (#028) und betrifft diese Funktion nicht.
let externeId: string | undefined; let externeId: string | undefined;
try { try {
externeId = externeIdLesen(eintrag, zuordnung.externeId); externeId = externeIdLesen(eintrag, zuordnung.externeId);
@@ -209,11 +190,11 @@ export function eintragZuordnen(
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste * wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
* Teil des DN ein. * Teil des DN ein.
*/ */
export function ersatzName(vorname: string | null, nachname: string | null, kennzeichen: string): string { function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
const gebaut = [nachname, vorname].filter((teil) => teil).join(", "); const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
if (gebaut) return gebaut; if (gebaut) return gebaut;
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim(); const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
return ersterTeil ?? kennzeichen; return ersterTeil ?? dn;
} }
/** /**
@@ -244,16 +225,10 @@ export function vorgesetzteAufloesen(
): GemappterEintrag[] { ): GemappterEintrag[] {
const nachDn = new Map(zusaetzlicheKennungen ?? []); const nachDn = new Map(zusaetzlicheKennungen ?? []);
for (const eintrag of eintraege) { for (const eintrag of eintraege) {
if (eintrag.dn) nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId); nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
} }
return eintraege.map(({ vorgesetzterDn, vorgesetzterDirekt, ...rest }) => ({ return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
...rest, ...rest,
/* vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
* **Die direkte Kennung gewinnt** (#028). Eine Graph-Quelle kennt den
* Vorgesetzten schon beim Lesen; eine Aufloesung ueber DNs waere dort nicht
* nur unnoetig, sondern unmoeglich — es gibt keine.
*/
vorgesetzterExterneId:
vorgesetzterDirekt ?? (vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null),
})); }));
} }
-78
View File
@@ -1,78 +0,0 @@
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodell der Anhänge (#037).
*
* **Warum das hier liegt und nicht beim Dienst.** `anhaengeDesLaufs` stand in
* `src/server/services/anhaenge.ts` und nahm nur eine Lauf-Kennung — ohne Rollen-
* und ohne Mandantensicht. Beide Aufrufer prüften den Lauf vorher, es war also
* kein Schaden; es stand aber gegen die Regel aus `CLAUDE.md` und
* `docs/architektur.md` §2: Lesemodelle liegen in `queries/*` und **wenden ihre
* Sicht selbst an, damit kein Aufrufer sie vergessen kann**.
*
* Zwei Aufrufer in zwei Schichten gab es schon (die Abarbeitungsansicht und die
* Druckansicht). Der dritte ist der, der es vergisst — und er fällt nicht auf,
* weil die bestehenden korrekt sind. Gefunden bei der Durchsicht von Phase 2.
*/
export type AnhangZeile = {
readonly id: string;
readonly schrittergebnisId: string;
readonly dateiname: string;
readonly medientyp: string;
readonly groesse: number;
readonly erstelltAm: Date;
readonly erstelltVon: string | null;
};
/**
* Die Anhänge eines Laufs, je Schrittergebnis. **Nur Metadaten** — der Inhalt
* geht über `GET /api/anhaenge/[id]`, und der prüft sein Recht selbst.
*
* Gibt eine leere Liste zurück, wenn der Lauf dem Benutzer nicht sichtbar ist.
* Leer und nicht geworfen, weil dieselbe Antwort für „keine Anhänge“ und „nicht
* sichtbar“ richtig ist: ob es den Lauf gibt, ist für jemanden, der ihn nicht
* sehen darf, keine Auskunft.
*/
export async function anhaengeDesLaufs(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly AnhangZeile[]> {
/*
* `undefined` heißt „alle Kunden“ — im Modus INTERN, für ADMIN und REDAKTEUR,
* und ohne eingerichtete Kundengruppen. Die Einschränkung sitzt deshalb in
* einem `...(sichtbar ? … : {})` und nicht in einem `in: sichtbar ?? []`: das
* zweite ergäbe für jeden Administrator eine leere Liste.
*/
const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.anhang.findMany({
where: {
laufId,
...(sichtbar ? { kundeId: { in: [...sichtbar] } } : {}),
},
orderBy: { erstelltAm: "asc" },
select: {
id: true,
schrittergebnisId: true,
dateiname: true,
medientyp: true,
groesse: true,
erstelltAm: true,
erstelltVon: { select: { name: true, email: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
schrittergebnisId: zeile.schrittergebnisId,
dateiname: zeile.dateiname,
medientyp: zeile.medientyp,
groesse: zeile.groesse,
erstelltAm: zeile.erstelltAm,
// Der Name, sonst die Mailadresse: ein Systemlauf hat keinen von beidem, und
// `null` heißt dort „von der Anwendung“ (#029 schreibt so, der Zeitplan auch).
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
}));
}
+5 -29
View File
@@ -1,8 +1,7 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen"; import { anhaengeDesLaufs } from "@/server/services/anhaenge";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe"; import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
@@ -175,8 +174,6 @@ export async function laufZumDrucken(
archiviertVon: { select: { name: true } }, archiviertVon: { select: { name: true } },
// Beim Start festgeschriebene Abschnittsauswahl (#031). // Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true, geltendeAbschnitte: true,
// Speist die Auswertung der Bedingungen (#039).
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
version: true, version: true,
@@ -186,17 +183,9 @@ export async function laufZumDrucken(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { select: { id: true, titel: true, typ: true, pflicht: true, konfig: true },
id: true,
titel: true,
typ: true,
pflicht: true,
konfig: true,
bedingung: true,
},
}, },
}, },
}, },
@@ -237,28 +226,15 @@ export async function laufZumDrucken(
const alleKommentare = await kommentareDesLaufs(laufId, benutzer); const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei // Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
// nicht in sich (#032). // nicht in sich (#032).
const alleAnhaenge = await anhaengeDesLaufs(laufId, benutzer); const alleAnhaenge = await anhaengeDesLaufs(laufId);
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis])); const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
let nummer = 0; let nummer = 0;
let anzahlSchritte = 0; let anzahlSchritte = 0;
let anzahlVomTisch = 0; let anzahlVomTisch = 0;
/* // Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031).
* Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
* (#031, #039). **Dieselbe Funktion** und nicht dieselbe Absicht: zwei
* Auswertungen liefen hier sonst auseinander, und ein Ausdruck, der andere
* Schritte zeigt als der Bildschirm, ist das Gegenteil eines Beweismittels.
*
* Ein archivierter Lauf bleibt dabei stabil: seine Variablen aendern sich
* nicht mehr, und an seinen Schrittergebnissen auch nichts
* (`laufMussVeraenderbarSein`). Was er zeigt, ist also genau das, was bei
* seinem Abschluss galt.
*/
const geltende = geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebungBauen(lauf.variablen, lauf.ergebnisse),
);
const abschnitte = geltende.map((abschnitt, index) => { const abschnitte = geltende.map((abschnitt, index) => {
const schritte = abschnitt.schritte.map((schritt) => { const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1; nummer += 1;
+2 -34
View File
@@ -2,7 +2,6 @@ import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell"; import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau"; import { STANDARD_SHELL } from "@/lib/editor-aufbau";
@@ -50,11 +49,6 @@ export type LaufZeile = {
readonly kundeName: string; readonly kundeName: string;
readonly erstelltAm: Date; readonly erstelltAm: Date;
readonly abgeschlossenAm: Date | null; readonly abgeschlossenAm: Date | null;
/** Freigabe-Zustand (#040). `freigabePflicht` kommt von der Version. */
readonly freigabePflicht: boolean;
readonly freigegebenVon: string | null;
readonly freigegebenAm: Date | null;
readonly freigabeNotiz: string | null;
readonly anzahlSchritte: number; readonly anzahlSchritte: number;
readonly anzahlErledigt: number; readonly anzahlErledigt: number;
}; };
@@ -79,17 +73,12 @@ const ZEILEN_AUSWAHL = {
faelligAm: true, faelligAm: true,
erstelltAm: true, erstelltAm: true,
abgeschlossenAm: true, abgeschlossenAm: true,
freigegebenAm: true,
freigabeNotiz: true,
freigegebenVon: { select: { name: true, email: true } },
mitarbeiterName: true, mitarbeiterName: true,
kunde: { select: { name: true } }, kunde: { select: { name: true } },
zugewiesenAn: { select: { name: true } }, zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } }, mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } }, geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
select: { version: true, freigabePflicht: true, vorlage: { select: { name: true } } },
},
ergebnisse: { select: { zustand: true } }, ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect; } satisfies Prisma.LaufSelect;
@@ -123,11 +112,6 @@ function zeileBauen(zeile: Rohzeile): LaufZeile {
kundeName: zeile.kunde.name, kundeName: zeile.kunde.name,
erstelltAm: zeile.erstelltAm, erstelltAm: zeile.erstelltAm,
abgeschlossenAm: zeile.abgeschlossenAm, abgeschlossenAm: zeile.abgeschlossenAm,
freigabePflicht: zeile.vorlagenVersion.freigabePflicht,
// Name, sonst Mailadresse – wie ueberall, wo ein Mensch genannt wird.
freigegebenVon: zeile.freigegebenVon?.name ?? zeile.freigegebenVon?.email ?? null,
freigegebenAm: zeile.freigegebenAm,
freigabeNotiz: zeile.freigabeNotiz,
anzahlSchritte: zeile.ergebnisse.length, anzahlSchritte: zeile.ergebnisse.length,
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length, anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
}; };
@@ -255,8 +239,6 @@ export async function laufZumAbarbeiten(
select: { select: {
// Beim Start festgeschriebene Abschnittsauswahl (#031). // Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true, geltendeAbschnitte: true,
// Speist die Auswertung der Bedingungen (#039).
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -264,7 +246,6 @@ export async function laufZumAbarbeiten(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { select: {
@@ -275,7 +256,6 @@ export async function laufZumAbarbeiten(
pflicht: true, pflicht: true,
dokuUrl: true, dokuUrl: true,
konfig: true, konfig: true,
bedingung: true,
}, },
}, },
}, },
@@ -317,19 +297,7 @@ export async function laufZumAbarbeiten(
let anzahlPflicht = 0; let anzahlPflicht = 0;
let anzahlPflichtOffen = 0; let anzahlPflichtOffen = 0;
/* const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
* Zwei Filter uebereinander, in dieser Reihenfolge: die beim Start
* festgeschriebene Abteilungsauswahl (#031), dann die laufend ausgewerteten
* Bedingungen (#039). Die Begruendung steht in `geltendeStruktur`.
*
* **Die Nummerierung kommt danach.** `01 · Vorbereitung` zaehlt die Abschnitte,
* die der Techniker **sieht** — ein ausgeblendeter hinterlaesst keine Luecke,
* und gespeichert wird die Nummer nie (CLAUDE.md, „Gestaltung“).
*/
const geltende = geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebungBauen(lauf.variablen, lauf.ergebnisse),
);
const abschnitte = geltende.map((abschnitt, index) => { const abschnitte = geltende.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = []; const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
-162
View File
@@ -1,162 +0,0 @@
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;
}
-26
View File
@@ -1,7 +1,6 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungZuPfad } from "@/lib/bedingungen";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format"; import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -181,8 +180,6 @@ export type VersionsZeile = {
readonly version: number; readonly version: number;
readonly status: VersionStatus; readonly status: VersionStatus;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
readonly veroeffentlichtAm: Date | null; readonly veroeffentlichtAm: Date | null;
readonly veroeffentlichtVon: string | null; readonly veroeffentlichtVon: string | null;
readonly anzahlLaeufe: number; readonly anzahlLaeufe: number;
@@ -208,7 +205,6 @@ export async function versionsHistorie(
version: true, version: true,
status: true, status: true,
aenderungsnotiz: true, aenderungsnotiz: true,
freigabePflicht: true,
veroeffentlichtAm: true, veroeffentlichtAm: true,
veroeffentlichtVon: { select: { name: true } }, veroeffentlichtVon: { select: { name: true } },
_count: { select: { laeufe: true } }, _count: { select: { laeufe: true } },
@@ -219,7 +215,6 @@ export async function versionsHistorie(
version: version.version, version: version.version,
status: version.status, status: version.status,
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
veroeffentlichtAm: version.veroeffentlichtAm, veroeffentlichtAm: version.veroeffentlichtAm,
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null, veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
anzahlLaeufe: version._count.laeufe, anzahlLaeufe: version._count.laeufe,
@@ -238,8 +233,6 @@ export type VersionMitAufbau = {
*/ */
readonly stand: Date; readonly stand: Date;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
readonly struktur: Struktur; readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */ /** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[]; readonly platzhalter: readonly string[];
@@ -263,22 +256,6 @@ export async function versionMitAufbau(
}); });
if (!version) return undefined; if (!version) return undefined;
/*
* Kennung → Titel-Pfad, fuer die Bedingungen (#039). Der Editor sieht und
* schickt **Titel**, nie Kennungen: `entwurfSpeichern` loescht und schreibt
* neu, eine Kennung ueberlebte das nicht.
*/
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
for (const abschnitt of version.abschnitte) {
for (const schritt of abschnitt.schritte) {
pfade.set(schritt.id, { abschnitt: abschnitt.titel, schritt: schritt.titel });
}
}
const alsPfad = (roh: unknown) => {
const bedingung = bedingungZuPfad(roh, pfade);
return bedingung ? { bedingung } : {};
};
const struktur: Struktur = { const struktur: Struktur = {
variablen: version.variablen.map((variable) => ({ variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel, schluessel: variable.schluessel,
@@ -291,14 +268,12 @@ export async function versionMitAufbau(
titel: abschnitt.titel, titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen, nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }), ...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
...alsPfad(abschnitt.bedingung),
schritte: abschnitt.schritte.map((schritt) => ({ schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel, titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }), ...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
typ: schritt.typ, typ: schritt.typ,
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: (schritt.konfig ?? {}) as Record<string, unknown>, konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
...alsPfad(schritt.bedingung),
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }), ...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
})), })),
})), })),
@@ -312,7 +287,6 @@ export async function versionMitAufbau(
status: version.status, status: version.status,
stand: version.geaendertAm, stand: version.geaendertAm,
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
struktur, struktur,
platzhalter: platzhalterDerStruktur(struktur), platzhalter: platzhalterDerStruktur(struktur),
}; };
-115
View File
@@ -1,115 +0,0 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { SCIM_TYP, fehlerDarstellen } from "@/lib/scim";
import { ScimFehler, quelleZuToken } from "@/server/services/scim";
/**
* Was jeder SCIM-Handler gemeinsam braucht (#029).
*
* **Jeder Handler prüft selbst** — die Hülle unter `(app)` liegt hier nicht
* darüber, und der Proxy lässt `/api/scim` absichtlich ohne Cookie durch
* (CLAUDE.md). Diese Datei ist die eine Stelle, an der das Token zur Quelle
* wird; jeder Handler beginnt mit `quelleAusAnfrage`.
*/
/** Jede Antwort trägt `application/scim+json` — nicht `application/json`. */
export function scimAntwort(daten: unknown, status = 200, kopfzeilen: HeadersInit = {}): Response {
return new Response(JSON.stringify(daten), {
status,
headers: {
"Content-Type": SCIM_TYP,
// Nichts zwischenspeichern: eine Zuweisung ist kein Inhalt, der altert.
"Cache-Control": "no-store",
...kopfzeilen,
},
});
}
export function scimFehler(status: number, detail: string, scimType?: string): Response {
const kopfzeilen: HeadersInit =
status === 401
? /*
* `WWW-Authenticate` bei 401, weil RFC 7235 es verlangt und manche
* Anbieter daran erkennen, dass ein Token erwartet wird — ohne die
* Kopfzeile melden sie „unbekannter Fehler“ statt „Token fehlt“.
*/
{ "WWW-Authenticate": 'Bearer realm="Laufzettel SCIM"' }
: {};
return scimAntwort(fehlerDarstellen(status, detail, scimType), status, kopfzeilen);
}
/** Aus einem geworfenen Fehler eine SCIM-Antwort. */
export function alsScimAntwort(fehler: unknown): Response {
if (fehler instanceof ScimFehler) {
return scimFehler(fehler.status, fehler.message, fehler.scimType);
}
/*
* Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht
* ins Protokoll des Prozesses und **nicht** in die Antwort: eine
* Datenbankmeldung nennt Host, Port und Benutzernamen (#025).
*/
console.error(
"SCIM-Anfrage fehlgeschlagen:",
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return scimFehler(500, "Unerwarteter Fehler. Einzelheiten stehen im Protokoll des Dienstes.");
}
/**
* Das Bearer-Token aus der Anfrage.
*
* Verglichen wird das Schema ohne Rücksicht auf Groß- und Kleinschreibung: RFC
* 7235 schreibt das so vor, und die Anbieter schicken `Bearer` wie `bearer`.
*/
export function tokenAus(anfrage: Request): string | undefined {
const kopf = anfrage.headers.get("authorization")?.trim();
if (!kopf) return undefined;
const trenner = kopf.indexOf(" ");
if (trenner < 0) return undefined;
if (kopf.slice(0, trenner).toLowerCase() !== "bearer") return undefined;
return kopf.slice(trenner + 1).trim() || undefined;
}
export type ScimZugang =
{ readonly quelle: Verzeichnisquelle; readonly basis: string } | { readonly antwort: Response };
/**
* Die Quelle zu dieser Anfrage – oder eine fertige 401.
*
* **Dieselbe Antwort für „kein Token“ und „falsches Token“.** Die
* Unterscheidung wäre eine Auskunft darüber, ob ein geratenes Token die richtige
* Form hat.
*/
export async function quelleAusAnfrage(anfrage: Request): Promise<ScimZugang> {
const token = tokenAus(anfrage);
if (!token) {
return { antwort: scimFehler(401, "Dieser Endpunkt verlangt ein Bearer-Token.") };
}
const quelle = await quelleZuToken(token);
if (!quelle) {
return { antwort: scimFehler(401, "Das Token gilt nicht (mehr).") };
}
/*
* `meta.location` muss eine **absolute** Adresse sein. Gebaut wird sie über
* `oeffentlicheAdresse`, damit hinter einem Reverse Proxy die öffentliche
* Adresse herauskommt und nicht `127.0.0.1:3000` — ein Anbieter, der dieser
* Adresse folgt, landete sonst bei sich selbst.
*/
const basis = oeffentlicheAdresse("/api/scim/v2", new URL(anfrage.url).origin)
.toString()
.replace(/\/+$/, "");
return { quelle, basis };
}
/** Den Rumpf als JSON lesen – oder eine fertige 400. */
export async function rumpfLesen(
anfrage: Request,
): Promise<{ readonly rumpf: unknown } | { readonly antwort: Response }> {
try {
return { rumpf: await anfrage.json() };
} catch {
return { antwort: scimFehler(400, "Der Rumpf ist kein gültiges JSON.", "invalidSyntax") };
}
}
+20 -47
View File
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/db";
import { satzEnde } from "@/lib/satzzeichen"; import { satzEnde } from "@/lib/satzzeichen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant"; import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser"; import { eintraegeStroemen } from "@/server/ldap/leser";
import { fehlerMeldung } from "@/server/ldap/verbindung"; import { fehlerMeldung } from "@/server/ldap/verbindung";
import { dnSchluessel, type RoherEintrag } from "@/server/ldap/zuordnung"; import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis"; import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
import { mitSperre } from "@/server/services/sperre"; import { mitSperre } from "@/server/services/sperre";
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen"; import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
@@ -105,19 +105,11 @@ export async function faelligeQuellen(
* `psql` und eine Migration schon. * `psql` und eine Migration schon.
*/ */
const eigene = await eigeneOrganisation(); const eigene = await eigeneOrganisation();
/*
* **Eine SCIM-Quelle wird nie abgeglichen** (#029). Bei ihr holt Laufzettel
* nicht, sie bekommt geschickt — und der Zeitplan haette jede Minute versucht
* zu lesen, waere an der Weiche gescheitert und haette „kann noch nicht
* abgeglichen werden“ an die Quelle geschrieben. Dauerhaft ein Fehler, der
* keiner ist, und `letzterAbgleichFehler` ist in der Oberflaeche sichtbar.
*/
return prisma.$queryRaw<{ id: string; name: string }[]>` return prisma.$queryRaw<{ id: string; name: string }[]>`
SELECT "id", "name" SELECT "id", "name"
FROM "Verzeichnisquelle" FROM "Verzeichnisquelle"
WHERE "aktiv" = true WHERE "aktiv" = true
AND "kundeId" = ${eigene.id} AND "kundeId" = ${eigene.id}
AND "art" <> 'SCIM'
AND ("letzterAbgleichAm" IS NULL AND ("letzterAbgleichAm" IS NULL
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt}) OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
@@ -155,16 +147,7 @@ async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): P
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */ /** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
type OffenerVorgesetzter = { type OffenerVorgesetzter = {
readonly externeId: string; readonly externeId: string;
/** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */ readonly vorgesetzterDn: string;
readonly vorgesetzterDn: string | null;
/**
* Bei Graph: die Kennung steht schon fest (#028).
*
* Beides als zwei Felder und nicht als eines mit zwei Bedeutungen: ein DN und
* eine GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal
* das andere traegt, verwechselt irgendwann jemand.
*/
readonly vorgesetzterDirekt: string | null;
readonly vorherigeKennung: string | null; readonly vorherigeKennung: string | null;
readonly neuAngelegt: boolean; readonly neuAngelegt: boolean;
}; };
@@ -224,21 +207,21 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
let bindPasswort: string | undefined; let bindPasswort: string | undefined;
try { try {
/* const zugang = zugangAus(quelle);
* Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem bindPasswort = zugang.bindPasswort;
* Filtern der Fehlermeldung. Eine Graph-Quelle hat keines; ihr Geheimnis
* erscheint nirgends in einer Meldung, weil `tokenHolen` die ausfuehrliche
* Antwort von Entra ID selbst ins Prozessprotokoll schreibt (#028).
*/
bindPasswort = quelle.art === "LDAP" ? zugangAus(quelle).bindPasswort : undefined;
const kennungNachDn = new Map<string, string>(); const kennungNachDn = new Map<string, string>();
const offene: OffenerVorgesetzter[] = []; const offene: OffenerVorgesetzter[] = [];
const bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => { const bilanz = await eintraegeStroemen(
zugang,
{
basisDn: quelle.basisDn,
filter: quelle.filter,
zuordnung: zuordnungLesen(quelle.attributZuordnung),
},
async (eintrag) => {
verarbeitet += 1; verarbeitet += 1;
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
let vorhanden = await prisma.mitarbeiter.findFirst({ let vorhanden = await prisma.mitarbeiter.findFirst({
where: { quelleId, externeId: eintrag.externeId }, where: { quelleId, externeId: eintrag.externeId },
}); });
@@ -290,11 +273,10 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
}, },
}); });
angelegt += 1; angelegt += 1;
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) { if (eintrag.vorgesetzterDn) {
offene.push({ offene.push({
externeId: eintrag.externeId, externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn, vorgesetzterDn: eintrag.vorgesetzterDn,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: null, vorherigeKennung: null,
neuAngelegt: true, neuAngelegt: true,
}); });
@@ -304,9 +286,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert // Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
// geleert. Steht einer, entscheidet der zweite Durchgang. // geleert. Steht einer, entscheidet der zweite Durchgang.
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen. const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
const vorgesetztenLeeren =
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === null;
if ( if (
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich // Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf. // als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
@@ -358,13 +338,13 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
if (!vorgesetztenLeeren) { if (!vorgesetztenLeeren) {
offene.push({ offene.push({
externeId: eintrag.externeId, externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn, vorgesetzterDn: eintrag.vorgesetzterDn as string,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: vorhanden.vorgesetzterExterneId, vorherigeKennung: vorhanden.vorgesetzterExterneId,
neuAngelegt: false, neuAngelegt: false,
}); });
} }
}); },
);
gelesen = bilanz.gesamtGelesen; gelesen = bilanz.gesamtGelesen;
// --- Zweiter Durchgang: Vorgesetzte -------------------------------------- // --- Zweiter Durchgang: Vorgesetzte --------------------------------------
@@ -374,14 +354,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von // jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
// Hand angelegte Person konnte keinen Vorgesetzten haben. // Hand angelegte Person konnte keinen Vorgesetzten haben.
for (const offen of offene) { for (const offen of offene) {
/* const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
* **Die direkte Kennung gewinnt** (#028). Bei Graph steht sie schon fest;
* die DN-Tabelle ist dort leer, und ein Nachschlagen darin ergaebe
* `null` — also keinen Vorgesetzten, obwohl einer bekannt ist.
*/
const kennung =
offen.vorgesetzterDirekt ??
(offen.vorgesetzterDn ? (kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null) : null);
if (kennung === offen.vorherigeKennung) continue; if (kennung === offen.vorherigeKennung) continue;
const vorgesetzter = const vorgesetzter =
kennung === null kennung === null
+36
View File
@@ -213,6 +213,42 @@ export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenu
await rm(dateiPfad(anhang), { force: true }); await rm(dateiPfad(anhang), { force: true });
} }
/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */
export async function anhaengeDesLaufs(laufId: string): Promise<
readonly {
readonly id: string;
readonly schrittergebnisId: string;
readonly dateiname: string;
readonly medientyp: string;
readonly groesse: number;
readonly erstelltAm: Date;
readonly erstelltVon: string | null;
}[]
> {
const zeilen = await prisma.anhang.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
select: {
id: true,
schrittergebnisId: true,
dateiname: true,
medientyp: true,
groesse: true,
erstelltAm: true,
erstelltVon: { select: { name: true, email: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
schrittergebnisId: zeile.schrittergebnisId,
dateiname: zeile.dateiname,
medientyp: zeile.medientyp,
groesse: zeile.groesse,
erstelltAm: zeile.erstelltAm,
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
}));
}
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */ /** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
export function anhangPfad(anhang: { export function anhangPfad(anhang: {
readonly id: string; readonly id: string;
+6 -71
View File
@@ -2,7 +2,6 @@ import { createTransport } from "nodemailer";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { RECHTE } from "@/server/auth/rollen";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen"; import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur"; import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
@@ -40,15 +39,10 @@ export class MeldungsFehler extends Error {
/** /**
* Wie lange zwischen zwei Versuchen gewartet wird, in Minuten. * Wie lange zwischen zwei Versuchen gewartet wird, in Minuten.
* *
* **Vier Wartezeiten ergeben fuenf Versuche**, nicht vier: gewartet wird erst * Die Laenge der Liste ist zugleich die Zahl der Versuche: nach dem vierten
* *nach* einem Fehlschlag, der fuenfte Versuch findet also keine Wartezeit mehr * Fehlschlag wird aufgegeben. Wachsend, weil die haeufigste Ursache ein
* vor und wird aufgegeben. Der Kommentar hier behauptete „nach dem vierten“, * Mailserver ist, der gerade neu startet — eine Minute spaeter geht es meist
* waehrend `naechsterVersuch` weiter unten das Richtige sagt — und das ist der * wieder, und nach zwei Stunden ist es ein Fall fuer einen Menschen.
* Kommentar, den man zuerst liest (Reviewbefund zu #029).
*
* Wachsend, weil die haeufigste Ursache ein Mailserver ist, der gerade neu
* startet — eine Minute spaeter geht es meist wieder, und nach zwei Stunden ist
* es ein Fall fuer einen Menschen.
*/ */
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const; export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
@@ -338,26 +332,8 @@ function appUrl(): string {
return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, ""); return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, "");
} }
/**
* Die Pfade, auf die eine Meldung zeigen kann — **an einer Stelle**.
*
* Sie stehen hier als Konstanten, weil ein Pfad in einer Mail nicht nachgesehen
* wird: er wird geklickt. `/verwaltung/verzeichnis` stand hier und war ein 404 —
* `verwaltung` ist der Name des **Rechts**, die Seite liegt unter `/admin/`
* (Reviewbefund zu #029). Aufgefallen ist das niemandem, weil der Test nur
* prueft, dass der **uebergebene** Link im Rumpf steht.
*
* `tests/benachrichtigungen.test.ts` prueft jeden dieser Pfade jetzt gegen die
* Routen unter `src/app/` — ein Pfad, den es nicht gibt, faellt beim Testlauf
* auf und nicht im Postfach eines Administrators.
*/
export const MELDUNGS_PFADE = {
lauf: "/laeufe",
verzeichnis: "/admin/verzeichnis",
} as const;
function laufLink(laufId: string): string { function laufLink(laufId: string): string {
return `${appUrl()}${MELDUNGS_PFADE.lauf}/${laufId}`; return `${appUrl()}/laeufe/${laufId}`;
} }
/** /**
@@ -383,41 +359,10 @@ async function laufEmpfaenger(
handelnderId: string | null, handelnderId: string | null,
): Promise<{ id: string; email: string; name: string }[]> { ): Promise<{ id: string; email: string; name: string }[]> {
const kennungen = new Set<string>(); const kennungen = new Set<string>();
if (anlass === Meldeanlass.LAUF_FREIGABE_OFFEN) {
/*
* **Ein anderer Empfaengerkreis als bei allen anderen Anlaessen** (#040): nicht
* der Zustaendige, sondern **jeder mit dem Recht `freigeben`**. Eine Freigabe
* ist die Aufgabe eines Dritten; ihm die Meldung nicht zu schicken hiesse,
* dass ein Lauf auf etwas wartet, von dem niemand weiss — und genau das macht
* die ganze Regel wertlos.
*
* Wer **gearbeitet** hat, bekommt sie nicht: er darf ohnehin nicht freigeben,
* und eine Aufforderung zu etwas, das der Dienst abweist, ist schlimmer als
* keine.
*/
const berechtigte = await prisma.benutzer.findMany({
where: { aktiv: true, meldungenAktiv: true, rolle: { in: [...RECHTE.freigeben] } },
select: { id: true },
});
const bearbeiter = new Set<string>(lauf.erstelltVonId ? [lauf.erstelltVonId] : []);
for (const zeile of await prisma.schrittergebnis.findMany({
where: { laufId: lauf.id, aktualisiertVonId: { not: null } },
select: { aktualisiertVonId: true },
distinct: ["aktualisiertVonId"],
})) {
if (zeile.aktualisiertVonId) bearbeiter.add(zeile.aktualisiertVonId);
}
for (const person of berechtigte) {
if (!bearbeiter.has(person.id)) kennungen.add(person.id);
}
} else {
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId); if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) { if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
kennungen.add(lauf.erstelltVonId); kennungen.add(lauf.erstelltVonId);
} }
}
if (handelnderId) kennungen.delete(handelnderId); if (handelnderId) kennungen.delete(handelnderId);
if (kennungen.size === 0) return []; if (kennungen.size === 0) return [];
@@ -479,16 +424,6 @@ async function einreihen(
* `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“ * `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“
* genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das * genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das
* waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung. * waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung.
*
* **Hier haelt nur die Datenbank, und das ist Absicht.** Eine zweite Pruefung
* im Dienst („gibt es die schon?“) waere ein Wettlauf: zwei Instanzen hinter
* einem Lastverteiler fragen beide und bekommen beide „nein“. Die Migration
* `20261005110000_benachrichtigungen` sagt im Kommentar das Gegenteil — sie
* verspricht eine Dienstpruefung „mit besserer Meldung“, die es nicht gibt und
* nicht geben soll (Reviewbefund zu #029). Eine angewandte Migration wird
* nicht nachtraeglich umgeschrieben, so wie eine veroeffentlichte
* `VorlagenVersion` nicht; die Richtigstellung steht deshalb hier, wo sie
* gelesen wird, und in `tests/datenbankregeln.test.ts`.
*/ */
const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true }); const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true });
return ergebnis.count; return ergebnis.count;
@@ -563,7 +498,7 @@ export async function abgleichMeldungEinreihen(quelleId: string, grund: string):
const webhook = await webhookZugang(); const webhook = await webhookZugang();
if (admins.length === 0 && !webhook) return 0; if (admins.length === 0 && !webhook) return 0;
const link = `${appUrl()}${MELDUNGS_PFADE.verzeichnis}`; const link = `${appUrl()}/verwaltung/verzeichnis`;
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link); const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
return einreihen( return einreihen(
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN, Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
+7 -258
View File
@@ -2,7 +2,6 @@ import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen"; import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
import type { LaufAnlass } from "@/lib/meldungen"; import type { LaufAnlass } from "@/lib/meldungen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
@@ -567,14 +566,7 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
vorlage: version.vorlagenName, vorlage: version.vorlagenName,
vorlagenVersionId: version.id, vorlagenVersionId: version.id,
status: LaufStatus.OFFEN, status: LaufStatus.OFFEN,
/* anzahlSchritte: version.schrittIds.length,
* Die Schritte **dieses Laufs**, nicht die der Version. Seit #031
* fallen abteilungsfremde Abschnitte heraus; `version.schrittIds.length`
* behauptete deshalb mehr Schritte, als der Lauf hat (Reviewbefund zu
* #029). Das Protokoll ist seit #023 ansehbar — eine Zahl darin, die
* nicht zur Liste darunter passt, kostet jemanden eine Suche.
*/
anzahlSchritte: anzulegen.length,
zugewiesenAnId, zugewiesenAnId,
}, },
}, },
@@ -704,7 +696,6 @@ export async function offenePflichtschritte(
where: { id: laufId }, where: { id: laufId },
select: { select: {
geltendeAbschnitte: true, geltendeAbschnitte: true,
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -712,11 +703,10 @@ export async function offenePflichtschritte(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
where: { pflicht: true }, where: { pflicht: true },
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { id: true, titel: true, bedingung: true }, select: { id: true, titel: true },
}, },
}, },
}, },
@@ -729,20 +719,11 @@ export async function offenePflichtschritte(
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand])); const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
const offen: OffenerPflichtschritt[] = []; const offen: OffenerPflichtschritt[] = [];
/* /*
* Nur die geltenden Abschnitte (#031) und nur, was seine Bedingung erfuellt * Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf
* (#039). Ein ausgefilterter oder ausgeblendeter Pflichtschritt darf den * den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
* Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
* sich nur, was in der Abarbeitung steht. * sich nur, was in der Abarbeitung steht.
*
* **Die Reihenfolge der beiden Filter steht in `geltendeStruktur`**, und sie
* ist dort begruendet: erst die festgeschriebene Abteilungsauswahl, dann die
* laufende Auswertung.
*/ */
const umgebung = umgebungBauen(lauf.variablen, lauf.ergebnisse); for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) {
for (const abschnitt of geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebung,
)) {
for (const schritt of abschnitt.schritte) { for (const schritt of abschnitt.schritte) {
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt. // Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN; const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
@@ -805,174 +786,6 @@ export async function abschlussPruefenNachAenderung(
}); });
} }
/* ------------------------------------------------------------------ */
/* Vier-Augen-Freigabe (#040) */
/* ------------------------------------------------------------------ */
/**
* Hat dieser Benutzer an dem Lauf **gearbeitet**?
*
* **Die eine Stelle, an der die Vier-Augen-Regel haengt** — wie
* `laufMussVeraenderbarSein` fuer das Archiv. Sie wird vor jeder Freigabe und
* vor jeder Zurueckweisung gerufen; zwei Stellen liefen auseinander, und die
* erste, die es vergisst, hebt die Regel auf.
*
* Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
* **Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
* Zustaendige (`zugewiesenAnId`) gilt bewusst **nicht** als Bearbeiter: eine
* Zuweisung ist noch keine Arbeit, und wer zugewiesen wurde, ohne etwas zu tun,
* soll freigeben koennen, wenn es jemand anderes abgearbeitet hat.
*/
async function hatDaranGearbeitet(
tx: Prisma.TransactionClient,
laufId: string,
benutzerId: string,
): Promise<boolean> {
const lauf = await tx.lauf.findUniqueOrThrow({
where: { id: laufId },
select: { erstelltVonId: true, abgeschlossenVonId: true },
});
if (lauf.erstelltVonId === benutzerId) return true;
if (lauf.abgeschlossenVonId === benutzerId) return true;
const eigene = await tx.schrittergebnis.count({
where: { laufId, aktualisiertVonId: benutzerId },
});
return eigene > 0;
}
/** Verlangt die Version dieses Laufs eine Freigabe? */
async function freigabePflichtig(tx: Prisma.TransactionClient, laufId: string): Promise<boolean> {
const lauf = await tx.lauf.findUniqueOrThrow({
where: { id: laufId },
select: { vorlagenVersion: { select: { freigabePflicht: true } } },
});
return lauf.vorlagenVersion.freigabePflicht;
}
/**
* Gibt einen Lauf frei.
*
* Verlangt das Recht `freigeben` — und dass der Freigebende **nicht** daran
* gearbeitet hat. Beides wird auch dann geprueft, wenn die Version gar keine
* Freigabe fordert: eine Freigabe an einem Lauf, der keine braucht, ist kein
* Fehler, aber sie soll denselben Regeln folgen. Sonst waere sie ein Weg, einen
* Freigeber in die Akte zu schreiben, der dort nicht hingehoert.
*/
export async function laufFreigeben(
laufId: string,
benutzer: HandelnderBenutzer,
notiz?: string,
): Promise<Lauf> {
if (!darf(benutzer.rolle, "freigeben")) {
throw new LaufFehler(
`Einen Lauf freizugeben verlangt das Recht "freigeben"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (lauf.freigegebenVonId) {
throw new LaufFehler("Dieser Lauf ist bereits freigegeben.");
}
if (await hatDaranGearbeitet(tx, laufId, benutzer.id)) {
throw new LaufFehler(
"Du hast an diesem Lauf gearbeitet und kannst ihn deshalb nicht selbst freigeben. " +
"Das ist der Sinn der Vier-Augen-Regel — es braucht jemand anderen mit dem Recht " +
"zur Freigabe.",
);
}
const jetzt = new Date();
const nachher = await tx.lauf.update({
where: { id: laufId },
data: {
freigegebenVonId: benutzer.id,
freigegebenAm: jetzt,
// Bei einer Freigabe freiwillig; eine vorherige Zurueckweisung wird damit
// ueberschrieben, und das ist richtig: die letzte Notiz ist die geltende.
freigabeNotiz: notiz?.trim() || null,
},
});
await tx.protokoll.create({
data: {
laufId,
entitaet: "Lauf",
entitaetId: laufId,
aktion: "Lauf freigegeben",
benutzerId: benutzer.id,
nachher: { freigegebenAm: jetzt.toISOString(), notiz: notiz?.trim() || null },
},
});
return nachher;
});
}
/**
* Weist einen Lauf zurueck. Die Notiz ist **Pflicht**.
*
* Kein eigener `LaufStatus`: der Lauf geht zurueck auf `IN_ARBEIT`, und die
* Begruendung steht in `freigabeNotiz` und im Protokoll. Ein fuenfter Status
* waere eine Aenderung an einem Enum, das vier Jahre halten soll, und jeder
* Codepfad, der heute vier Faelle kennt, muesste ihn lernen.
*
* **Eine Zurueckweisung ohne Begruendung ist keine.** Dieselbe Regel wie bei
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` (CLAUDE.md): wer etwas zurueckgibt,
* sagt warum — sonst steht der naechste vor derselben Frage.
*/
export async function laufZurueckweisen(
laufId: string,
benutzer: HandelnderBenutzer,
notiz: string,
): Promise<Lauf> {
if (!darf(benutzer.rolle, "freigeben")) {
throw new LaufFehler(
`Einen Lauf zurueckzuweisen verlangt das Recht "freigeben"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
const begruendung = notiz.trim();
if (!begruendung) {
throw new LaufFehler(
"Eine Zurückweisung braucht eine Begründung. Ohne sie steht der Bearbeiter vor derselben " +
"Frage wie vorher.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (await hatDaranGearbeitet(tx, laufId, benutzer.id)) {
throw new LaufFehler(
"Du hast an diesem Lauf gearbeitet und kannst ihn deshalb nicht selbst zurückweisen.",
);
}
const nachher = await tx.lauf.update({
where: { id: laufId },
data: {
// Eine Zurueckweisung nimmt eine erteilte Freigabe zurueck.
freigegebenVonId: null,
freigegebenAm: null,
freigabeNotiz: begruendung,
status: LaufStatus.IN_ARBEIT,
abgeschlossenAm: null,
abgeschlossenVonId: null,
},
});
await tx.protokoll.create({
data: {
laufId,
entitaet: "Lauf",
entitaetId: laufId,
aktion: "Lauf zurückgewiesen",
benutzerId: benutzer.id,
vorher: { status: lauf.status },
nachher: { status: LaufStatus.IN_ARBEIT, notiz: begruendung },
},
});
return nachher;
});
}
/** /**
* Schliesst einen Lauf ab. * Schliesst einen Lauf ab.
* *
@@ -984,38 +797,14 @@ export async function laufZurueckweisen(
* abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten * abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten
* Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben. * Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben.
*/ */
/** export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
* Der eigentliche Abschluss, in einer Transaktion. const abgeschlossen = await prisma.$transaction(async (tx) => {
*
* Getrennt von `laufAbschliessen`, weil die Aufforderung an die
* Freigabeberechtigten **ausserhalb** der Transaktion entstehen muss: innerhalb
* wuerde sie mit dem Fehler zurueckgerollt, und dann wartet der Lauf auf etwas,
* von dem niemand weiss.
*/
async function abschlussVersuchen(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<{ lauf: Lauf; warSchonFertig: boolean }> {
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer); const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true }; if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true };
const offen = await offenePflichtschritte(tx, laufId); const offen = await offenePflichtschritte(tx, laufId);
if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen)); if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen));
/*
* **Die Freigabe wird vor dem Abschluss geprueft, nicht danach** (#040):
* ein abgeschlossener Lauf ohne Freigabe waere genau der Zustand, den die
* Regel verhindern soll, und ihn wieder aufzubrechen verlangte
* `archivVerwalten`.
*/
if (!lauf.freigegebenVonId && (await freigabePflichtig(tx, laufId))) {
throw new FreigabeFehlt(
"Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen werden " +
"kann. Die Freigabeberechtigten sind benachrichtigt.",
);
}
const jetzt = new Date(); const jetzt = new Date();
const nachher = await tx.lauf.update({ const nachher = await tx.lauf.update({
where: { id: laufId }, where: { id: laufId },
@@ -1043,46 +832,6 @@ async function abschlussVersuchen(
}); });
return { lauf: nachher, warSchonFertig: false }; return { lauf: nachher, warSchonFertig: false };
}); });
}
/**
* Der Abschluss scheitert an der fehlenden Freigabe (#040).
*
* Ein eigener Typ, damit der Aufrufer **ausserhalb** der Transaktion erkennt,
* dass genau dieser Fall eingetreten ist: die Aufforderung an die
* Freigabeberechtigten darf nicht mit der Transaktion zurueckgerollt werden,
* und ein Zeichenkettenvergleich auf die Meldung waere eine Zusage ueber ihren
* Wortlaut.
*
* Erbt von `LaufFehler`, damit jeder bestehende Aufrufer ihn weiter faengt.
*/
export class FreigabeFehlt extends LaufFehler {
constructor(nachricht: string) {
super(nachricht);
this.name = "FreigabeFehlt";
}
}
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
let abgeschlossen: { lauf: Lauf; warSchonFertig: boolean };
try {
abgeschlossen = await abschlussVersuchen(laufId, benutzer);
} catch (fehler) {
if (fehler instanceof FreigabeFehlt) {
/*
* **Hier entsteht die Aufforderung**, und zwar genau hier: der Techniker
* sagt mit dem Abschlussversuch „ich bin fertig“. Ein eigener Knopf
* „Freigabe anfordern“ waere ein Schritt mehr, den jemand vergisst — und
* ein Lauf, der auf etwas wartet, von dem niemand weiss, macht die ganze
* Regel wertlos.
*
* Der partielle Unique-Index `Zustellung_freigabe_einmal` sorgt dafuer,
* dass mehrere Versuche nicht mehrere Mails ergeben.
*/
await meldenOhneBruch(Meldeanlass.LAUF_FREIGABE_OFFEN, laufId, benutzer.id);
}
throw fehler;
}
/* /*
* Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf * Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf
-478
View File
@@ -1,478 +0,0 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { hash, verify } from "@node-rs/argon2";
import type { Mitarbeiter, Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { ScimEingabe, ScimPerson } from "@/lib/scim";
import { darf } from "@/server/auth/rollen";
import { eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
/**
* SCIM: Token und Schreiboperationen (#029).
*
* Der Abgleich holt, SCIM bekommt — aber **danach gelten dieselben Regeln**:
* anlegen, übernehmen, aktualisieren, deaktivieren, **nie löschen**. Ein
* `DELETE` vom Anbieter setzt `aktiv = false`; die Zeile bleibt, weil Läufe und
* Geräte an ihr hängen.
*
* **Das Token ist der einzige Mandantenbeweis, den ein Push mitbringt.** Es
* identifiziert die Quelle, und die Quelle den Kunden. Deshalb steht es als
* argon2-Hash in der Datenbank und wird genau einmal angezeigt.
*/
export class ScimFehler extends Error {
readonly status: number;
readonly scimType: string | undefined;
constructor(status: number, meldung: string, scimType?: string) {
super(meldung);
this.name = "ScimFehler";
this.status = status;
this.scimType = scimType;
}
}
/* ------------------------------------------------------------------ */
/* Token */
/* ------------------------------------------------------------------ */
/**
* Wie lang ein Token ist.
*
* 32 Byte aus `randomBytes`, base64url — 43 Zeichen. Das ist weit jenseits
* dessen, was sich raten lässt, und kurz genug, dass es in ein Formularfeld
* eines Anbieters passt, ohne umzubrechen.
*/
const TOKEN_BYTES = 32;
/** Das Präfix macht ein gefundenes Token erkennbar — in einem Log, in einer Zwischenablage. */
export const TOKEN_PRAEFIX = "lzscim_";
export function tokenErzeugen(): string {
return `${TOKEN_PRAEFIX}${randomBytes(TOKEN_BYTES).toString("base64url")}`;
}
function rechtPruefen(benutzer: HandelnderBenutzer): void {
if (!darf(benutzer.rolle, "verwaltung")) {
throw new VerzeichnisFehler(
`Ein SCIM-Token zu verwalten verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
}
/**
* Erzeugt ein neues Token für eine SCIM-Quelle und gibt es **einmal** zurück.
*
* Ein vorhandenes wird dabei ersetzt — es gibt genau eines je Quelle. Das ist
* eine Entscheidung gegen Bequemlichkeit: zwei gültige Token wären zwei Dinge,
* die man widerrufen muss, und eines davon vergisst man.
*/
export async function scimTokenErzeugen(
quelleId: string,
benutzer: HandelnderBenutzer,
): Promise<{ readonly token: string }> {
rechtPruefen(benutzer);
const quelle = await prisma.verzeichnisquelle.findUnique({ where: { id: quelleId } });
if (!quelle) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr).");
const eigene = await eigeneOrganisation();
if (quelle.kundeId !== eigene.id) {
throw new VerzeichnisFehler(
`„${quelle.name}“ gehört nicht zur eigenen Organisation. Der Verzeichnisabgleich gilt nur für sie.`,
);
}
if (quelle.art !== "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine Quelle der Art ${quelle.art}. Ein SCIM-Token gilt nur für die Art SCIM.`,
);
}
const token = tokenErzeugen();
await prisma.verzeichnisquelle.update({
where: { id: quelleId },
data: { scimTokenHash: await hash(token), scimTokenGesetztAm: new Date() },
});
await prisma.protokoll.create({
data: {
kundeId: quelle.kundeId,
entitaet: "Verzeichnisquelle",
entitaetId: quelle.id,
aktion: quelle.scimTokenHash ? "SCIM-Token ersetzt" : "SCIM-Token erzeugt",
benutzerId: benutzer.id,
// Kein Token, auch nicht gekürzt: das Protokoll wird gelesen (#023).
nachher: { name: quelle.name },
},
});
return { token };
}
/** Widerruft das Token. Danach nimmt die Quelle nichts mehr an. */
export async function scimTokenWiderrufen(quelleId: string, benutzer: HandelnderBenutzer): Promise<void> {
rechtPruefen(benutzer);
const quelle = await prisma.verzeichnisquelle.findUnique({ where: { id: quelleId } });
if (!quelle) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr).");
const eigene = await eigeneOrganisation();
if (quelle.kundeId !== eigene.id) {
throw new VerzeichnisFehler(`„${quelle.name}“ gehört nicht zur eigenen Organisation.`);
}
if (!quelle.scimTokenHash) return;
await prisma.verzeichnisquelle.update({
where: { id: quelleId },
data: { scimTokenHash: null, scimTokenGesetztAm: null },
});
await prisma.protokoll.create({
data: {
kundeId: quelle.kundeId,
entitaet: "Verzeichnisquelle",
entitaetId: quelle.id,
aktion: "SCIM-Token widerrufen",
benutzerId: benutzer.id,
nachher: { name: quelle.name },
},
});
}
/**
* Die Quelle zu einem Token – oder `undefined`.
*
* **Durchsucht wird, nicht nachgeschlagen.** Ein argon2-Hash ist je Aufruf
* anders, es gibt also keinen Index, über den sich ein Token finden ließe. Bei
* einer Handvoll SCIM-Quellen ist das gleichgültig; bei hunderten wäre es ein
* Grund, zusätzlich eine Kennung im Token zu führen.
*
* **Ohne Vorab-Prüfung auf das Präfix wäre das ein Zeitorakel**: ein Token mit
* falschem Präfix würde gegen jeden Hash geprüft, eines mit richtigem nur bis
* zum Treffer. Deshalb erst die Form, dann der Hash — und das Präfix ist kein
* Geheimnis.
*/
export async function quelleZuToken(token: string): Promise<Verzeichnisquelle | undefined> {
const wert = token.trim();
if (!wert.startsWith(TOKEN_PRAEFIX)) return undefined;
const quellen = await prisma.verzeichnisquelle.findMany({
where: { art: "SCIM", aktiv: true, scimTokenHash: { not: null } },
});
for (const quelle of quellen) {
if (!quelle.scimTokenHash) continue;
try {
if (await verify(quelle.scimTokenHash, wert)) return quelle;
} catch {
/*
* Ein unlesbarer Hash ist ein Datenfehler, nicht ein falsches Token. Die
* uebrigen Quellen werden weiter geprueft — eine kaputte Zeile darf nicht
* die Zuweisung aller anderen anhalten.
*/
console.error(`SCIM-Token-Hash der Quelle ${quelle.id} ist unlesbar.`);
}
}
return undefined;
}
/**
* Zeitkonstanter Textvergleich – für Stellen, an denen kein Hash im Spiel ist.
*
* Steht hier, weil der Handler das `Bearer`-Schema prüft und ein `===` darauf
* zwar harmlos wäre, aber das Muster vorgibt. Ein Vergleich, der früh abbricht,
* gehört in dieser Datei nirgends hin.
*/
export function textGleich(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
/* ------------------------------------------------------------------ */
/* Personen */
/* ------------------------------------------------------------------ */
function alsPerson(zeile: Mitarbeiter): ScimPerson {
return {
id: zeile.id,
externeId: zeile.externeId,
upn: zeile.upn,
vorname: zeile.vorname,
nachname: zeile.nachname,
anzeigename: zeile.anzeigename,
mail: zeile.mail,
abteilung: zeile.abteilung,
titel: zeile.titel,
aktiv: zeile.aktiv,
vorgesetzterId: zeile.vorgesetzterId,
erstelltAm: zeile.erstelltAm,
geaendertAm: zeile.geaendertAm,
};
}
/** Der Anzeigename, wenn der Anbieter keinen schickt. Wie beim Abgleich. */
function anzeigenameBauen(eingabe: ScimEingabe): string {
if (eingabe.anzeigename) return eingabe.anzeigename;
const gebaut = [eingabe.nachname, eingabe.vorname].filter((teil) => teil).join(", ");
return gebaut || eingabe.upn || "Ohne Namen";
}
/**
* Den Vorgesetzten auflösen: aus der Kennung des Anbieters eine Zeile.
*
* **Drei Gründe, warum das `null` ergeben darf, und keiner davon ist ein
* Fehler:** der Vorgesetzte wurde Laufzettel noch nicht zugewiesen (der
* Anbieter schickt die Leute in beliebiger Reihenfolge), er gehört zu einer
* anderen Quelle, oder jemand ist sein eigener Vorgesetzter. Beim nächsten
* `PATCH` auf diese Person wird es nachgezogen — der Anbieter schickt die
* Zuordnung erneut, sobald sie sich ändert.
*
* Eine fremde Quelle wird ausgeschlossen, weil `manager` sonst ein Weg über die
* Mandantengrenze wäre: eine Kennung aus einer anderen Quelle zeigt auf jemanden,
* den diese hier nicht sehen darf.
*/
async function vorgesetzterAufloesen(
quelle: Verzeichnisquelle,
eigeneId: string | null,
scimId: string | null,
): Promise<{ readonly vorgesetzterId: string | null; readonly vorgesetzterExterneId: string | null }> {
if (!scimId || scimId === eigeneId) return { vorgesetzterId: null, vorgesetzterExterneId: null };
const zeile = await prisma.mitarbeiter.findFirst({
where: { id: scimId, quelleId: quelle.id },
select: { id: true, externeId: true },
});
return zeile
? { vorgesetzterId: zeile.id, vorgesetzterExterneId: zeile.externeId }
: { vorgesetzterId: null, vorgesetzterExterneId: null };
}
async function zustellungVermerken(quelleId: string): Promise<void> {
await prisma.verzeichnisquelle.update({
where: { id: quelleId },
data: { letzteScimZustellungAm: new Date() },
});
}
/**
* Protokolleintrag mit der **Quelle** als Urheber.
*
* `benutzerId` bleibt leer — das ist ein Systemlauf, wie beim Zeitplan des
* Abgleichs. Ein erfundener Administrator wäre eine Lüge im Protokoll, und das
* Protokoll ist ansehbar (#023).
*/
async function vermerken(
quelle: Verzeichnisquelle,
mitarbeiterId: string,
aktion: string,
nachher: Prisma.InputJsonObject,
): Promise<void> {
await prisma.protokoll.create({
data: {
kundeId: quelle.kundeId,
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: `${aktion} (SCIM: ${quelle.name})`,
nachher,
},
});
}
/** Eine Person dieser Quelle, über die Laufzettel-Kennung. */
export async function personHolen(quelle: Verzeichnisquelle, id: string): Promise<ScimPerson | undefined> {
/*
* **Immer mit `quelleId`.** Die Kennung allein wäre genug, um die Zeile zu
* finden — und genau das ist der Fehler, der einem fremden Token Zugriff auf
* die Personen einer anderen Quelle gäbe.
*/
const zeile = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } });
return zeile ? alsPerson(zeile) : undefined;
}
/** Personen dieser Quelle, gefiltert nach `userName` oder alle. */
export async function personenSuchen(
quelle: Verzeichnisquelle,
upn?: string,
): Promise<readonly ScimPerson[]> {
const zeilen = await prisma.mitarbeiter.findMany({
where: {
quelleId: quelle.id,
...(upn ? { upn: { equals: upn, mode: "insensitive" } } : {}),
},
orderBy: { anzeigename: "asc" },
// Eine Obergrenze, damit ein `GET /Users` ohne Filter nicht das ganze
// Verzeichnis in eine Antwort schreibt. Die Anbieter fragen gefiltert.
take: 200,
});
return zeilen.map(alsPerson);
}
/**
* Legt eine Person an – oder weist ab, wenn es sie schon gibt.
*
* **Zweimal dasselbe `POST` ergibt `409`**, nicht eine zweite Person. Das ist
* kein Komfort, sondern der Normalfall: ein Anbieter, der eine Zuweisung
* wiederholt (nach einem Fehler, nach einem Neustart), schickt dasselbe `POST`
* erneut.
*
* Erkannt wird eine Dublette an `externalId` **oder** am `userName` — der
* Anbieter schickt nicht immer beides, und beides identifiziert dieselbe Person.
*/
export async function personAnlegen(quelle: Verzeichnisquelle, eingabe: ScimEingabe): Promise<ScimPerson> {
if (!eingabe.upn && !eingabe.externeId) {
throw new ScimFehler(
400,
"Ohne userName und ohne externalId lässt sich niemand anlegen.",
"invalidValue",
);
}
const vorhanden = await prisma.mitarbeiter.findFirst({
where: {
quelleId: quelle.id,
OR: [
...(eingabe.externeId ? [{ externeId: eingabe.externeId }] : []),
...(eingabe.upn ? [{ upn: { equals: eingabe.upn, mode: "insensitive" as const } }] : []),
],
},
});
if (vorhanden) {
throw new ScimFehler(409, `„${vorhanden.anzeigename}“ gibt es in dieser Quelle schon.`, "uniqueness");
}
/*
* **Übernahme statt Dublette**, wie beim Abgleich (#016): beim Onboarding legt
* jemand die Person von Hand an, das Konto beim Anbieter entsteht danach.
* Gesucht wird nur unter Zeilen **ohne** Quelle — eine Person, die eine andere
* Quelle führt, gehört der.
*/
const vonHand = eingabe.upn
? await prisma.mitarbeiter.findFirst({
where: {
kundeId: quelle.kundeId,
quelleId: null,
upn: { equals: eingabe.upn, mode: "insensitive" },
},
})
: null;
// Beim Anlegen gibt es noch keine eigene Kennung, auf die `manager` zeigen
// koennte – deshalb `null` als eigene.
const vorgesetzter = await vorgesetzterAufloesen(quelle, null, eingabe.vorgesetzterScimId);
const daten = {
externeId: eingabe.externeId,
upn: eingabe.upn,
...vorgesetzter,
vorname: eingabe.vorname ?? "",
nachname: eingabe.nachname ?? "",
anzeigename: anzeigenameBauen(eingabe),
mail: eingabe.mail,
abteilung: eingabe.abteilung,
titel: eingabe.titel,
// Ohne `active` wird aktiv angelegt: ein Anbieter weist niemandem ein
// deaktiviertes Konto zu.
aktiv: eingabe.aktiv ?? true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: new Date(),
};
const zeile = vonHand
? await prisma.mitarbeiter.update({
where: { id: vonHand.id },
data: { ...daten, quelleId: quelle.id },
})
: await prisma.mitarbeiter.create({
data: { ...daten, kundeId: quelle.kundeId, quelleId: quelle.id },
});
await vermerken(quelle, zeile.id, vonHand ? "Person übernommen" : "Person angelegt", {
upn: zeile.upn,
anzeigename: zeile.anzeigename,
});
await zustellungVermerken(quelle.id);
return alsPerson(zeile);
}
/**
* Ändert eine Person. `eingabe` ist der **vollständige Zielzustand**.
*
* Was hier `null` ist, wird geleert — auch bei `PATCH`. Das ist der
* entscheidende Punkt, und er hat einen Anlauf gekostet (#029): diese Funktion
* hatte einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht
* mitgeschickt“ las und den alten Wert stehen ließ. Damit wirkte ein
* `remove` **gar nicht** — jemand nimmt beim Anbieter die Abteilung aus dem
* Profil, und in Laufzettel stand sie weiter.
*
* Nötig war der Schalter nie: `patchAnwenden` setzt auf dem vorhandenen Stand
* auf und liefert deshalb immer einen vollständigen Zustand, in dem „nicht
* mitgeschickt“ schon als alter Wert steht. Der Unterschied zwischen `PUT` und
* `PATCH` liegt also **vor** dieser Funktion und nicht in ihr.
*/
export async function personAendern(
quelle: Verzeichnisquelle,
id: string,
eingabe: ScimEingabe,
): Promise<ScimPerson> {
const vorhanden = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } });
if (!vorhanden) throw new ScimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
const vorgesetzter = await vorgesetzterAufloesen(quelle, id, eingabe.vorgesetzterScimId);
const zeile = await prisma.mitarbeiter.update({
where: { id },
data: {
externeId: eingabe.externeId,
upn: eingabe.upn,
...vorgesetzter,
vorname: eingabe.vorname ?? "",
nachname: eingabe.nachname ?? "",
anzeigename: eingabe.anzeigename ?? anzeigenameBauen(eingabe),
mail: eingabe.mail,
abteilung: eingabe.abteilung,
titel: eingabe.titel,
// Ohne `active` im Rumpf bleibt es aktiv: ein `PUT` ohne das Feld ist
// keine Abschaltung, und bei `PATCH` steht hier ohnehin der alte Wert.
aktiv: eingabe.aktiv ?? true,
zuletztGesehenAm: new Date(),
},
});
const wurdeDeaktiviert = vorhanden.aktiv && !zeile.aktiv;
const wurdeAktiviert = !vorhanden.aktiv && zeile.aktiv;
await vermerken(
quelle,
zeile.id,
wurdeDeaktiviert ? "Person deaktiviert" : wurdeAktiviert ? "Person wieder aktiv" : "Person aktualisiert",
{ upn: zeile.upn, anzeigename: zeile.anzeigename, aktiv: zeile.aktiv },
);
await zustellungVermerken(quelle.id);
return alsPerson(zeile);
}
/**
* `DELETE` vom Anbieter: **deaktiviert, löscht nicht.**
*
* Die Regel des ganzen Projekts. An einer Person hängen Läufe und Geräte; ein
* archivierter Lauf muss auch in zehn Jahren noch sagen können, für wen er lief.
* Der Anbieter bekommt `204` und hält die Person damit für gelöscht — das ist
* aus seiner Sicht richtig, denn sie wird ihm nicht mehr zugewiesen.
*
* **Das muss in der Anleitung stehen**, sonst wirkt es wie ein Fehler: jemand
* entfernt eine Person beim Anbieter, und sie steht in Laufzettel weiter in der
* Liste — inaktiv, aber sichtbar.
*/
export async function personDeaktivieren(quelle: Verzeichnisquelle, id: string): Promise<void> {
const vorhanden = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } });
if (!vorhanden) throw new ScimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
if (vorhanden.aktiv) {
await prisma.mitarbeiter.update({
where: { id },
data: { aktiv: false, zuletztGesehenAm: new Date() },
});
await vermerken(quelle, id, "Person deaktiviert (DELETE)", {
upn: vorhanden.upn,
anzeigename: vorhanden.anzeigename,
hinweis: "Die Zeile bleibt: Läufe und Geräte hängen daran.",
});
}
await zustellungVermerken(quelle.id);
}
+51 -277
View File
@@ -1,28 +1,17 @@
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client"; import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
import { QuellenArt, Betriebsmodus } from "@/generated/prisma/enums"; import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant"; import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
import { VerzeichnisFehler } from "@/server/ldap/fehler"; import { VerzeichnisFehler } from "@/server/ldap/fehler";
// Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
// Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit import { eintraegeLesen } from "@/server/ldap/leser";
// bestehende Aufrufer ihre Anlaufstelle behalten. import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
import { ldapVorgabeAus, zugangAus, zuordnungsArt } from "@/server/verzeichnis/zugang";
import {
ZUORDNUNGS_VORGABE,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
intervallMeldung,
zuordnungPruefen,
} from "@/lib/verzeichnis";
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { urlPruefen } from "@/server/ldap/verbindung";
import { import {
AD_ZUORDNUNG, AD_ZUORDNUNG,
type AttributZuordnung, type AttributZuordnung,
type GemappterEintrag, type GemappterEintrag,
type RoherEintrag,
STANDARD_FILTER, STANDARD_FILTER,
type Uebersprungen, type Uebersprungen,
vorgesetzteAufloesen, vorgesetzteAufloesen,
@@ -59,28 +48,16 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
* Aufrufer sie von hier kennt. * Aufrufer sie von hier kennt.
*/ */
export { VerzeichnisFehler }; export { VerzeichnisFehler };
export { ldapVorgabeAus, zugangAus, zuordnungsArt };
/** Ohne Passwort – diese Form geht an die Oberflaeche. */ /** Ohne Passwort – diese Form geht an die Oberflaeche. */
export type QuellenSicht = { export type QuellenSicht = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly aktiv: boolean; readonly aktiv: boolean;
/** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */ readonly url: string;
readonly art: QuellenArt; readonly bindDn: string;
/** Nur bei `art = LDAP` belegt. */ readonly basisDn: string;
readonly url: string | null; readonly filter: string;
readonly bindDn: string | null;
readonly basisDn: string | null;
readonly filter: string | null;
/** Nur bei `art = GRAPH` belegt – das Geheimnis **nie**, nur ob eines da ist. */
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVorhanden: boolean;
/** Nur bei `art = SCIM`. Das Token **nie**, nur ob eines gilt und seit wann. */
readonly scimTokenVorhanden: boolean;
readonly scimTokenGesetztAm: Date | null;
readonly letzteScimZustellungAm: Date | null;
readonly attributZuordnung: AttributZuordnung; readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean; readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number; readonly abgleichIntervallMinuten: number;
@@ -91,18 +68,11 @@ export type QuellenSicht = {
export type NeueQuelle = { export type NeueQuelle = {
name: string; name: string;
/** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */ url: string;
art?: QuellenArt; bindDn: string;
/** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */ bindPasswort: string;
url?: string; basisDn: string;
bindDn?: string;
bindPasswort?: string;
basisDn?: string;
filter?: string; filter?: string;
/** Pflicht bei `art = GRAPH`. */
tenantId?: string;
clientId?: string;
clientSecret?: string;
attributZuordnung?: Partial<AttributZuordnung>; attributZuordnung?: Partial<AttributZuordnung>;
caZertifikatPem?: string | null; caZertifikatPem?: string | null;
abgleichIntervallMinuten?: number; abgleichIntervallMinuten?: number;
@@ -180,28 +150,11 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
id: zeile.id, id: zeile.id,
name: zeile.name, name: zeile.name,
aktiv: zeile.aktiv, aktiv: zeile.aktiv,
art: zeile.art,
url: zeile.url, url: zeile.url,
bindDn: zeile.bindDn, bindDn: zeile.bindDn,
basisDn: zeile.basisDn, basisDn: zeile.basisDn,
filter: zeile.filter, filter: zeile.filter,
tenantId: zeile.tenantId, attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
clientId: zeile.clientId,
/*
* Wie beim Bind-Passwort: nur **ob** eines hinterlegt ist. Ein Feld, das es
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
/*
* Dasselbe fuer das SCIM-Token (#029), und hier waere es noch teurer: es ist
* der einzige Mandantenbeweis, den ein Push mitbringt. Es steht als Hash in
* der Datenbank und geht aus dem Dienst genau einmal heraus — in der
* Antwort von `scimTokenErzeugen`, nie in einer Sicht.
*/
scimTokenVorhanden: Boolean(zeile.scimTokenHash?.trim()),
scimTokenGesetztAm: zeile.scimTokenGesetztAm,
letzteScimZustellungAm: zeile.letzteScimZustellungAm,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines // Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext. // hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()), caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
@@ -215,89 +168,6 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */ /** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
export { alsSicht as quellenSichtBauen }; export { alsSicht as quellenSichtBauen };
/**
* Die artabhaengigen Felder einer neuen Quelle, geprueft.
*
* Eine eigene Funktion, weil beide Richtungen gelten muessen: was eine Art
* **braucht**, und was sie **nicht haben darf**. Ohne den zweiten Teil liesse
* sich eine Graph-Quelle mit `basisDn` anlegen — eine Zeile, die zwei Dinge
* gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt. Die
* Datenbank weist das ohnehin ab; hier entsteht die Meldung, die weiterhilft.
*/
function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
if (art === "LDAP") {
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
urlPruefen(url);
const bindPasswort = eingabe.bindPasswort?.trim();
if (!bindPasswort) {
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
}
if (eingabe.tenantId?.trim() || eingabe.clientId?.trim() || eingabe.clientSecret?.trim()) {
throw new VerzeichnisFehler(
"Eine LDAP-Quelle hat keine Graph-Zugangsdaten. Für Entra ID gibt es die Art „Graph“.",
);
}
return {
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
// gewuenscht war.
url,
bindDn: pflichtText(eingabe.bindDn, "Der Bind-DN"),
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
basisDn: pflichtText(eingabe.basisDn, "Der Basis-DN"),
filter: eingabe.filter?.trim() || STANDARD_FILTER,
};
}
if (art === "GRAPH") {
const clientSecret = eingabe.clientSecret?.trim();
if (!clientSecret) {
throw new VerzeichnisFehler("Das Client-Secret der App-Registrierung fehlt.");
}
if (eingabe.url?.trim() || eingabe.bindDn?.trim() || eingabe.basisDn?.trim()) {
throw new VerzeichnisFehler(
"Eine Graph-Quelle hat keine Adresse, keinen Bind-DN und keinen Basis-DN — Entra ID kennt " +
"beides nicht. Für ein lokales AD gibt es die Art „LDAP“.",
);
}
return {
tenantId: pflichtText(eingabe.tenantId, "Die Mandanten-ID"),
clientId: pflichtText(eingabe.clientId, "Die Anwendungs-ID"),
clientSecretVerschluesselt: verschluesseln(clientSecret),
};
}
if (art === "SCIM") {
/*
* **Eine SCIM-Quelle hat keine Zugangsdaten — sie hat ein Token, und das
* entsteht danach.** Erst steht die Quelle, dann erzeugt der Administrator
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an und antwortet mit 401; das ist der richtige Anfangszustand.
*/
if (
eingabe.url?.trim() ||
eingabe.bindDn?.trim() ||
eingabe.basisDn?.trim() ||
eingabe.tenantId?.trim() ||
eingabe.clientId?.trim()
) {
throw new VerzeichnisFehler(
"Eine SCIM-Quelle hat weder LDAP- noch Graph-Zugangsdaten: Laufzettel verbindet sich nicht, " +
"sondern wird beliefert. Sie braucht nur einen Namen — das Token kommt danach.",
);
}
return {};
}
/*
* Unerreichbar, solange `QuellenArt` drei Werte hat — und genau deshalb hier:
* eine vierte Art faellt beim Uebersetzen auf und nicht im Betrieb.
*/
const unbekannt: never = art;
throw new VerzeichnisFehler(`Quellen der Art ${String(unbekannt)} lassen sich nicht anlegen.`);
}
export async function quelleAnlegen( export async function quelleAnlegen(
eingabe: NeueQuelle, eingabe: NeueQuelle,
benutzer: HandelnderBenutzer, benutzer: HandelnderBenutzer,
@@ -307,40 +177,31 @@ export async function quelleAnlegen(
const kundeId = await kundeErmitteln(null, benutzer); const kundeId = await kundeErmitteln(null, benutzer);
const name = pflichtText(eingabe.name, "Der Name der Quelle"); const name = pflichtText(eingabe.name, "Der Name der Quelle");
const art = eingabe.art ?? "LDAP"; const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
urlPruefen(url);
const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
const bindPasswort = eingabe.bindPasswort?.trim();
if (!bindPasswort) {
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
}
const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten); const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
/*
* **Welche Felder gelten, haengt an der Art** (#028). Geprueft wird hier mit
* einer brauchbaren Meldung; dieselbe Regel haelt die Datenbank fest (CHECK
* `Verzeichnisquelle_felder_je_art`), damit sie auch gilt, wenn jemand an
* dieser Schicht vorbei schreibt.
*/
const felder = feldernJeArt(art, eingabe);
/*
* **Die Zuordnung wird hier geprueft, nicht nur in der Server Action.**
* Aus diesen Namen entsteht eine LDAP-Attributliste und ein `$select` in der
* Graph-Adresse; `src/server/graph/leser.ts` berief sich im Kommentar darauf,
* dass sie dem Muster entsprechen — waehrend die Pruefung eine Schicht
* darueber in `zuordnungAusFormular` stand. Ein Name mit Komma und `$filter`
* kam deshalb durch den Dienst und landete in der Abfrage (Reviewbefund zu
* #029). Ein Skript, der Seed und ein Import gehen nicht durch das Formular.
*/
const zuordnungsMangel = zuordnungPruefen(eingabe.attributZuordnung ?? {});
if (zuordnungsMangel) throw new VerzeichnisFehler(zuordnungsMangel);
const zeile = await prisma.$transaction(async (tx) => { const zeile = await prisma.$transaction(async (tx) => {
const angelegt = await tx.verzeichnisquelle.create({ const angelegt = await tx.verzeichnisquelle.create({
data: { data: {
kundeId, kundeId,
name, name,
art, // Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
...felder, // ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
attributZuordnung: zuordnungLesen( // waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
{ ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung }, // gewuenscht war.
zuordnungsArt(art), url,
), bindDn,
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
basisDn,
filter: eingabe.filter?.trim() || STANDARD_FILTER,
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem), caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen. // Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }), ...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
@@ -354,20 +215,8 @@ export async function quelleAnlegen(
entitaetId: angelegt.id, entitaetId: angelegt.id,
aktion: "Verzeichnisquelle angelegt", aktion: "Verzeichnisquelle angelegt",
benutzerId: benutzer.id, benutzerId: benutzer.id,
/* // Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
* Kein Passwort und kein Client-Secret, auch nicht verschluesselt: das nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
* Protokoll wird gelesen (#023). Der Mandant und die Anwendungs-ID sind
* dagegen keine Geheimnisse — ohne sie liesse sich hinterher nicht
* sagen, welche Registrierung gemeint war.
*/
nachher: {
name: angelegt.name,
art: angelegt.art,
...(angelegt.url ? { url: angelegt.url } : {}),
...(angelegt.basisDn ? { basisDn: angelegt.basisDn } : {}),
...(angelegt.tenantId ? { tenantId: angelegt.tenantId } : {}),
...(angelegt.clientId ? { clientId: angelegt.clientId } : {}),
},
}, },
}); });
return angelegt; return angelegt;
@@ -387,35 +236,6 @@ export async function quelleBearbeiten(
const daten: Prisma.VerzeichnisquelleUpdateInput = {}; const daten: Prisma.VerzeichnisquelleUpdateInput = {};
const geaendert: string[] = []; const geaendert: string[] = [];
/*
* **Die Art laesst sich nicht aendern** (#028).
*
* Nicht aus Bequemlichkeit: an der Art haengt, was `externeId` bedeutet. Im
* AD ist es eine `objectGUID`, in Entra ID eine andere GUID — nach einem
* Wechsel wuerde kein einziger Mitarbeiter wiedererkannt. Der naechste
* Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf
* `aktiv = false`: Laeufe und Geraete haengen dann an den inaktiven.
*
* Wer wechselt, legt eine neue Quelle an und laesst die alte stehen, bis die
* Leute uebernommen sind — dafuer gibt es die Uebernahme von Hand angelegter
* Personen aus #016.
*/
if (aenderung.art !== undefined && aenderung.art !== vorher.art) {
throw new VerzeichnisFehler(
`Die Art einer Quelle lässt sich nicht ändern (${vorher.art} → ${aenderung.art}). ` +
"Daran hängt, was die dauerhafte Kennung bedeutet — nach einem Wechsel würde kein " +
"Mitarbeiter wiedererkannt. Leg stattdessen eine neue Quelle an.",
);
}
const nurBei = (art: QuellenArt, feld: string): void => {
if (vorher.art !== art) {
throw new VerzeichnisFehler(
`„${feld}“ gilt nur für eine Quelle der Art ${art}; „${vorher.name}“ ist ${vorher.art}.`,
);
}
};
if (aenderung.name !== undefined) { if (aenderung.name !== undefined) {
const name = pflichtText(aenderung.name, "Der Name der Quelle"); const name = pflichtText(aenderung.name, "Der Name der Quelle");
if (name !== vorher.name) { if (name !== vorher.name) {
@@ -423,40 +243,7 @@ export async function quelleBearbeiten(
geaendert.push("Name"); geaendert.push("Name");
} }
} }
// --- Graph-Felder ---------------------------------------------------------
if (aenderung.tenantId !== undefined) {
nurBei("GRAPH", "Mandanten-ID");
const wert = pflichtText(aenderung.tenantId, "Die Mandanten-ID");
if (wert !== vorher.tenantId) {
daten.tenantId = wert;
geaendert.push("Mandant");
}
}
if (aenderung.clientId !== undefined) {
nurBei("GRAPH", "Anwendungs-ID");
const wert = pflichtText(aenderung.clientId, "Die Anwendungs-ID");
if (wert !== vorher.clientId) {
daten.clientId = wert;
geaendert.push("Anwendung");
}
}
if (aenderung.clientSecret !== undefined) {
nurBei("GRAPH", "Client-Secret");
/*
* Leer heisst **unveraendert**, wie beim Bind-Passwort: das Formular zeigt
* ein gesetztes Geheimnis nie an, also kommt es leer zurueck. Wuerde leer
* als „loeschen“ gelesen, machte jedes Umbenennen die Quelle unbrauchbar.
*/
const wert = aenderung.clientSecret.trim();
if (wert) {
daten.clientSecretVerschluesselt = verschluesseln(wert);
geaendert.push("Client-Secret");
}
}
if (aenderung.url !== undefined) { if (aenderung.url !== undefined) {
nurBei("LDAP", "Adresse");
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses"); const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
urlPruefen(url); urlPruefen(url);
if (url !== vorher.url) { if (url !== vorher.url) {
@@ -465,7 +252,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.bindDn !== undefined) { if (aenderung.bindDn !== undefined) {
nurBei("LDAP", "Bind-DN");
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN"); const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
if (bindDn !== vorher.bindDn) { if (bindDn !== vorher.bindDn) {
daten.bindDn = bindDn; daten.bindDn = bindDn;
@@ -473,7 +259,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.bindPasswort !== undefined) { if (aenderung.bindPasswort !== undefined) {
nurBei("LDAP", "Bind-Passwort");
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht // Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern // anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
// eine leere Zeichenkette und loeschte das Geheimnis. // eine leere Zeichenkette und loeschte das Geheimnis.
@@ -484,7 +269,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.basisDn !== undefined) { if (aenderung.basisDn !== undefined) {
nurBei("LDAP", "Basis-DN");
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN"); const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
if (basisDn !== vorher.basisDn) { if (basisDn !== vorher.basisDn) {
daten.basisDn = basisDn; daten.basisDn = basisDn;
@@ -492,7 +276,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.filter !== undefined) { if (aenderung.filter !== undefined) {
nurBei("LDAP", "Filter");
const filter = aenderung.filter.trim() || STANDARD_FILTER; const filter = aenderung.filter.trim() || STANDARD_FILTER;
if (filter !== vorher.filter) { if (filter !== vorher.filter) {
daten.filter = filter; daten.filter = filter;
@@ -500,8 +283,6 @@ export async function quelleBearbeiten(
} }
} }
if (aenderung.attributZuordnung !== undefined) { if (aenderung.attributZuordnung !== undefined) {
const mangel = zuordnungPruefen(aenderung.attributZuordnung);
if (mangel) throw new VerzeichnisFehler(mangel);
const zuordnung = zuordnungLesen({ const zuordnung = zuordnungLesen({
...zuordnungLesen(vorher.attributZuordnung), ...zuordnungLesen(vorher.attributZuordnung),
...aenderung.attributZuordnung, ...aenderung.attributZuordnung,
@@ -617,32 +398,15 @@ export async function verbindungTesten(
await nurIntern(); await nurIntern();
rechtPruefen(benutzer); rechtPruefen(benutzer);
const quelle = await quelleZeileHolen(quelleId); const quelle = await quelleZeileHolen(quelleId);
/*
* **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029):
* Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht,
* zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte
* Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt.
*/
if (quelle.art === "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` +
"testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " +
"danach steht hier, wann die letzte angekommen ist.",
);
}
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS); const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
/* const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
* **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier zugangAus(quelle),
* waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss — {
* und die vergisst man. basisDn: quelle.basisDn,
*/ filter: quelle.filter,
const eintraege: RoherEintrag[] = []; zuordnung: zuordnungLesen(quelle.attributZuordnung),
const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen( },
quelle,
(eintrag) => void eintraege.push(eintrag),
wieViele, wieViele,
); );
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen }; return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
@@ -669,3 +433,13 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
} }
return zeile; return zeile;
} }
/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
return {
url: quelle.url,
bindDn: quelle.bindDn,
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
caZertifikatPem: quelle.caZertifikatPem,
};
}
+12 -171
View File
@@ -2,13 +2,7 @@ import type { ZodError } from "@/lib/validierung";
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client"; import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungAufloesen, bedingungZuPfad, schrittPfad } from "@/lib/bedingungen"; import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
import {
AustauschSchema,
FORMAT_VERSION,
type Austausch,
type AustauschBedingung,
} from "@/lib/vorlagen-format";
/** /**
* JSON-Austausch fuer Vorlagen (#009). * JSON-Austausch fuer Vorlagen (#009).
@@ -48,22 +42,7 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] }, variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
abschnitte: { abschnitte: {
orderBy: [{ position: "asc" }, { id: "asc" }], orderBy: [{ position: "asc" }, { id: "asc" }],
include: { include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] },
/*
* Der Baustein mit. Ein Baustein-Abschnitt im **Entwurf** hat keine
* eigenen Schritte — die entstehen erst beim Veroeffentlichen. Ohne
* diese Zeilen exportierte ein Entwurf einen leeren Abschnitt unter
* dem Platzhaltertitel „wird ersetzt“ (Reviewbefund zu #029).
*/
bausteinVersion: {
select: {
titel: true,
baustein: { select: { name: true } },
schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
},
},
}, },
}, },
}); });
@@ -72,22 +51,6 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
} }
const { vorlage } = version; const { vorlage } = version;
/*
* Kennung → Titel-Pfad, fuer die Schritt-Bedingungen. Eine Kennung bedeutet
* in einer anderen Installation nichts; ein Titel-Pfad schon (#039).
*/
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
for (const abschnitt of version.abschnitte) {
const titel =
abschnitt.bausteinVersion && abschnitt.schritte.length === 0
? abschnitt.bausteinVersion.titel
: abschnitt.titel;
for (const schritt of abschnitt.schritte) {
pfade.set(schritt.id, { abschnitt: titel, schritt: schritt.titel });
}
}
return { return {
format: FORMAT_VERSION, format: FORMAT_VERSION,
name: vorlage.name, name: vorlage.name,
@@ -105,40 +68,13 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
optionen: [...variable.optionen], optionen: [...variable.optionen],
pflicht: variable.pflicht, pflicht: variable.pflicht,
})), })),
abschnitte: version.abschnitte.map((abschnitt) => { abschnitte: version.abschnitte.map((abschnitt) => ({
/* titel: abschnitt.titel,
* **Ein Baustein-Abschnitt wird aufgeloest, nicht abgeschrieben.**
*
* Der Verweis selbst wandert bewusst nicht mit (#030): in der anderen
* Installation gibt es den Baustein vielleicht nicht, und eine Vorlage,
* die auf etwas Fehlendes zeigt, ist kein Austauschformat. Die **Inhalte**
* muessen aber mitwandern — und bei einem Entwurf stehen sie noch nicht
* am Abschnitt, sondern nur am Baustein.
*
* Vorher exportierte ein Entwurf deshalb `{"titel":"wird ersetzt",
* "schritte":[]}`: ein Redakteur brachte eine unveroeffentlichte Vorlage
* in eine zweite Installation, und der geteilte Abschnitt kam dort als
* leerer Abschnitt mit dem Editor-Platzhalter als Namen an. Die Checkliste
* war unvollstaendig, und nichts sagte es (Reviewbefund zu #029).
*
* Bei einer **veroeffentlichten** Version aendert das nichts: dort sind
* die Schritte beim Veroeffentlichen schon in den Abschnitt geschrieben
* worden, und der Titel kommt ohnehin aus dem Baustein.
*/
const quelle = abschnitt.bausteinVersion;
const ausBaustein = quelle !== null && abschnitt.schritte.length === 0;
return {
titel: ausBaustein ? quelle.titel : abschnitt.titel,
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation // Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe. // dasselbe. Der Baustein-Verweis wandert bewusst **nicht** mit (#030).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...bedingungAbbilden(abschnitt.bedingung, pfade), schritte: abschnitt.schritte.map(schrittAbbilden),
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map((schritt) => })),
schrittAbbilden(schritt, pfade),
),
};
}),
}, },
}; };
} }
@@ -154,43 +90,7 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar * nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer. * kein Objekt ist, gilt als leer.
*/ */
/** function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
* Eine gespeicherte Bedingung in die Austauschform.
*
* Gibt `{}` zurueck, wenn es keine gibt oder wenn eine Schritt-Bedingung auf
* einen Schritt zeigt, den der Export nicht kennt. **Weglassen und nicht
* mitschreiben**: eine Bedingung mit einem Verweis ins Leere waere in der
* zweiten Installation nicht entscheidbar, der Abschnitt erschiene also immer —
* und niemand koennte sagen, warum. Ohne Bedingung erscheint er auch immer, aber
* das sieht man in der Datei.
*/
function bedingungAbbilden(
roh: unknown,
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): { readonly bedingung?: AustauschBedingung } {
const pfadBedingung = bedingungZuPfad(roh, pfade);
return pfadBedingung ? { bedingung: pfadBedingung as AustauschBedingung } : {};
}
function schrittAbbilden(
/*
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
* jetzt auch einen `BausteinSchritt` ab (Baustein-Abschnitte werden beim
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
* ein Schritt haengt — und das steht ohnehin in keinem Export.
*/
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl"> & {
/*
* Optional, weil ein **Baustein**-Schritt keine Bedingung hat — und das ist
* Absicht (#039): ein Baustein ist wiederverwendbar, und eine Bedingung auf
* einer Variable, die es in der Zielvorlage nicht gibt, waere nicht
* entscheidbar. Der Abschnitt, in dem er steht, darf eine haben; der
* einzelne Schritt daraus nicht.
*/
readonly bedingung?: unknown;
},
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): AustauschSchritt {
const roh = schritt.konfig; const roh = schritt.konfig;
const konfig = const konfig =
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {}; typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
@@ -200,7 +100,6 @@ function schrittAbbilden(
typ: schritt.typ, typ: schritt.typ,
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig, konfig,
...bedingungAbbilden(schritt.bedingung, pfade),
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}), ...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
}; };
} }
@@ -256,38 +155,13 @@ export async function vorlageImportieren(
} }
let schritteGesamt = 0; let schritteGesamt = 0;
/*
* Kennungen, um die Schritt-Bedingungen danach aufzuloesen (#039). Erst
* anlegen, dann verweisen: eine Bedingung kann auf einen Schritt zeigen, der
* weiter unten steht, und der Trigger verlangt einen Schritt derselben
* Version.
*/
const neueKennungen = new Map<string, string>();
const abschnittsZeilen = new Map<string, string>();
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) { for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({ const zeile = await tx.vorlagenAbschnitt.create({
data: { data: { versionId: version.id, titel: abschnitt.titel, position },
versionId: version.id,
titel: abschnitt.titel,
position,
/*
* **Wandert mit, und das fehlte hier.** Der Export schreibt die
* Abteilungseinschraenkung seit #031, das Schema nimmt sie an — und
* der Import verwarf sie stillschweigend. Eine Vorlage, die jemand in
* eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
* sie galten dort fuer **alle**. Also mehr Schritte statt weniger, und
* niemand merkt es (gefunden bei #039).
*/
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
}); });
abschnittsZeilen.set(abschnitt.titel, zeile.id);
if (abschnitt.schritte.length === 0) continue; if (abschnitt.schritte.length === 0) continue;
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) { await tx.vorlagenSchritt.createMany({
const angelegt = await tx.vorlagenSchritt.create({ data: abschnitt.schritte.map((schritt, schrittPosition) => ({
data: {
abschnittId: zeile.id, abschnittId: zeile.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -299,44 +173,11 @@ export async function vorlageImportieren(
konfig: schritt.konfig as Prisma.InputJsonObject, konfig: schritt.konfig as Prisma.InputJsonObject,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition, position: schrittPosition,
}, })),
}); });
neueKennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
schritteGesamt += abschnitt.schritte.length; schritteGesamt += abschnitt.schritte.length;
} }
/*
* Zweiter Durchgang: die Bedingungen. Ein Titel-Pfad, den es in dieser Datei
* nicht gibt, wird **uebergangen** und nicht als Fehler gemeldet — die Datei
* kann von Hand geschrieben sein, und eine Vorlage wegen eines Tippfehlers in
* einer Bedingung ganz abzuweisen waere die teurere Antwort. Uebergangen
* heisst: der Abschnitt gilt immer, und das steht sichtbar in der Oberflaeche.
*/
for (const abschnitt of austausch.struktur.abschnitte) {
const abschnittId = abschnittsZeilen.get(abschnitt.titel);
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, neueKennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = neueKennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, neueKennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId, kundeId: vorlage.kundeId,
+67 -211
View File
@@ -1,7 +1,6 @@
import { Prisma, type SchrittTyp, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client"; import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungAufloesen, bedingungLesen, schrittPfad } from "@/lib/bedingungen";
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format"; import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/** /**
@@ -83,114 +82,6 @@ export type VorlagenAenderung = {
readonly kundeId?: string | null; readonly kundeId?: string | null;
}; };
/**
* Eine tiefe Kopie der Abschnitte und Schritte einer Version.
*
* **An einer Stelle, weil es zweimal gebraucht wird** — `vorlageBearbeiten`
* macht aus der letzten veroeffentlichten Version einen Entwurf,
* `vorlageDuplizieren` eine neue Vorlage. Beide hatten die Kopie einzeln
* ausgeschrieben, und beide vergassen dabei dasselbe: `nurFuerAbteilungen`
* (seit #031) und `bedingung` (seit #039).
*
* Der Schaden war still und ernst: „Bearbeiten“ einer Vorlage verlor **alle**
* Abteilungseinschraenkungen — die neue Version galt dann fuer jeden Lauf, also
* mehr Schritte statt weniger, und niemand merkt es. Gefunden erst bei der
* Klickprobe am gebauten Stand (#039); keine Suite hat es gesehen, weil keine
* die Kopie gegen das Original verglich.
*
* **Schritt-Bedingungen werden auf die neuen Kennungen umgeschrieben.** Eine
* Kopie hat neue Kennungen; ein Verweis auf die alte waere nach dem
* Veroeffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
* stillschweigend immer. Deshalb zwei Durchgaenge, wie beim Import.
*/
async function abschnitteKopieren(
tx: Prisma.TransactionClient,
zielVersionId: string,
abschnitte: readonly {
readonly titel: string;
readonly nurFuerAbteilungen: readonly string[];
readonly bedingung: unknown;
readonly bausteinVersionId?: string | null;
readonly schritte: readonly {
readonly id: string;
readonly titel: string;
readonly hilfetext: string | null;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: unknown;
readonly dokuUrl: string | null;
readonly bedingung: unknown;
}[];
}[],
): Promise<void> {
/*
* Alte Kennung → neue. Die alten Bedingungen zeigen auf die alten Kennungen;
* aufgeloest wird im zweiten Durchgang.
*/
const neueVonAlt = new Map<string, string>();
const zeilen: { readonly id: string; readonly stelle: number }[] = [];
for (const [index, abschnitt] of abschnitte.entries()) {
const kopie = await tx.vorlagenAbschnitt.create({
data: {
versionId: zielVersionId,
titel: abschnitt.titel,
position: index,
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...(abschnitt.bausteinVersionId === undefined
? {}
: { bausteinVersionId: abschnitt.bausteinVersionId }),
},
});
zeilen.push({ id: kopie.id, stelle: index });
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
const schrittKopie = await tx.vorlagenSchritt.create({
data: {
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
},
});
neueVonAlt.set(schritt.id, schrittKopie.id);
}
}
/** Eine Bedingung auf die neuen Kennungen umschreiben, oder weglassen. */
const umschreiben = (roh: unknown): Prisma.InputJsonObject | undefined => {
const bedingung = bedingungLesen(roh);
if (!bedingung) return undefined;
if (bedingung.art === "variable") return bedingung as unknown as Prisma.InputJsonObject;
const neueId = neueVonAlt.get(bedingung.schrittId);
if (!neueId) return undefined;
return { ...bedingung, schrittId: neueId } as unknown as Prisma.InputJsonObject;
};
for (const [index, abschnitt] of abschnitte.entries()) {
const zeile = zeilen[index];
const fuerAbschnitt = umschreiben(abschnitt.bedingung);
if (zeile && fuerAbschnitt) {
await tx.vorlagenAbschnitt.update({
where: { id: zeile.id },
data: { bedingung: fuerAbschnitt },
});
}
for (const schritt of abschnitt.schritte) {
const fuerSchritt = umschreiben(schritt.bedingung);
const neueId = neueVonAlt.get(schritt.id);
if (neueId && fuerSchritt) {
await tx.vorlagenSchritt.update({ where: { id: neueId }, data: { bedingung: fuerSchritt } });
}
}
}
}
/** /**
* Aendert die Kopfdaten einer Vorlage. * Aendert die Kopfdaten einer Vorlage.
* *
@@ -345,27 +236,27 @@ export async function entwurfHolenOderErzeugen(
} }
} }
/** type VersionMitInhalt = {
* Was eine Version mitbringen muss, damit sie kopiert werden kann. readonly version: number;
* readonly variablen: readonly {
* **Aus Prisma abgeleitet und nicht von Hand geschrieben** — und das ist die schluessel: string;
* Lehre aus zwei stillen Datenverlusten. Hier stand ein selbst getippter Typ, beschriftung: string;
* der weniger Felder nannte, als die Abfrage liefert: `nurFuerAbteilungen` (seit typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
* #031) und `bedingung` (seit #039) fehlten darin. Die Abfrage holte sie, der optionen: string[];
* Typ warf sie weg, und `abschnitteKopieren` konnte sie nicht sehen — „Bearbeiten“ pflicht: boolean;
* einer Vorlage verlor damit **alle** Abteilungseinschraenkungen, ohne dass }[];
* etwas rot wurde. readonly abschnitte: readonly {
* titel: string;
* Ein abgeleiteter Typ kann das nicht: ein neues Feld am Modell ist hier schritte: readonly {
* automatisch vorhanden, und wer es beim Kopieren vergisst, faellt im Test titel: string;
* „die Kopie ist inhaltsgleich“ auf. hilfetext: string | null;
*/ typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
type VersionMitInhalt = Prisma.VorlagenVersionGetPayload<{ pflicht: boolean;
include: { konfig: unknown;
variablen: true; dokuUrl: string | null;
abschnitte: { include: { schritte: true } }; }[];
}; }[];
}>; };
async function entwurfKopieren( async function entwurfKopieren(
vorlageId: string, vorlageId: string,
@@ -381,19 +272,7 @@ async function entwurfKopieren(
select: { kundeId: true }, select: { kundeId: true },
}); });
const entwurf = await tx.vorlagenVersion.create({ const entwurf = await tx.vorlagenVersion.create({
data: { data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
vorlageId,
version: naechste,
status: VersionStatus.ENTWURF,
/*
* **Die Freigabepflicht wandert mit** (#040). Ohne diese Zeile verlöre
* jedes „Bearbeiten“ sie — genau der Fehler, den #039 bei
* `nurFuerAbteilungen` und `bedingung` aufgedeckt hat, nur an einer
* anderen Spalte. Eine Vorlage, die eine Freigabe verlangt, soll sie
* nach einer Textkorrektur nicht verlieren.
*/
freigabePflicht: letzte.freigabePflicht,
},
}); });
if (letzte.variablen.length > 0) { if (letzte.variablen.length > 0) {
@@ -410,7 +289,26 @@ async function entwurfKopieren(
}); });
} }
await abschnitteKopieren(tx, entwurf.id, letzte.abschnitte); for (const [index, abschnitt] of letzte.abschnitte.entries()) {
const kopie = await tx.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
@@ -626,23 +524,6 @@ export async function entwurfSpeichern(
}); });
} }
/*
* Erst alles anlegen, dann die Bedingungen (#039).
*
* Dieser Dienst **loescht und schreibt neu** — die Schritte bekommen also
* bei jedem Speichern neue Kennungen. Eine Bedingung auf einer Kennung
* ueberlebte das nicht; in der Struktur steht deshalb ein **Titel-Pfad**,
* genau wie in einer Austauschdatei, und er wird hier aufgeloest. Dieselbe
* Funktion fuer beide Wege (`bedingungAufloesen`), sonst liefen sie
* auseinander.
*
* Zweiter Durchgang und nicht einer: eine Bedingung darf auf einen Schritt
* zeigen, der weiter unten steht — der Editor warnt davor, verbietet es aber
* nicht.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: { readonly id: string; readonly titel: string }[] = [];
for (const [index, abschnitt] of struktur.abschnitte.entries()) { for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const angelegt = await tx.vorlagenAbschnitt.create({ const angelegt = await tx.vorlagenAbschnitt.create({
data: { data: {
@@ -655,11 +536,9 @@ export async function entwurfSpeichern(
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
}, },
}); });
abschnittsZeilen.push({ id: angelegt.id, titel: abschnitt.titel }); if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) { data: abschnitt.schritte.map((schritt, schrittIndex) => ({
const angelegterSchritt = await tx.vorlagenSchritt.create({
data: {
abschnittId: angelegt.id, abschnittId: angelegt.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -668,33 +547,8 @@ export async function entwurfSpeichern(
konfig: schritt.konfig as Prisma.InputJsonValue, konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittIndex, position: schrittIndex,
}, })),
}); });
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegterSchritt.id);
}
}
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const zeile = abschnittsZeilen[index];
if (zeile && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: zeile.id },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
} }
} }
@@ -721,14 +575,6 @@ export async function versionVeroeffentlichen(
versionId: string, versionId: string,
aenderungsnotiz: string | null, aenderungsnotiz: string | null,
durchBenutzerId: string, durchBenutzerId: string,
/**
* Verlangt diese Version eine Freigabe (#040)?
*
* `undefined` laesst den Wert des Entwurfs stehen — so verhalten sich alle
* Aufrufer von vor #040 unveraendert, und der Wert, der beim Bearbeiten
* mitgewandert ist, bleibt erhalten.
*/
freigabePflicht?: boolean,
): Promise<VorlagenVersion> { ): Promise<VorlagenVersion> {
const notiz = aenderungsnotiz?.trim() || null; const notiz = aenderungsnotiz?.trim() || null;
@@ -769,8 +615,6 @@ export async function versionVeroeffentlichen(
aenderungsnotiz: notiz, aenderungsnotiz: notiz,
veroeffentlichtAm: new Date(), veroeffentlichtAm: new Date(),
veroeffentlichtVonId: durchBenutzerId, veroeffentlichtVonId: durchBenutzerId,
// `undefined` laesst den Wert des Entwurfs stehen (#040).
...(freigabePflicht === undefined ? {} : { freigabePflicht }),
}, },
}); });
if (getroffen.count === 0) { if (getroffen.count === 0) {
@@ -896,13 +740,7 @@ export async function vorlageDuplizieren(
}, },
}); });
const version = await tx.vorlagenVersion.create({ const version = await tx.vorlagenVersion.create({
data: { data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF },
vorlageId: kopie.id,
version: 1,
status: VersionStatus.ENTWURF,
// Wandert mit, wie beim Entwurf (#040).
freigabePflicht: letzte.freigabePflicht,
},
}); });
if (letzte.variablen.length > 0) { if (letzte.variablen.length > 0) {
@@ -918,7 +756,25 @@ export async function vorlageDuplizieren(
})), })),
}); });
} }
await abschnitteKopieren(tx, version.id, letzte.abschnitte); for (const [index, abschnitt] of letzte.abschnitte.entries()) {
const abschnittKopie = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: abschnittKopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
-69
View File
@@ -1,69 +0,0 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import { zuordnungLesen } from "@/lib/verzeichnis";
import { graphEintraegeStroemen } from "@/server/graph/leser";
import { graphZugangAus } from "@/server/graph/zugang";
import { eintraegeStroemen } from "@/server/ldap/leser";
import type { RoherEintrag, Uebersprungen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
import { ldapVorgabeAus, zugangAus } from "@/server/verzeichnis/zugang";
/**
* Einträge lesen, egal woher (#028).
*
* **Das ist die ganze Weiche.** Der Abgleich in `services/abgleich.ts` ruft nur
* noch diese Funktion; was er danach tut — anlegen, übernehmen, aktualisieren,
* deaktivieren, **nie löschen** — ist für LDAP und Graph dasselbe.
*
* Genau so steht es im Issue, und es ist die wichtigste Entscheidung dieses
* Meilensteins: zwei Abgleiche nebeneinander würden auseinanderdriften. Der eine
* bekäme einen Reviewbefund nachgetragen, der andere nicht — und die Zusage
* „es wird nichts gelöscht“ gälte dann für eine Art und für die andere nicht
* mehr ganz.
*/
export type QuellenBilanz = {
readonly gesamtGelesen: number;
readonly uebersprungen: readonly Uebersprungen[];
};
/**
* Läuft über die Einträge einer Quelle und gibt jeden einzeln weiter.
*
* `grenze` zählt die zugeordneten Einträge — „die ersten zehn Treffer zeigen“.
*/
export async function eintraegeDerQuelleStroemen(
quelle: Verzeichnisquelle,
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
grenze?: number,
): Promise<QuellenBilanz> {
if (quelle.art === "LDAP") {
return eintraegeStroemen(zugangAus(quelle), ldapVorgabeAus(quelle), verarbeiten, grenze);
}
if (quelle.art === "GRAPH") {
const bilanz = await graphEintraegeStroemen(
graphZugangAus(quelle),
zuordnungLesen(quelle.attributZuordnung, "GRAPH"),
verarbeiten,
grenze,
);
// `seiten` interessiert nur den Graph-Test; der Abgleich rechnet mit zwei Zahlen.
return { gesamtGelesen: bilanz.gesamtGelesen, uebersprungen: bilanz.uebersprungen };
}
/*
* **Eine SCIM-Quelle wird nicht gelesen, sie liefert** (#029) — es gibt hier
* nichts abzuholen, und das bleibt so. Trotzdem eine Meldung und kein stilles
* Nichts: ein Abgleich, der null Einträge liest und `ERFOLG` meldet, würde
* über die Abschaltregel in `abgleichAusfuehren` die ganze Belegschaft
* deaktivieren. Genau dieser Fall hat in #016 einen Reviewbefund gekostet.
*
* Der Zeitplan kommt hier nicht an: `faelligeQuellen` nimmt SCIM-Quellen
* nicht auf. Diese Stelle fängt den Weg über „Jetzt abgleichen“.
*/
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine SCIM-Quelle: Laufzettel holt dort nichts ab, sondern wird ` +
"beliefert. Ein Abgleich gibt es für sie nicht — der Anbieter schickt Änderungen, sobald " +
"sie dort entstehen.",
);
}
-74
View File
@@ -1,74 +0,0 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import type { QuellenArt } from "@/generated/prisma/enums";
import { entschluesseln } from "@/lib/geheimnisse";
import { type AttributZuordnung, zuordnungLesen } from "@/lib/verzeichnis";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
import type { LdapZugang } from "@/server/ldap/verbindung";
/**
* Aus einer Quellenzeile wird ein Zugang (#016, je Art seit #028).
*
* **Warum ein eigenes Modul und nicht weiter in `services/verzeichnis.ts`:** die
* Weiche in `quellenleser.ts` braucht diese Funktionen, und
* `services/verzeichnis.ts` braucht die Weiche fuer „Verbindung testen“. Beides
* in einer Datei waere ein Import-Zyklus — der laeuft in ESM oft, aber nicht
* zuverlaessig, und ein Modul, das beim Laden halb leer ist, ergibt Fehler, die
* niemand liest.
*/
/**
* Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette.
*
* Wirft, wenn die LDAP-Felder fehlen. Seit #028 sind sie nur bei `art = LDAP`
* belegt — die Datenbank haelt das fest (CHECK
* `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer Graph-Zeile waere
* ein Programmierfehler, und der soll nicht als Verbindung zu `null` weiterlaufen.
*/
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
if (!quelle.url || !quelle.bindDn || !quelle.bindPasswortVerschluesselt) {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist keine LDAP-Quelle oder hat keine vollständigen Zugangsdaten.`,
);
}
return {
url: quelle.url,
bindDn: quelle.bindDn,
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
caZertifikatPem: quelle.caZertifikatPem,
};
}
/**
* Die LDAP-Lesevorgabe einer Quelle.
*
* Dieselbe Pruefung und derselbe Grund wie bei `zugangAus`: `basisDn` und
* `filter` sind seit #028 nullable.
*/
export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
readonly basisDn: string;
readonly filter: string;
readonly zuordnung: AttributZuordnung;
} {
if (!quelle.basisDn || !quelle.filter) {
throw new VerzeichnisFehler(
`„${quelle.name}“ fehlt der Basis-DN oder der Filter — beides braucht eine LDAP-Quelle.`,
);
}
return {
basisDn: quelle.basisDn,
filter: quelle.filter,
zuordnung: zuordnungLesen(quelle.attributZuordnung, "LDAP"),
};
}
/**
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
*
* `SCIM` hat keine und faellt auf die LDAP-Namen zurueck (#029): bei einem Push
* bestimmt der Anbieter die Feldnamen, sie stehen in RFC 7643 und sind nicht
* einstellbar. Ein Wurf an dieser Stelle machte jede Liste unbenutzbar, in der
* eine SCIM-Zeile steht.
*/
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
return art === "GRAPH" ? "GRAPH" : "LDAP";
}

Some files were not shown because too many files have changed in this diff Show More