Compare commits
10
Commits
4aa79c93ff
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60aaf746cf | ||
|
|
150188274a | ||
|
|
75c335df23 | ||
|
|
80d97467da | ||
|
|
fcd67018b8 | ||
|
|
76a61ec771 | ||
|
|
0158d0292f | ||
|
|
a417426f43 | ||
|
|
1be73b266c | ||
|
|
3ccf3aa2d9 |
@@ -19,6 +19,34 @@ APP_SECRET=
|
|||||||
#
|
#
|
||||||
# DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`,
|
# DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`,
|
||||||
# `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die
|
# `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die
|
||||||
|
# ---- Anhaenge (#032) -------------------------------------------------------
|
||||||
|
# Wo die Dateien der Anhaenge liegen. Im Compose-Stand setzt docker-compose.yml
|
||||||
|
# das selbst auf /daten/anhaenge im Volume `anhaenge`; hier gilt es fuer die
|
||||||
|
# Entwicklung und die native Installation.
|
||||||
|
#
|
||||||
|
# **Gehoert in jede Sicherung.** In der Datenbank stehen nur die Metadaten.
|
||||||
|
ANHAENGE_PFAD=./daten/anhaenge
|
||||||
|
# Hoechstgroesse je Datei in MB und Hoechstzahl Anhaenge je Lauf.
|
||||||
|
ANHANG_MAX_MB=10
|
||||||
|
ANHAENGE_JE_LAUF=20
|
||||||
|
#
|
||||||
|
# **Wer das anhebt, hebt `client_max_body_size` im Reverse Proxy mit an.** Sonst
|
||||||
|
# weist nginx die Datei mit 413 ab, bevor die Anwendung ihre eigene, freundliche
|
||||||
|
# Meldung zeigen kann — siehe docs/betrieb/reverse-proxy.md.
|
||||||
|
|
||||||
|
# ---- Live-Aktualisierung (#033) --------------------------------------------
|
||||||
|
# Die Abarbeitungsansicht haelt einen Ereignisstrom offen, statt alle zehn
|
||||||
|
# Sekunden zu fragen. Beide Werte brauchen im Normalfall niemand anzufassen.
|
||||||
|
#
|
||||||
|
# Abstand der Herzschlaege in Millisekunden. Muss **unter** der Leerlaufzeit des
|
||||||
|
# Reverse Proxy liegen (nginx und Caddy: 60 s), sonst schliesst der die stille
|
||||||
|
# Verbindung.
|
||||||
|
SSE_HERZSCHLAG_MS=25000
|
||||||
|
# Hoechstzahl gleichzeitig offener Stroeme je Instanz. Ist sie erreicht, fallen
|
||||||
|
# weitere Laschen von sich aus auf das Polling zurueck — die Seite bleibt
|
||||||
|
# bedienbar, nur nicht mehr sofort aktuell.
|
||||||
|
SSE_MAX_STROEME=200
|
||||||
|
|
||||||
# Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT).
|
# Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT).
|
||||||
POSTGRES_USER=laufzettel
|
POSTGRES_USER=laufzettel
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ blob-report/
|
|||||||
# Release liegt es bei, so steht es im Auftrag.
|
# Release liegt es bei, so steht es im Auftrag.
|
||||||
/docs/anleitung/*.pdf
|
/docs/anleitung/*.pdf
|
||||||
|
|
||||||
|
# Hochgeladene Anhaenge (#032). Nutzdaten, nie Quellcode – in der Entwicklung
|
||||||
|
# landen sie unter `daten/anhaenge`, im Betrieb ausserhalb des Arbeitsbaums.
|
||||||
|
/daten/
|
||||||
|
|
||||||
# Werkzeuge
|
# Werkzeuge
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+162
@@ -7,8 +7,38 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
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
|
||||||
|
zehn Sekunden. Dahinter stehen vier Datenbank-Trigger mit `NOTIFY` und ein Ereignisstrom; das
|
||||||
|
funktioniert auch mit mehreren Instanzen der Anwendung, ohne einen zweiten Dienst. Zwanzig offene
|
||||||
|
Laschen kosten jetzt nichts mehr, statt zwanzig Abfragen je zehn Sekunden.
|
||||||
|
**Das Polling bleibt als Rückfall** an derselben Adresse, mit derselben Rechteprüfung: wer kein
|
||||||
|
`EventSource` hat oder hinter einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg. Neu in
|
||||||
|
der Umgebung: `SSE_HERZSCHLAG_MS` (25000) und `SSE_MAX_STROEME` (200) (#033)
|
||||||
|
- Der Zustand eines Laufs trägt jetzt auch die Zahl der **Anhänge**. Ohne sie wäre ein angehängtes
|
||||||
|
Foto für die zweite Sitzung unsichtbar geblieben — die Anhänge kamen in #032 dazu, der Zustand
|
||||||
|
kannte sie nicht (#033)
|
||||||
|
- **`docs/betrieb/sicherung.md` ist neu geschrieben.** Dort stand „Kein Upload-Verzeichnis, keine
|
||||||
|
erzeugten Dateien“ — mit den Anhängen ist das falsch, und eine Sicherung nach der alten Anleitung
|
||||||
|
hätte ab #032 still Daten verloren: in der Datenbank stehen nur die Metadaten. Jetzt sind es drei
|
||||||
|
Dinge statt zwei, mit `tar` für beide Betriebswege, dem `chown`-Fallstrick nach einer
|
||||||
|
Rücksicherung als `root`, einem Sicherungsskript, das Datenbank und Anhänge im **selben** Lauf
|
||||||
|
mitnimmt, und einer Gegenprobe über die Zahlen. Sicherung und Rückspielung sind einmal wirklich
|
||||||
|
gefahren (#032)
|
||||||
- Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
|
- Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
|
||||||
sich vorher nirgends speichern ließ (#020)
|
sich vorher nirgends speichern ließ (#020)
|
||||||
- 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
|
||||||
@@ -24,8 +54,140 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- 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 (#040)
|
verlangt (#040)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
`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
|
||||||
|
Anwendung wäre nie zum Zuge gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören
|
||||||
|
(#033)
|
||||||
|
|
||||||
### 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
|
||||||
|
die Übersicht öffnete; ein überfälliger überhaupt nicht. Vier Anlässe gehen jetzt hinaus —
|
||||||
|
**zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je
|
||||||
|
Person abschaltbar, und **niemand bekommt Post über die eigene Tat**. Mail über SMTP (Zugangsdaten
|
||||||
|
verschlüsselt wie das LDAP-Bind-Passwort, mit „Testmail schicken“), dazu optional ein Webhook mit
|
||||||
|
HMAC-SHA256-Signatur im Kopf `X-Laufzettel-Signatur`.
|
||||||
|
Zugestellt wird aus einer **Warteschlange in der Datenbank**, nicht im Anfrage-Pfad: ein hängender
|
||||||
|
Mailserver darf kein Abhaken blockieren. Fehlschläge werden nach 1, 5, 30 und 120 Minuten
|
||||||
|
wiederholt und bleiben danach als **aufgegeben** sichtbar — unter Verwaltung → Zustellungen, denn
|
||||||
|
eine stille Warteschlange ist schlimmer als keine.
|
||||||
|
**In keiner Nachricht steht ein Schrittinhalt**, und damit auch kein erzeugtes Passwort: geprüft
|
||||||
|
über alle Rumpftexte, die wirklich verschickt wurden (#034)
|
||||||
|
- **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei
|
||||||
|
Übergabe“ — manches lässt sich nicht abhaken, es muss gezeigt werden. 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, Größe und Prüfsumme. Jeder Abruf prüft Recht und Mandantengrenze — auch der des Bildes
|
||||||
|
selbst. Grenzen über `ANHANG_MAX_MB` (10) und `ANHAENGE_JE_LAUF` (20), und eine abgewiesene Datei
|
||||||
|
nennt den Grund. Kein SVG: das ist ein Dokument, das Skripte ausführt. Der Archiv-Schreibschutz
|
||||||
|
gilt auch hier, und die Druckansicht zeigt Bilder als Miniatur, ein PDF als Zeile (#032)
|
||||||
- **Abschnitte nur für bestimmte Abteilungen.** Beim Onboarding in der Buchhaltung gehören Schritte
|
- **Abschnitte nur für bestimmte Abteilungen.** Beim Onboarding in der Buchhaltung gehören Schritte
|
||||||
dazu, die sonst niemand braucht. Bisher hieß die Wahl: eine Vorlage je Abteilung pflegen, oder
|
dazu, die sonst niemand braucht. Bisher hieß die Wahl: eine Vorlage je Abteilung pflegen, oder
|
||||||
Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche
|
Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche
|
||||||
|
|||||||
@@ -38,14 +38,18 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
|
|||||||
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
|
||||||
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
|
||||||
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
|
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
|
||||||
|
| — (ergänzt) | `Zustellung` | | |
|
||||||
|
|
||||||
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
|
||||||
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
|
`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}` ·
|
||||||
|
`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.
|
||||||
|
|
||||||
@@ -56,7 +60,8 @@ src/server/services/* Regeln und Schreiboperationen. Werfen eigene …Fehler-K
|
|||||||
src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an.
|
src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an.
|
||||||
src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service.
|
src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service.
|
||||||
src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar.
|
src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar.
|
||||||
src/app/api/* Route Handler. Nur Polling, QR-SVG, CSV, Auth, Healthcheck. Minimal.
|
src/app/api/* Route Handler. Nur Zustandsstrom, QR-SVG, CSV, Anhaenge, Auth,
|
||||||
|
Healthcheck. Minimal.
|
||||||
src/components/* Darstellung. Keine Geschäftslogik.
|
src/components/* Darstellung. Keine Geschäftslogik.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,12 +80,19 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
|||||||
- **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
|
||||||
@@ -97,9 +109,11 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
|||||||
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
|
||||||
ist darstellbar.
|
ist darstellbar.
|
||||||
- **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei
|
- **Regeln stehen in der Datenbank, nicht nur im Service.** CHECK-Bedingungen, partielle
|
||||||
partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch
|
Eindeutigkeitsindizes, zusammengesetzte Fremdschlüssel und Trigger halten fest, was auch
|
||||||
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist
|
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt.
|
||||||
|
`tests/datenbankregeln.test.ts` zählt sie **namentlich** auf — dort steht die jeweils aktuelle
|
||||||
|
Liste, und genau deshalb steht hier keine Zahl mehr: sie lief dreimal der Wirklichkeit nach. Die Mandantengrenze ist
|
||||||
einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines
|
einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines
|
||||||
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
|
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
|
||||||
|
|
||||||
@@ -121,6 +135,12 @@ Zwei Obergrenzen, die nicht angehoben werden dürfen:
|
|||||||
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
|
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
|
||||||
(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr).
|
(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr).
|
||||||
|
|
||||||
|
**`minimumReleaseAgeExclude` bleibt leer.** pnpm 12 nimmt Pakete erst nach einer Wartezeit an — ein
|
||||||
|
Schutz gegen ein kompromittiertes Release, das Stunden später zurückgezogen wird. Trägt `pnpm add`
|
||||||
|
dort von sich aus eine Ausnahme ein, ist das kein Formfehler, sondern die Meldung „diese Version ist
|
||||||
|
frisch von heute“. Dann gehört **eine abgelagerte Version** gepinnt, nicht die Ausnahme committet.
|
||||||
|
`nodemailer` steht deshalb exakt auf `10.0.10` und nicht auf der neuesten Fassung (#034).
|
||||||
|
|
||||||
## Next 16
|
## Next 16
|
||||||
|
|
||||||
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
|
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
|
||||||
@@ -173,18 +193,71 @@ 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
|
||||||
docker compose -f docker-compose.test.yml up -d --wait ldap
|
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit
|
||||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
||||||
```
|
```
|
||||||
|
|
||||||
**Der LDAP-Container gehört in die erste Zeile.** Ohne ihn überspringt die Suite **22 Tests** — den
|
**Die beiden Container gehören in die erste Zeile.** Ohne sie überspringt die Suite Tests, sagt das
|
||||||
ganzen Verzeichnis-Abgleich (nachgezählt: 81 statt 59 in `tests/abgleich.test.ts` und
|
auf stderr — und sieht trotzdem grün aus. Wer nur diesem Abschnitt folgte, hätte das Betroffene nie
|
||||||
`tests/verzeichnis.test.ts`). Sie sagt das auf stderr, aber der Lauf sieht grün aus, und wer nur
|
geprüft. In CI bricht sie stattdessen ab, absichtlich.
|
||||||
diesem Abschnitt folgte, hat den Abgleich nie geprüft. In CI bricht sie stattdessen ab, absichtlich.
|
|
||||||
Aufgefallen bei der Abnahme zu #019.
|
| Container | Fehlt er, fehlen | Betroffen |
|
||||||
|
|---|---|---|
|
||||||
|
| `ldap` | **22 Tests** (nachgezählt: 81 statt 59) | der ganze Verzeichnis-Abgleich, `tests/abgleich.test.ts` und `tests/verzeichnis.test.ts` |
|
||||||
|
| `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**,
|
||||||
|
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
|
||||||
|
|||||||
@@ -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:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in
|
> **Stand:** `v0.1.0` ist veröffentlicht, Phase 2 liegt auf `main` und wird `v0.2.0`. Der
|
||||||
> [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im
|
> Fortschritt steht in [`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 AD per LDAP |
|
| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes Verzeichnis per LDAP, Microsoft Graph oder SCIM |
|
||||||
| `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
|
||||||
@@ -45,12 +45,13 @@ openssl rand -base64 48
|
|||||||
### Qualitätsschranken
|
### Qualitätsschranken
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.test.yml up -d --wait ldap # sonst fehlen 22 Tests
|
docker compose -f docker-compose.test.yml up -d --wait ldap mailpit # sonst fehlen 25 Tests
|
||||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
|
||||||
```
|
```
|
||||||
|
|
||||||
Der LDAP-Container ist keine Zierde: ohne ihn überspringt die Suite den ganzen Verzeichnis-Abgleich
|
Die beiden Container sind keine Zierde: ohne `ldap` überspringt die Suite den ganzen
|
||||||
— sie sagt das auf stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
|
Verzeichnis-Abgleich (22 Tests), ohne `mailpit` den echten Mailversand (3 Tests) — sie sagt das auf
|
||||||
|
stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
|
||||||
|
|
||||||
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
|
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
|
||||||
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
|
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
|
||||||
@@ -157,15 +158,48 @@ 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-Abgleich (nur Modus `INTERN`)
|
## Verzeichnis (nur Modus `INTERN`)
|
||||||
|
|
||||||
Unter **Admin → Verzeichnis** wird eine LDAP-Quelle angelegt: Adresse (`ldaps://` empfohlen,
|
Unter **Admin → Verzeichnis** wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden
|
||||||
`ldap+starttls://` und `ldap://` gehen auch), ein Dienstkonto mit **Leserecht**, der Basis-DN und
|
sich in der **Richtung**:
|
||||||
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.
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ readonly STANDALONE="$ZIEL/.next/standalone"
|
|||||||
# Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt
|
# Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt
|
||||||
# sauber und `git pull` findet nichts Fremdes vor.
|
# sauber und `git pull` findet nichts Fremdes vor.
|
||||||
readonly HEIM="/var/cache/laufzettel"
|
readonly HEIM="/var/cache/laufzettel"
|
||||||
|
# Die Dateien der Anhaenge (#032). Unter /var/lib und nicht unter /opt: /opt ist
|
||||||
|
# der Quellcode und wird bei jedem Aktualisieren angefasst, /var/lib ist
|
||||||
|
# veraenderlicher Zustand, der bleibt. **Gehoert in jede Sicherung** -- in der
|
||||||
|
# Datenbank stehen nur die Metadaten.
|
||||||
|
readonly ANHAENGE="/var/lib/laufzettel/anhaenge"
|
||||||
readonly ETC_VERZEICHNIS="/etc/laufzettel"
|
readonly ETC_VERZEICHNIS="/etc/laufzettel"
|
||||||
readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env"
|
readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env"
|
||||||
readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service"
|
readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service"
|
||||||
@@ -330,6 +335,9 @@ nutzerAnlegen() {
|
|||||||
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0755 "$ZIEL"
|
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0755 "$ZIEL"
|
||||||
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM"
|
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM"
|
||||||
install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS"
|
install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS"
|
||||||
|
# 0750 und nicht 0755: ein Anhang kann ein Foto eines Typenschilds oder eine
|
||||||
|
# Rechnung sein. Nur der Dienstnutzer liest hier.
|
||||||
|
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$ANHAENGE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- Schritt 4: Geheimnisse und Umgebungsdatei -----------------------
|
# ---------- Schritt 4: Geheimnisse und Umgebungsdatei -----------------------
|
||||||
@@ -365,6 +373,11 @@ DATABASE_URL="postgresql://$DB_ROLLE:$dbPasswort@localhost/$DATENBANK?host=$SOCK
|
|||||||
HOSTNAME="$bindAdresse"
|
HOSTNAME="$bindAdresse"
|
||||||
PORT="$port"
|
PORT="$port"
|
||||||
|
|
||||||
|
# Wo die Dateien der Anhaenge liegen (#032). In der Datenbank stehen nur ihre
|
||||||
|
# Metadaten -- dieses Verzeichnis gehoert deshalb in jede Sicherung, sonst
|
||||||
|
# zeigen die Anhaenge nach einer Ruecksicherung ins Leere.
|
||||||
|
ANHAENGE_PFAD="$ANHAENGE"
|
||||||
|
|
||||||
# Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese
|
# Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese
|
||||||
# Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen.
|
# Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen.
|
||||||
AUTH_LOCAL_ENABLED="true"
|
AUTH_LOCAL_ENABLED="true"
|
||||||
|
|||||||
@@ -17,6 +17,30 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
# Der Mailfaenger fuer die Benachrichtigungen (#034). Mailpit nimmt SMTP auf
|
||||||
|
# 1025 an und zeigt die Nachrichten ueber eine HTTP-Schnittstelle auf 8025 —
|
||||||
|
# darueber liest der Test, was wirklich ankam.
|
||||||
|
#
|
||||||
|
# **Ein echter SMTP-Dialog, keine Attrappe.** Eine abgefangene
|
||||||
|
# `sendMail`-Funktion haette in #034 nichts bewiesen: dass Betreff und
|
||||||
|
# Empfaenger stimmen, zeigt erst eine Nachricht, die durch Verbindungsaufbau,
|
||||||
|
# EHLO und DATA gegangen ist. Ohne Anmeldung und ohne TLS, weil der Test den
|
||||||
|
# Dialog prueft und nicht die Verschluesselung.
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: "true"
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:1025:1025"
|
||||||
|
- "127.0.0.1:8025:8025"
|
||||||
|
healthcheck:
|
||||||
|
# Mailpit bringt kein curl mit; `/livez` ueber den eigenen Client.
|
||||||
|
test: ["CMD", "/mailpit", "readyz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
# Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen
|
# Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen
|
||||||
# Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
|
# Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
|
||||||
# userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
|
# userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
|
||||||
|
|||||||
@@ -99,9 +99,19 @@ services:
|
|||||||
# Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er
|
# Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er
|
||||||
# an, weil dieser Dienst der einzige ist, der dauerhaft laeuft.
|
# an, weil dieser Dienst der einzige ist, der dauerhaft laeuft.
|
||||||
ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true}
|
ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true}
|
||||||
|
# Anhaenge und Fotos liegen im Dateisystem, nicht in der Datenbank (#032).
|
||||||
|
# Der Pfad zeigt in das Volume `anhaenge` unten.
|
||||||
|
ANHAENGE_PFAD: /daten/anhaenge
|
||||||
|
ANHANG_MAX_MB: ${ANHANG_MAX_MB:-10}
|
||||||
|
ANHAENGE_JE_LAUF: ${ANHAENGE_JE_LAUF:-20}
|
||||||
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
|
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
|
||||||
ports:
|
ports:
|
||||||
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
# **Gehoert in jede Sicherung.** Eine Sicherung, die nur `pg_dump` macht,
|
||||||
|
# verliert ab hier Daten: in der Datenbank stehen nur die Metadaten der
|
||||||
|
# Anhaenge, die Dateien liegen hier. Siehe docs/betrieb/sicherung.md.
|
||||||
|
- anhaenge:/daten/anhaenge
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -130,3 +140,6 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
datenbank:
|
datenbank:
|
||||||
|
# Die Dateien der Anhaenge (#032). Zusammen mit der Datenbank zu sichern –
|
||||||
|
# getrennt gesichert ergeben beide Haelften kein vollstaendiges Bild.
|
||||||
|
anhaenge:
|
||||||
|
|||||||
@@ -181,8 +181,18 @@ werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen.
|
|||||||
|
|
||||||
### Zwei Techniker am selben Lauf
|
### Zwei Techniker am selben Lauf
|
||||||
|
|
||||||
Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas
|
Hakt der Kollege einen Schritt ab, erscheint das bei dir **sofort** — ohne dass du etwas tun musst.
|
||||||
geändert hat. Bei verstecktem Tab pausiert die Abfrage.
|
Dasselbe gilt für Kommentare, angehängte Fotos und einen geänderten Titel. Die Seite lädt sich nur
|
||||||
|
dann neu, wenn sich wirklich etwas geändert hat; an einem Feld, in das du gerade tippst, geht dabei
|
||||||
|
nichts verloren.
|
||||||
|
|
||||||
|
Ist die Lasche im Hintergrund, pausiert das. Holst du sie zurück, steht gleich der aktuelle Stand
|
||||||
|
da.
|
||||||
|
|
||||||
|
Bleibt die Seite doch einmal stehen, obwohl jemand anders arbeitet, aktualisiert sie sich spätestens
|
||||||
|
nach zehn Sekunden von selbst — das ist der eingebaute Rückfall. Dauert es dauerhaft so lange, liegt
|
||||||
|
es meist am Reverse Proxy; der Abschnitt „Die Seite aktualisiert sich nur langsam“ in
|
||||||
|
[Fehlerbehebung](08-fehlerbehebung.md) sagt, was zu tun ist.
|
||||||
|
|
||||||
Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen
|
Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen
|
||||||
erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen
|
erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen
|
||||||
@@ -195,6 +205,33 @@ Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst
|
|||||||
entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
|
entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
|
||||||
das findet die Person in ihrer Historie später nichts wieder.
|
das findet die Person in ihrer Historie später nichts wieder.
|
||||||
|
|
||||||
|
## Fotos und Dateien an einem Schritt
|
||||||
|
|
||||||
|
Manches lässt sich nicht abhaken, es muss gezeigt werden: das Typenschild, der Zustand bei der
|
||||||
|
Übergabe, die Rechnung. An jedem Schritt stehen dafür zwei Knöpfe:
|
||||||
|
|
||||||
|
- **Foto** öffnet am Handy direkt die Kamera. Am Rechner zeigt der Browser den Dateiwähler — dort
|
||||||
|
gibt es keine Kamera-App.
|
||||||
|
- **Datei** öffnet den Dateiwähler.
|
||||||
|
|
||||||
|
Abgeschickt wird **sofort nach der Wahl**, ohne zweiten Knopf. Ein Foto, das du gerade gemacht hast,
|
||||||
|
willst du nicht noch bestätigen.
|
||||||
|
|
||||||
|
Erlaubt sind **Bilder** (JPEG, PNG, WebP, HEIC) und **PDF**, höchstens 10 MB je Datei und 20 Dateien
|
||||||
|
je Lauf. Beide Grenzen lassen sich beim Betrieb ändern (`ANHANG_MAX_MB`, `ANHAENGE_JE_LAUF`). Eine zu
|
||||||
|
große Datei wird schon im Browser abgewiesen — am Handy im Mobilfunk ist das der Unterschied zwischen
|
||||||
|
einer Meldung und einer Minute Warten.
|
||||||
|
|
||||||
|
Bilder erscheinen als Miniatur, ein PDF als Zeile mit Namen. Ein Klick öffnet die Datei in einem
|
||||||
|
neuen Tab; ein PDF wird dabei heruntergeladen statt im Browser angezeigt.
|
||||||
|
|
||||||
|
**An einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg** — dieselbe Regel wie für
|
||||||
|
Schritte und Kommentare. Ansehen geht weiter: ein Foto vom Typenschild ist Teil des Belegs.
|
||||||
|
|
||||||
|
> **Für den Betrieb wichtig:** Die Dateien liegen im Dateisystem, in der Datenbank stehen nur ihre
|
||||||
|
> Namen und Größen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert sie — siehe
|
||||||
|
> `docs/betrieb/sicherung.md`.
|
||||||
|
|
||||||
## Abschließen
|
## Abschließen
|
||||||
|
|
||||||
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
|
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
|
||||||
|
|||||||
@@ -16,6 +16,141 @@ 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
|
||||||
|
|
||||||

|

|
||||||
@@ -90,6 +225,10 @@ Namensänderung der Quelle ihre Anmeldung.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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).
|
||||||
@@ -169,3 +308,7 @@ 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.
|
||||||
|
|||||||
@@ -169,6 +169,55 @@ nicht archivieren.
|
|||||||
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
|
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
|
||||||
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
|
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
|
||||||
|
|
||||||
|
## Benachrichtigungen
|
||||||
|
|
||||||
|
Ein zugewiesener Lauf fiel früher nur auf, wenn jemand die Übersicht öffnete. Ein überfälliger
|
||||||
|
überhaupt nicht. Laufzettel meldet deshalb vier Dinge — und nur diese vier:
|
||||||
|
|
||||||
|
| Anlass | Wer bekommt es |
|
||||||
|
|---|---|
|
||||||
|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
|
||||||
|
| Ein Lauf ist **überfällig** | der Zugewiesene, einmal je Lauf |
|
||||||
|
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
|
||||||
|
| Ein **Verzeichnisabgleich schlägt fehl** | alle Administratoren |
|
||||||
|
|
||||||
|
**Über die eigene Tat bekommt niemand Post.** Wer selbst zuweist, abschließt oder abhakt, erfährt es
|
||||||
|
nicht per Mail — das ist der häufigste Grund, Benachrichtigungen ganz abzuschalten.
|
||||||
|
|
||||||
|
**In der Nachricht stehen Titel, Status, Gerät, Person, Frist und ein Link.** Keine Schrittinhalte,
|
||||||
|
und damit auch kein erzeugtes Passwort. Das Passwort entsteht bei jeder Anzeige neu; eine Mail
|
||||||
|
dagegen bleibt für immer in einem Postfach.
|
||||||
|
|
||||||
|
### Einrichten
|
||||||
|
|
||||||
|
Unter **Verwaltung → Einstellungen**, Abschnitt **Benachrichtigungen**: Host, Port, Benutzer,
|
||||||
|
Passwort und Absender des Mailservers. Dann **Speichern**, dann **Testmail schicken** — die geht
|
||||||
|
sofort hinaus, nicht über die Warteschlange, und ein Fehler erscheint im Klartext.
|
||||||
|
|
||||||
|
Daneben steht ein **Webhook**: dieselben vier Anlässe als JSON an eine Adresse, mit Signatur. Das ist
|
||||||
|
für Ticketsysteme und Chat-Räume gedacht; die Einzelheiten stehen in
|
||||||
|
[`docs/betrieb/benachrichtigungen.md`](../betrieb/benachrichtigungen.md).
|
||||||
|
|
||||||
|
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist.** Leer heißt „unverändert lassen“ —
|
||||||
|
sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
|
||||||
|
|
||||||
|
### Abschalten
|
||||||
|
|
||||||
|
Unter **Verwaltung → Benutzer** schaltet der **Mail**-Knopf an einer Person die Benachrichtigungen
|
||||||
|
ab. Das geht auch für das eigene Konto. Wer abgeschaltet hat, bekommt nichts und löst auch keinen
|
||||||
|
Fehler aus.
|
||||||
|
|
||||||
|
### Zustellungen
|
||||||
|
|
||||||
|
Unter **Verwaltung → Zustellungen** steht, was hinausgegangen ist und was nicht. Schlägt eine
|
||||||
|
Zustellung fehl, versucht Laufzettel es nach 1, 5, 30 und 120 Minuten erneut; nach dem fünften
|
||||||
|
Fehlschlag bleibt sie als
|
||||||
|
**aufgegeben** stehen.
|
||||||
|
|
||||||
|
**Da hinzusehen lohnt, wenn jemand sagt, er bekomme keine Mails.** Fast immer ist das SMTP-Passwort
|
||||||
|
abgelaufen — und ohne diese Seite merkt es wochenlang niemand, weil die Läufe weiter funktionieren.
|
||||||
|
Ganz oben steht ein Hinweis, sobald etwas aufgegeben wurde.
|
||||||
|
|
||||||
## Protokoll
|
## Protokoll
|
||||||
|
|
||||||
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
|
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
|
||||||
|
|||||||
@@ -77,6 +77,58 @@ Zurückholen wird protokolliert.
|
|||||||
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
|
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
|
||||||
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
|
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
|
||||||
|
|
||||||
|
## Die Seite aktualisiert sich nur langsam
|
||||||
|
|
||||||
|
**Was du siehst:** Der Kollege hakt einen Schritt ab, und bei dir erscheint es erst nach rund zehn
|
||||||
|
Sekunden statt sofort. Alles funktioniert, es ist nur träge.
|
||||||
|
|
||||||
|
**Ursache:** Die Seite hält normalerweise eine offene Verbindung zum Server, über die Änderungen
|
||||||
|
sofort ankommen. Kommt die nicht zustande, schaltet sie von selbst auf Nachfragen im
|
||||||
|
Zehn-Sekunden-Takt um — die Arbeit geht also weiter, nur nicht in Echtzeit. Fast immer liegt das am
|
||||||
|
Reverse Proxy, der die Verbindung sammelt statt sie durchzulassen.
|
||||||
|
|
||||||
|
**Was zu tun ist** (für Administratoren): In `docs/betrieb/reverse-proxy.md` stehen die beiden
|
||||||
|
nötigen Zeilen — `proxy_buffering off` für nginx, `flush_interval -1` für Caddy — und ein
|
||||||
|
Mindestwert für `proxy_read_timeout`. Fällt der unter 30 Sekunden, reißt die Verbindung zwischen
|
||||||
|
zwei Herzschlägen immer wieder ab.
|
||||||
|
|
||||||
|
**Zweitursache:** Sehr viele gleichzeitig offene Laschen. Ab einer Obergrenze (`SSE_MAX_STROEME`,
|
||||||
|
voreingestellt 200 je Instanz) bekommen weitere Laschen bewusst den langsamen Weg, damit der Server
|
||||||
|
erreichbar bleibt. Dann hilft, nicht mehr gebrauchte Laschen zu schließen.
|
||||||
|
|
||||||
|
**Was es nicht ist:** ein Datenverlust. Gespeichert wird beim Klick, nicht beim Aktualisieren. Was du
|
||||||
|
siehst, kann veraltet sein; was in der Datenbank steht, ist es nie.
|
||||||
|
|
||||||
|
## „Die Datei zu … fehlt im Dateisystem“
|
||||||
|
|
||||||
|
**Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast
|
||||||
|
immer heißt das: es wurde eine Sicherung eingespielt, die nur die Datenbank enthielt. Die Dateien der
|
||||||
|
Anhänge liegen im Dateisystem, nicht in der Datenbank.
|
||||||
|
|
||||||
|
**Was tun:** Das Verzeichnis aus derselben Sicherungsrunde zurückspielen —
|
||||||
|
`docs/betrieb/sicherung.md` beschreibt beide Wege. Gibt es kein Archiv der Dateien, sind sie weg; die
|
||||||
|
Zeilen lassen sich dann nur entfernen, indem jemand den Anhang in der Oberfläche löscht.
|
||||||
|
|
||||||
|
**Zweitursache, seltener:** nach einer Rücksicherung gehören die Dateien `root` statt dem
|
||||||
|
Dienstnutzer. Der Dienst kommt dann nicht an sie heran, obwohl sie daliegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ls -l /var/lib/laufzettel/anhaenge
|
||||||
|
sudo chown -R laufzettel:laufzettel /var/lib/laufzettel/anhaenge
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorbeugen:** Datenbank und Anhänge im **selben** Skript sichern, direkt hintereinander. Getrennt
|
||||||
|
gesichert passen die beiden Hälften irgendwann nicht mehr zusammen.
|
||||||
|
|
||||||
|
## „Dateien vom Typ … lassen sich nicht anhängen“
|
||||||
|
|
||||||
|
**Ursache:** Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF. Alles andere wird abgewiesen —
|
||||||
|
auch SVG, und das ist Absicht: ein SVG ist ein Dokument, das Skripte ausführen kann, und ein Anhang,
|
||||||
|
den jemand öffnet, soll kein Angriffsweg sein.
|
||||||
|
|
||||||
|
**Was tun:** Ein Office-Dokument oder eine Textdatei als PDF ausgeben. Ein Screenshot wird zu PNG,
|
||||||
|
sobald du ihn speicherst statt ihn aus der Zwischenablage zu ziehen.
|
||||||
|
|
||||||
## „Kein Laufzettel-Tag“ nach dem Scannen
|
## „Kein Laufzettel-Tag“ nach dem Scannen
|
||||||
|
|
||||||
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
|
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Meine Geräte
|
||||||
|
|
||||||
|
> **Dieses Kapitel ist für dich, wenn du kein IT-Mitarbeiter bist.** Alle anderen Kapitel richten
|
||||||
|
> sich an die Leute, die Laufzettel bedienen — dieses hier an die, über die darin etwas steht.
|
||||||
|
|
||||||
|
Wenn die IT an deinem Rechner gearbeitet hat, hält sie das in Laufzettel fest: was gemacht wurde,
|
||||||
|
wann und mit welchem Gerät. Unter **Meine Geräte** siehst du den Teil davon, der dich betrifft.
|
||||||
|
|
||||||
|
## Anmelden
|
||||||
|
|
||||||
|
Du meldest dich mit demselben Konto an wie überall sonst im Haus — über den Knopf **Mit dem Anbieter
|
||||||
|
anmelden**. Ein eigenes Passwort für Laufzettel gibt es nicht.
|
||||||
|
|
||||||
|
Danach landest du direkt auf **Meine Geräte**. Mehr gibt es für dich nicht zu sehen, und das ist
|
||||||
|
keine Einschränkung aus Versehen: alles andere in Laufzettel sind Arbeitsunterlagen der IT.
|
||||||
|
|
||||||
|
## Was dort steht
|
||||||
|
|
||||||
|
Für jedes Gerät, das auf dich läuft:
|
||||||
|
|
||||||
|
- **Name und Typ** — etwa „NB-042, Notebook“
|
||||||
|
- **Seriennummer und Aufkleber-Code** — hilfreich, wenn du bei der IT anrufst
|
||||||
|
- **Die Arbeiten daran**, neueste zuerst: Titel, Zeitraum und ob sie fertig sind
|
||||||
|
|
||||||
|
Darunter, falls es das gibt, ein Abschnitt **Zu mir persönlich**: Arbeiten, die an keinem Gerät
|
||||||
|
hängen — etwa das Einrichten deines Kontos, als du angefangen hast.
|
||||||
|
|
||||||
|
Und ganz oben **Meine Daten**: dein Name, deine Funktion, deine Abteilung und deine Mailadresse, so
|
||||||
|
wie das Verzeichnis sie führt. Stimmt da etwas nicht, sag es der IT — ändern lässt es sich hier
|
||||||
|
nicht, denn diese Angaben kommen aus dem Verzeichnis des Hauses.
|
||||||
|
|
||||||
|
## Was dort nicht steht, und warum
|
||||||
|
|
||||||
|
Du siehst **Titel, Zeitraum und Status** einer Arbeit. Du siehst **nicht**:
|
||||||
|
|
||||||
|
- die einzelnen Schritte
|
||||||
|
- Notizen der Techniker
|
||||||
|
- Kommentare
|
||||||
|
- angehängte Fotos
|
||||||
|
|
||||||
|
Das ist Absicht und keine Geheimniskrämerei. In einem Lauf stehen Arbeitsnotizen, die während der
|
||||||
|
Arbeit entstehen, und Befehle mit Platzhaltern für Zugangsdaten. Beides ist nicht dafür gedacht,
|
||||||
|
gelesen zu werden — weder von dir noch von sonst jemandem außerhalb der IT.
|
||||||
|
|
||||||
|
**Wenn du wissen willst, was genau gemacht wurde, frag die IT.** Dann antwortet ein Mensch, der den
|
||||||
|
Zusammenhang kennt, statt einer Liste, die man missverstehen kann.
|
||||||
|
|
||||||
|
## Ein archiviertes Gerät steht weiter da
|
||||||
|
|
||||||
|
Geräte, die nicht mehr in Benutzung sind, bleiben in der Liste — mit dem Vermerk **nicht mehr in
|
||||||
|
Benutzung**. „Was war eigentlich mit meinem alten Notebook?“ ist genau die Frage, um die es hier
|
||||||
|
geht, und das alte Notebook ist archiviert.
|
||||||
|
|
||||||
|
## Die Liste ist leer
|
||||||
|
|
||||||
|
Dann hat die IT zu dir nichts festgehalten. Kein Fehler — es heißt nur, dass auf dich gerade kein
|
||||||
|
Gerät läuft und keine Arbeit zu dir vermerkt ist.
|
||||||
|
|
||||||
|
## „Dein Konto ist keiner Person im Verzeichnis zugeordnet“
|
||||||
|
|
||||||
|
Diese Meldung bei der Anmeldung heißt: Laufzettel konnte dein Konto keiner Person zuordnen. Meist
|
||||||
|
liegt es daran, dass dein Anmeldename im Verzeichnis anders geschrieben steht als beim
|
||||||
|
Anmeldeanbieter. Das kann nur die IT auflösen — melde dich dort.
|
||||||
|
|
||||||
|
## Keine Mail, keine Benachrichtigung
|
||||||
|
|
||||||
|
Laufzettel schickt dir nichts. Die Benachrichtigungen, die es gibt, gehen an die IT: wenn eine
|
||||||
|
Arbeit jemandem zugewiesen wird, überfällig ist oder fertig. Du erfährst von einer Arbeit an deinem
|
||||||
|
Gerät auf dem üblichen Weg — der Techniker sagt es dir.
|
||||||
@@ -29,6 +29,14 @@ nie an ein zweites Gerät vergeben — er ist die einzige Angabe am Gerät, die
|
|||||||
- [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen
|
- [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
|
||||||
|
|||||||
+149
-10
@@ -153,6 +153,17 @@ 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
|
||||||
@@ -185,21 +196,121 @@ Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurüc
|
|||||||
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
|
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
|
||||||
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
||||||
|
|
||||||
## 5d. Warum die Aktualisierung keine Befehle mitliefert
|
## 5d. Wie die Aktualisierung funktioniert – und warum sie keine Befehle mitliefert
|
||||||
|
|
||||||
Die Abarbeitungsansicht fragt alle zehn Sekunden `GET /api/laeufe/[id]/zustand` — zwei Techniker
|
Die Abarbeitungsansicht hält `GET /api/laeufe/[id]/zustand` als **Ereignisstrom** offen — zwei
|
||||||
teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu laden.
|
Techniker teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu
|
||||||
Diese Antwort trägt **nur** Status und Schrittzustände, ausdrücklich keine fertigen Befehle.
|
laden. Diese Nutzlast trägt **nur** Status, Schrittzustände und die Zahl der Kommentare und
|
||||||
|
Anhänge, ausdrücklich keine fertigen Befehle.
|
||||||
|
|
||||||
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte das Polling
|
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte die
|
||||||
Befehle mit, stünde alle zehn Sekunden ein anderes Passwort auf dem Bildschirm als das, das der
|
Aktualisierung Befehle mit, stünde nach jeder Änderung ein anderes Passwort auf dem Bildschirm als
|
||||||
Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal je
|
das, das der Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal
|
||||||
Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas geändert
|
je Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas
|
||||||
hat. Bei verstecktem Tab pausiert die Abfrage.
|
geändert hat. Bei verstecktem Tab wird der Strom geschlossen.
|
||||||
|
|
||||||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück.
|
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück —
|
||||||
|
auch und gerade für den Strom, **bevor** das erste Ereignis fließt.
|
||||||
|
|
||||||
|
### Warum die Datenbank das Ereignis schickt, nicht der Dienst
|
||||||
|
|
||||||
|
Bis #033 fragte die Seite alle zehn Sekunden. Das war richtig, aber zehn Sekunden sind lang, wenn
|
||||||
|
zwei Leute dieselbe Liste abhaken, und zwanzig offene Laschen bedeuteten zwanzig Abfragen je zehn
|
||||||
|
Sekunden. Seit #033 melden vier Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung
|
||||||
|
je Prozess hört zu, und der Route Handler schiebt weiter.
|
||||||
|
|
||||||
|
**In der Datenbank und nicht im Service**, aus demselben Grund, aus dem auch die Regeln dort stehen:
|
||||||
|
ein Ereignis, das der Service verschickt, entsteht nur auf dem Weg durch den Service. Ein Trigger
|
||||||
|
sieht auch den Seed, eine Migration und die Reparatur per `psql`. Und `NOTIFY` geht an **jede**
|
||||||
|
Verbindung mit `LISTEN` — also an jede Instanz der Anwendung, ohne einen zweiten Dienst.
|
||||||
|
|
||||||
|
**Welche Tabellen melden, hängt an dem, was das Lesemodell liefert.** Das Polling verglich den
|
||||||
|
ganzen Stand und sah damit auch, was kein Trigger meldet; ein Strom sieht nur, was gemeldet wird.
|
||||||
|
Fehlte `Kommentar` oder `Anhang`, wäre die Live-Aktualisierung gegenüber dem Polling ein
|
||||||
|
**Rückschritt** — der Kommentar des Kollegen käme nie an. `tests/datenbankregeln.test.ts` zählt die
|
||||||
|
vier Trigger namentlich auf, `tests/zustand-route.test.ts` die Schlüssel des Zustands.
|
||||||
|
|
||||||
|
**Das Polling bleibt, als Rückfall an derselben Adresse.** Wer kein `EventSource` hat oder hinter
|
||||||
|
einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg — dieselbe Rechteprüfung, dasselbe
|
||||||
|
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand das Recht
|
||||||
|
vergessen kann. Unterschieden wird allein am `Accept`-Kopf, den `EventSource` von sich aus schickt.
|
||||||
|
Dieser Rückfall wird gefahren, nicht behauptet: `tests/e2e/live.spec.ts` nimmt dem Browser
|
||||||
|
`EventSource` weg.
|
||||||
|
|
||||||
|
Eine Obergrenze (`SSE_MAX_STROEME`, Vorgabe 200) begrenzt die offenen Ströme je Instanz. Ist sie
|
||||||
|
erreicht, antwortet der Handler gewöhnlich mit JSON; `EventSource` meldet darauf einen Fehler und
|
||||||
|
versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück. Das braucht keinen
|
||||||
|
Sonderfall und keinen eigenen Fehlercode.
|
||||||
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
||||||
|
|
||||||
|
### Warum die Benachrichtigungen in einer Warteschlange liegen
|
||||||
|
|
||||||
|
Vier Anlässe gehen seit #034 hinaus: zugewiesen, überfällig, abgeschlossen, Abgleich fehlgeschlagen.
|
||||||
|
Drei Entscheidungen prägen das.
|
||||||
|
|
||||||
|
**Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein hängender Mailserver darf kein
|
||||||
|
Abhaken blockieren. Der Dienst schreibt eine Zeile in `Zustellung` — eine Einfügung in dieselbe
|
||||||
|
Datenbank, die der Vorgang ohnehin anfasst —, und ein Zeitplan im selben Prozess schickt sie. Das
|
||||||
|
Einreihen passiert **nach** der Transaktion und mit abgefangenem Fehler: ein Abhaken darf nicht
|
||||||
|
daran scheitern, dass eine Benachrichtigung nicht eingereiht werden konnte.
|
||||||
|
|
||||||
|
**Betreff und Rumpf stehen fertig in der Zeile.** Das kostet Platz und ist es wert: eine Meldung
|
||||||
|
sagt, was *damals* war. Baute der Zeitplan den Text erst beim Senden, stünde in einer Mail von heute
|
||||||
|
der Stand von jetzt — und in einer aufgegebenen Zustellung von nächster Woche etwas völlig anderes.
|
||||||
|
|
||||||
|
**In keiner Meldung steht ein Schrittinhalt.** Darüber wacht `src/lib/meldungen.ts`: die Funktionen
|
||||||
|
dort nehmen **nur** Kopfdaten, der Typ lässt nichts anderes zu. Eine Mail mit dem Lauf-Inhalt wäre
|
||||||
|
genau die Lücke, die dieses Projekt an jeder anderen Stelle geschlossen hat — das erzeugte Passwort
|
||||||
|
wird nirgends gespeichert, aber eine Mail liegt für immer in einem Postfach, oft auf einem Server,
|
||||||
|
der nicht im Haus steht. `tests/benachrichtigungen.test.ts` prüft es zusätzlich über **alle**
|
||||||
|
Rumpftexte, die wirklich verschickt wurden, so wie #012 es für die Druckansicht tat.
|
||||||
|
|
||||||
|
Zwei Dinge, die ohne Datenbank nicht gehalten hätten. Die **Einmaligkeit der
|
||||||
|
Überfälligkeitsmeldung** liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute
|
||||||
|
nach, und ohne ihn bekäme der Zuständige für einen Lauf, der über das Wochenende steht, 2880 Mails —
|
||||||
|
jede davon für sich korrekt, kein Test rot. Und die **Sperre** der Warteschlange liegt in derselben
|
||||||
|
Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, und ein `let laeuft
|
||||||
|
= false` im Modul wüsste nichts von der zweiten.
|
||||||
|
|
||||||
|
**Das Polling-Prinzip gilt auch hier nicht umgekehrt:** ein Kommentar und ein angehängtes Foto lösen
|
||||||
|
*keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre
|
||||||
|
Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich.
|
||||||
|
|
||||||
|
### Warum ein Mitarbeiter keine Akteneinsicht bekommt
|
||||||
|
|
||||||
|
Ein `Mitarbeiter` ist kein `Benutzer`. Die vier Rollen bis #035 waren für Leute, die **arbeiten**;
|
||||||
|
ein Mitarbeiter ist Gegenstand der Arbeit, nicht ihr Träger. Mit der Selbstauskunft kommt die fünfte
|
||||||
|
Rolle `MITARBEITER` dazu, und sie ist die einzige, die **nicht** `lesen` darf.
|
||||||
|
|
||||||
|
**`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz.** Darum hat auch ein `ADMIN` dieses
|
||||||
|
Recht nicht: er sieht über `lesen` ohnehin alles, und ein zweiter Weg zu denselben Daten wäre eine
|
||||||
|
zweite Stelle, an der jemand die Einschränkung auf die eigene Person vergisst. Wer beides braucht,
|
||||||
|
braucht zwei Konten — und genau das hält die Datenbank fest: der CHECK
|
||||||
|
`Benutzer_mitarbeiter_nur_bei_rolle` verlangt, dass Rolle und Zuordnung zusammengehören, in beide
|
||||||
|
Richtungen.
|
||||||
|
|
||||||
|
**Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.**
|
||||||
|
`src/server/queries/selbstauskunft.ts` liefert Titel, Status, Zeitraum und Gerät — keine Schritte,
|
||||||
|
keine Notizen, keine Kommentare, keine Anhänge. In einem Lauf stehen Arbeitsnotizen über Menschen
|
||||||
|
(„Nutzerin war unkooperativ“) und Befehle mit Platzhaltern für Zugangsdaten; beides ist nicht dafür
|
||||||
|
gedacht, gelesen zu werden. Eine Seite, die filtert, wäre eine Seite, die es vergessen kann.
|
||||||
|
|
||||||
|
Geprüft wird das an der **Antwort** und nicht an der Seite: `tests/selbstauskunft.test.ts` hängt an
|
||||||
|
einen Lauf eine Notiz, einen Kommentar, einen Anhang und einen Befehlsschritt mit
|
||||||
|
`{{generiertes_passwort}}` und prüft, dass nichts davon durchkommt. Dazu ein struktureller Test, der
|
||||||
|
das `select` selbst liest — denn eine Gegenprobe zeigte, dass der erste Test grün blieb, als das
|
||||||
|
`select` versuchsweise Notizen mitholte: gerettet hatte ihn die Abbildungsfunktion, nicht die
|
||||||
|
Abfrage.
|
||||||
|
|
||||||
|
**Zugeordnet wird über den UPN, und mehrdeutig ist ein Fehler.** Zwei Kunden können dieselbe Person
|
||||||
|
führen; ein `findFirst` entschiede dann nach Sortierung darüber, wessen Geräte jemand sieht. Lieber
|
||||||
|
kein Zugang als der falsche — die Anmeldung wird abgewiesen und der Fall protokolliert.
|
||||||
|
|
||||||
|
**Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
|
||||||
|
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist; dort bleibt alles wie vor
|
||||||
|
#035. Das ist keine Vorsicht, sondern Notwendigkeit: ein Update, das jeden aussperrt, dem die
|
||||||
|
Gruppen fehlen, wäre ein kaputtes Update.
|
||||||
|
|
||||||
## 5e. Der Aufkleber ist die Schnittstelle
|
## 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
|
||||||
@@ -278,6 +389,34 @@ was darauf steht, ist aus der Hand gegeben.
|
|||||||
|
|
||||||
## 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:
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ 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
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,16 @@ 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`:
|
||||||
@@ -93,11 +100,47 @@ 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, bekommt er `LESER`
|
- Ist der Benutzer in keiner der vier Gruppen, versucht Laufzettel, ihn über seinen **UPN** einer
|
||||||
|
Person im Verzeichnis zuzuordnen. Klappt das, bekommt er `MITARBEITER` und sieht nur seine
|
||||||
|
eigenen Geräte; klappt es nicht, wird die Anmeldung **abgewiesen** — mit einer Begründung auf der
|
||||||
|
Anmeldeseite
|
||||||
|
- **Bis Version 0.2.0 bekam er stattdessen `LESER`**, also Lesezugriff auf alle Läufe des Hauses.
|
||||||
|
Wer das Verhalten von damals braucht, trägt die betreffenden Leute in `lz-leser` ein
|
||||||
- Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen
|
- 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. Notfallzugang
|
## 7. Selbstauskunft für Mitarbeiter
|
||||||
|
|
||||||
|
Ein Mitarbeiter, der in keiner Rollengruppe steht, sieht unter **Meine Geräte** die Arbeiten an
|
||||||
|
seinen eigenen Geräten — Titel, Zeitraum, Status. **Keine Schritte, keine Notizen, keine Kommentare,
|
||||||
|
keine Anhänge**; warum, steht in `docs/architektur.md` unter „Warum ein Mitarbeiter keine Akteneinsicht bekommt“.
|
||||||
|
|
||||||
|
Zugeordnet wird über den **UPN**. Laufzettel liest ihn in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. der Anspruch `upn` (so heißt er bei Entra ID)
|
||||||
|
2. `preferred_username` (authentik und Keycloak schicken dort den Anmeldenamen)
|
||||||
|
3. die Mailadresse
|
||||||
|
|
||||||
|
Verglichen wird mit `Mitarbeiter.upn`, unabhängig von Groß- und Kleinschreibung. Damit das greift,
|
||||||
|
muss der UPN in authentik derselbe sein wie der im Active Directory — bei einem AD als Quelle von
|
||||||
|
authentik ist er das meist von selbst.
|
||||||
|
|
||||||
|
**Zwei Fälle, die bewusst nicht durchgehen:**
|
||||||
|
|
||||||
|
| Fall | Was passiert |
|
||||||
|
|---|---|
|
||||||
|
| Zu dem UPN gibt es **keine aktive** Person | Anmeldung abgewiesen, mit Begründung auf der Anmeldeseite |
|
||||||
|
| Zu dem UPN gibt es **mehr als eine** Person | Anmeldung abgewiesen und im Protokoll festgehalten |
|
||||||
|
|
||||||
|
Der zweite Fall kommt im Modus **Dienstleistung** vor, wenn zwei Kunden dieselbe Person führen. Ein
|
||||||
|
Zufallstreffer wäre dort schlimmer als kein Zugang: er entschiede nach Sortierung darüber, wessen
|
||||||
|
Geräte jemand sieht.
|
||||||
|
|
||||||
|
**Abschalten lässt sich das Ganze**, indem du keine Rollengruppen konfigurierst — dann greift die
|
||||||
|
Weiche nicht, und alles bleibt wie vor 0.2.0. Oder indem du jeden, der hinein soll, in `lz-leser`
|
||||||
|
einträgst.
|
||||||
|
|
||||||
|
## 8. Notfallzugang
|
||||||
|
|
||||||
Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login:
|
Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login:
|
||||||
|
|
||||||
@@ -112,7 +155,7 @@ Passwörter liegen als argon2-Hash in der Datenbank.
|
|||||||
Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED`
|
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.
|
||||||
|
|
||||||
## 8. Fehlersuche
|
## 9. Fehlersuche
|
||||||
|
|
||||||
| Symptom | Ursache |
|
| Symptom | Ursache |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Benachrichtigungen einrichten
|
||||||
|
|
||||||
|
Laufzettel meldet vier Dinge, und nur diese vier:
|
||||||
|
|
||||||
|
| Anlass | Wer bekommt es |
|
||||||
|
|---|---|
|
||||||
|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
|
||||||
|
| Ein Lauf ist **überfällig** | der Zugewiesene, **einmal** je Lauf |
|
||||||
|
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
|
||||||
|
| Ein **Verzeichnisabgleich schlägt fehl** | alle aktiven Administratoren |
|
||||||
|
|
||||||
|
**Niemand bekommt Post über das, was er selbst gerade getan hat.** Das ist der häufigste Grund,
|
||||||
|
Benachrichtigungen ganz abzuschalten, und deshalb fällt der Handelnde immer aus der Empfängerliste.
|
||||||
|
|
||||||
|
> **In keiner Nachricht steht ein Schrittinhalt.** Titel, Status, Gerät, Person, Frist und ein Link —
|
||||||
|
> mehr nicht. Kein Wert, keine Notiz, kein Befehl, und damit auch kein `{{generiertes_passwort}}`.
|
||||||
|
> Das Passwort entsteht bei jeder Anzeige neu und wird nirgends gespeichert; eine Mail dagegen liegt
|
||||||
|
> für immer in einem Postfach, oft auf einem Server, der nicht im Haus steht.
|
||||||
|
|
||||||
|
## Was gebraucht wird
|
||||||
|
|
||||||
|
- **`APP_URL`**, damit der Link in der Mail irgendwohin führt. Fehlt sie, gehen die Meldungen
|
||||||
|
trotzdem hinaus — die Einstellungsseite sagt es dann deutlich.
|
||||||
|
- **Ein SMTP-Server** für Mail, **oder** eine Webhook-Adresse, **oder** beides. Ohne eines von beiden
|
||||||
|
wird nichts eingereiht; es entstehen also keine Zeilen, die nie ankommen.
|
||||||
|
|
||||||
|
## Mail über SMTP
|
||||||
|
|
||||||
|
Unter **Admin → Einstellungen → Benachrichtigungen**:
|
||||||
|
|
||||||
|
| Feld | Was hineingehört |
|
||||||
|
|---|---|
|
||||||
|
| Host | `mail.firma.de` — der Mailserver des Hauses |
|
||||||
|
| Port | `587` für StartTLS, `465` für TLS von Anfang an, `25` nur im eigenen Netz |
|
||||||
|
| Benutzer | leer lassen, wenn der Server dem Rechner ohne Anmeldung traut |
|
||||||
|
| Passwort | wird verschlüsselt gespeichert und **nie wieder angezeigt** |
|
||||||
|
| Absender | `laufzettel@firma.de` — muss der Server akzeptieren |
|
||||||
|
| TLS verlangen | an, außer der Server kann es wirklich nicht |
|
||||||
|
|
||||||
|
Dann **Speichern**, dann **Testmail schicken**. Die Testmail geht **direkt** hinaus und nicht über
|
||||||
|
die Warteschlange: ein Fehler erscheint sofort und im Klartext, statt eine Minute später in einer
|
||||||
|
Liste.
|
||||||
|
|
||||||
|
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist** — das ist Absicht. Leer heißt
|
||||||
|
„unverändert lassen“. Sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
|
||||||
|
|
||||||
|
### Wenn die Testmail nicht ankommt
|
||||||
|
|
||||||
|
| Meldung | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `greeting never received` | `secure` passt nicht zum Port. Auf 587 gehört StartTLS, nicht TLS von Anfang an — Laufzettel entscheidet das selbst am Port, aber ein Server, der auf 465 StartTLS erwartet, ist falsch konfiguriert. |
|
||||||
|
| `535 Authentication failed` | Benutzer oder Passwort stimmen nicht. Manche Server wollen die vollständige Mailadresse als Benutzernamen. |
|
||||||
|
| `550 relay not permitted` | Der Server nimmt von dieser Maschine keine Mail für fremde Domains an. Entweder den Rechner freischalten oder sich anmelden. |
|
||||||
|
| `self signed certificate` | Der Mailserver hat ein eigenes Zertifikat. Entweder ein richtiges einrichten oder TLS für diesen Server abschalten — im eigenen Netz ist das zweite vertretbar. |
|
||||||
|
| `ECONNREFUSED` | Port falsch, oder eine Firewall dazwischen. |
|
||||||
|
|
||||||
|
## Webhook
|
||||||
|
|
||||||
|
Dieselben vier Anlässe als `POST` mit JSON-Rumpf:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anlass": "LAUF_ABGESCHLOSSEN",
|
||||||
|
"betreff": "Laufzettel abgeschlossen: „PC Neuinstallation – PC-001“",
|
||||||
|
"link": "https://laufzettel.firma.de/laeufe/cmu…",
|
||||||
|
"laufId": "cmu…",
|
||||||
|
"verzeichnisquelleId": null,
|
||||||
|
"zeitpunkt": "2026-10-05T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`anlass` steht vorn, damit sich ohne Auspacken filtern lässt. Auch hier: **keine Schrittinhalte.**
|
||||||
|
|
||||||
|
Als erfolgreich gilt nur eine Antwort mit `2xx`. Alles andere ist ein Fehlschlag und wird wiederholt.
|
||||||
|
|
||||||
|
### Die Signatur nachrechnen
|
||||||
|
|
||||||
|
Ist ein Geheimnis gesetzt, trägt jede Anfrage den Kopf:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Laufzettel-Signatur: t=1762334400,v1=9f86d08188...
|
||||||
|
```
|
||||||
|
|
||||||
|
Signiert wird `<t>.<rumpf>` mit HMAC-SHA256. **Der Zeitstempel ist Teil der signierten Nachricht**,
|
||||||
|
nicht Beiwerk — stünde er nur daneben, könnte ihn jeder ändern, ohne die Signatur zu brechen, und
|
||||||
|
eine abgefangene Nachricht ließe sich in einem Jahr noch einspielen.
|
||||||
|
|
||||||
|
So prüft die Gegenseite (Node, aber in jeder Sprache dasselbe):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
function pruefen(geheimnis, rumpf, kopf) {
|
||||||
|
const teile = Object.fromEntries(kopf.split(",").map((s) => s.split("=")));
|
||||||
|
const alter = Math.abs(Math.floor(Date.now() / 1000) - Number(teile.t));
|
||||||
|
if (alter > 300) return false; // älter als fünf Minuten: ablehnen
|
||||||
|
|
||||||
|
const erwartet = createHmac("sha256", geheimnis).update(`${teile.t}.${rumpf}`).digest("hex");
|
||||||
|
// Zeitkonstant vergleichen, nicht mit ===
|
||||||
|
return timingSafeEqual(Buffer.from(erwartet), Buffer.from(teile.v1));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Drei Dinge, die dabei schiefgehen:
|
||||||
|
|
||||||
|
1. **Den Rumpf vorher geparst und wieder serialisiert.** Dann stimmt die Signatur nicht mehr —
|
||||||
|
`JSON.stringify` setzt Leerzeichen und Reihenfolge anders. Signiert wird der **rohe** Rumpf, also
|
||||||
|
vor jeder Verarbeitung lesen.
|
||||||
|
2. **Mit `===` verglichen.** Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die
|
||||||
|
richtige Signatur Zeichen für Zeichen erraten. `timingSafeEqual` oder das Äquivalent benutzen.
|
||||||
|
3. **Nur „zu alt“ geprüft.** Eine Nachricht mit Zeitstempel im Jahr 2040 wäre dann für immer gültig.
|
||||||
|
Der Abstand gehört in **beide** Richtungen geprüft.
|
||||||
|
|
||||||
|
Ohne Geheimnis fehlt die Kopfzeile ganz, statt dass eine leere Signatur Sicherheit vortäuscht.
|
||||||
|
|
||||||
|
## Die Warteschlange
|
||||||
|
|
||||||
|
**Eingereiht wird sofort, gesendet nicht.** Ein hängender Mailserver darf kein Abhaken blockieren:
|
||||||
|
der Dienst schreibt eine Zeile, und ein Zeitplan im selben Prozess schickt sie — jede Minute, kein
|
||||||
|
zweiter Dienst, kein Systemd-Timer.
|
||||||
|
|
||||||
|
Ein Fehlschlag wird wiederholt, mit wachsendem Abstand: nach **1, 5, 30 und 120 Minuten**. Beim
|
||||||
|
fünften Fehlschlag gibt Laufzettel auf und lässt die Zeile als **aufgegeben** stehen.
|
||||||
|
|
||||||
|
> **Eine stille Warteschlange ist schlimmer als keine.** Unter **Admin → Zustellungen** steht, was
|
||||||
|
> hinausgegangen ist und was nicht — mit Grund. Sieh dort hin, wenn jemand sagt, er bekomme keine
|
||||||
|
> Mails: fast immer ist das SMTP-Passwort abgelaufen, und ohne diese Seite merkt es wochenlang
|
||||||
|
> niemand, weil jeder Lauf weiter funktioniert.
|
||||||
|
|
||||||
|
Betreff und Rumpf stehen **fertig** in der Zeile. Eine Meldung sagt, was damals war; würde der Text
|
||||||
|
erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
|
||||||
|
|
||||||
|
### Mehrere Instanzen
|
||||||
|
|
||||||
|
Läuft Laufzettel hinter einem Lastverteiler mehrfach, nimmt genau eine Instanz je Durchgang die
|
||||||
|
Warteschlange — über eine Sperre in der Datenbank, dieselbe Mechanik wie beim Verzeichnisabgleich.
|
||||||
|
Ohne sie bekäme jeder Empfänger jede Mail doppelt. Dafür ist nichts einzurichten.
|
||||||
|
|
||||||
|
## Je Person abschalten
|
||||||
|
|
||||||
|
Unter **Admin → Benutzer** schaltet der **Mail**-Knopf die Benachrichtigungen einer Person ab. Das
|
||||||
|
gilt auch für das eigene Konto — sich selbst zu deaktivieren wäre ein Eigentor, seine eigene Post
|
||||||
|
abzustellen ist der häufigste Wunsch überhaupt.
|
||||||
|
|
||||||
|
Wer abgeschaltet hat, bekommt keine Zeile und keinen Fehler: es wird gar nichts eingereiht. Ein
|
||||||
|
deaktiviertes Konto ebenso.
|
||||||
|
|
||||||
|
## Was nicht gemeldet wird
|
||||||
|
|
||||||
|
- **Ein Kommentar oder ein angehängtes Foto.** Das sieht die Live-Aktualisierung in der
|
||||||
|
Abarbeitungsansicht sofort; eine Mail dafür wäre Lärm.
|
||||||
|
- **Ein einzelner abgehakter Schritt.** Dasselbe.
|
||||||
|
- **Ein archivierter Lauf.** Archivieren ist eine Aufräumarbeit, keine Nachricht.
|
||||||
|
|
||||||
|
Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. Diese vier decken
|
||||||
|
ab, was im Betrieb wirklich fehlt.
|
||||||
|
|
||||||
|
## Prüfen, dass es läuft
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Steht etwas in der Warteschlange, das nicht hinausgeht?
|
||||||
|
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc \
|
||||||
|
"SELECT zustand, count(*) FROM \"Zustellung\" GROUP BY zustand"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nativ dasselbe über `sudo -u postgres psql -d laufzettel -tAc "…"`.
|
||||||
|
|
||||||
|
Steht dort etwas unter `AUFGEGEBEN`, schau unter **Admin → Zustellungen** nach dem Grund. Alles unter
|
||||||
|
`OFFEN` oder `FEHLGESCHLAGEN` regelt der Zeitplan von selbst — es sei denn, es steht dort seit
|
||||||
|
Stunden, dann läuft der Prozess nicht oder `ZEITPLAN_AKTIV` ist auf `false`.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# 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
|
||||||
@@ -58,6 +58,14 @@ laufzettel.firma.de {
|
|||||||
# gerade beim Neustart. Der Pfad braucht keine Anmeldung.
|
# gerade beim Neustart. Der Pfad braucht keine Anmeldung.
|
||||||
health_uri /api/gesundheit
|
health_uri /api/gesundheit
|
||||||
health_interval 30s
|
health_interval 30s
|
||||||
|
|
||||||
|
# Die Live-Aktualisierung ist ein Ereignisstrom (#033). Caddy erkennt das
|
||||||
|
# heute an `Content-Type: text/event-stream` und reicht von sich aus
|
||||||
|
# sofort durch – ausdruecklich gesetzt bleibt es auch dann richtig, wenn
|
||||||
|
# eine Fassung das einmal anders handhabt. Ohne sofortiges Durchreichen
|
||||||
|
# sammelt der Proxy den Strom, und die Seite sieht aus, als sei sie
|
||||||
|
# haengen geblieben.
|
||||||
|
flush_interval -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -102,9 +110,16 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
|
||||||
|
|
||||||
# Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx
|
# Der CSV-Import schickt die Datei als Text durch eine Server Action, und
|
||||||
# laesst von sich aus nur 1 MB durch und waere damit die erste Wand.
|
# seit #032 haengt jemand Fotos an Schritte. nginx laesst von sich aus nur
|
||||||
client_max_body_size 8m;
|
# 1 MB durch und waere damit die erste Wand.
|
||||||
|
#
|
||||||
|
# **Muss ueber `ANHANG_MAX_MB` liegen.** Steht dort die Vorgabe 10, dann
|
||||||
|
# reichen 8m nicht: ein 10-MB-Foto scheitert an nginx mit „413 Request
|
||||||
|
# Entity Too Large“, und die Meldung der Anwendung („Erlaubt sind hoechstens
|
||||||
|
# 10,0 MB“) kommt nie zum Zuge. Wer die Grenze in der Anwendung anhebt,
|
||||||
|
# hebt sie hier mit an — mit etwas Luft fuer den Rahmen des Formulars.
|
||||||
|
client_max_body_size 12m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
@@ -117,12 +132,22 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
# Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind
|
# Die Abarbeitungsansicht haelt seit #033 einen **offenen** Strom auf
|
||||||
# reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab.
|
# /api/laeufe/<id>/zustand. Der Server schickt alle 25 s einen
|
||||||
|
# Herzschlag, damit nginx die stille Verbindung nicht schliesst; 90 s
|
||||||
|
# sind reichlich dafuer und fangen einen langsamen Verzeichnis-Abgleich
|
||||||
|
# mit ab. **Unter 30 s darf dieser Wert nicht fallen**, sonst reisst der
|
||||||
|
# Strom zwischen zwei Herzschlaegen immer wieder ab.
|
||||||
proxy_read_timeout 90s;
|
proxy_read_timeout 90s;
|
||||||
|
|
||||||
# Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort
|
# Next liefert Seiten stroemend aus, und die Live-Aktualisierung ist ein
|
||||||
# zusammen, und die Seite erscheint spuerbar spaeter auf einmal.
|
# Ereignisstrom. Ohne das haelt nginx die Antwort zusammen: die Seite
|
||||||
|
# erscheint spuerbar spaeter auf einmal, und vom Strom kommt gar nichts
|
||||||
|
# an — sie sieht dann aus, als sei sie haengen geblieben.
|
||||||
|
#
|
||||||
|
# Die Anwendung schickt zusaetzlich `X-Accel-Buffering: no` auf der
|
||||||
|
# Antwort des Stroms. Das wirkt auch, wenn hier `on` stuende, und deckt
|
||||||
|
# genau diesen einen Pfad ab; die Zeile hier gilt fuer alles andere.
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# 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“.
|
||||||
+140
-23
@@ -11,12 +11,19 @@ ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
|
|||||||
|
|
||||||
## Was gesichert werden muss
|
## Was gesichert werden muss
|
||||||
|
|
||||||
Zwei Dinge, und nur diese zwei:
|
Drei Dinge, und nur diese drei:
|
||||||
|
|
||||||
| | Was | Wo |
|
| | Was | Wo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: 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 |
|
||||||
|
|
||||||
|
> **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
|
||||||
|
> Sicherung, die nur `pg_dump` macht, sieht vollständig aus und ist es nicht: nach dem Zurückspielen
|
||||||
|
> stehen die Anhänge in der Oberfläche, und jeder Klick darauf meldet, dass die Datei fehlt.
|
||||||
|
> Datenbank und Verzeichnis gehören **zum selben Zeitpunkt** gesichert.
|
||||||
|
|
||||||
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
|
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
|
||||||
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
|
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
|
||||||
@@ -29,9 +36,8 @@ Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restli
|
|||||||
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
|
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
|
||||||
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
|
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
|
||||||
|
|
||||||
**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte.
|
**Was nicht gesichert werden muss:** Der Quellcode kommt aus Git, `node_modules` und `.next`
|
||||||
Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und
|
entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
||||||
`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
|
||||||
|
|
||||||
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
|
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
|
||||||
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
|
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
|
||||||
@@ -73,6 +79,28 @@ Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` weg
|
|||||||
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
|
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
|
||||||
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
|
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
|
||||||
|
|
||||||
|
#### Und die Anhänge
|
||||||
|
|
||||||
|
Hier gilt das Umgekehrte: ein Dateiabzug ist genau richtig, denn es sind gewöhnliche Dateien.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v laufzettel_anhaenge:/daten:ro \
|
||||||
|
-v "$PWD":/ziel \
|
||||||
|
debian:13 tar czf /ziel/anhaenge-$(date +%F).tar.gz -C /daten .
|
||||||
|
```
|
||||||
|
|
||||||
|
Kurz nachsehen, dass etwas drin ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar tzf anhaenge-$(date +%F).tar.gz | head -5
|
||||||
|
tar tzf anhaenge-$(date +%F).tar.gz | wc -l
|
||||||
|
```
|
||||||
|
|
||||||
|
Ist die Datei leer und es gibt Anhänge in der Oberfläche, stimmt der Volume-Name nicht — `docker
|
||||||
|
volume ls | grep anhaenge` nennt den richtigen. Er trägt den Projektnamen als Präfix, und der kommt
|
||||||
|
aus dem Verzeichnisnamen.
|
||||||
|
|
||||||
### Zurückspielen
|
### Zurückspielen
|
||||||
|
|
||||||
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
|
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
|
||||||
@@ -91,10 +119,18 @@ docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel
|
|||||||
# 3. Abzug einspielen
|
# 3. Abzug einspielen
|
||||||
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
|
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
|
||||||
|
|
||||||
# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
# 4. Die Anhänge zurück ins Volume – **vorher leeren**, sonst bleiben Dateien
|
||||||
|
# liegen, auf die keine Zeile mehr zeigt
|
||||||
|
docker run --rm -v laufzettel_anhaenge:/daten debian:13 sh -c 'rm -rf /daten/* /daten/.[!.]*' || true
|
||||||
|
docker run --rm \
|
||||||
|
-v laufzettel_anhaenge:/daten \
|
||||||
|
-v "$PWD":/quelle:ro \
|
||||||
|
debian:13 tar xzf /quelle/anhaenge-2026-09-24.tar.gz -C /daten
|
||||||
|
|
||||||
|
# 5. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||||||
docker compose run --rm migrate pnpm exec prisma migrate deploy
|
docker compose run --rm migrate pnpm exec prisma migrate deploy
|
||||||
|
|
||||||
# 5. Wieder anfahren
|
# 6. Wieder anfahren
|
||||||
docker compose start web
|
docker compose start web
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
```
|
```
|
||||||
@@ -118,6 +154,26 @@ auf —, aber alles, was er anlegen würde, steht schon im Abzug.
|
|||||||
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
|
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Der Pfad steht in ANHAENGE_PFAD; `deploy/install.sh` setzt ihn auf
|
||||||
|
# /var/lib/laufzettel/anhaenge. **Kein Rückfallwert** – siehe den Kasten darunter.
|
||||||
|
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" .
|
||||||
|
```
|
||||||
|
|
||||||
|
> **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
|
||||||
@@ -141,16 +197,32 @@ sudo -u postgres createdb -O laufzettel laufzettel
|
|||||||
# 3. Abzug einspielen
|
# 3. Abzug einspielen
|
||||||
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
|
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
|
||||||
|
|
||||||
# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
|
||||||
|
# die keine Zeile mehr zeigt
|
||||||
|
# Ohne ANHAENGE_PFAD bricht das hier ab – und zwar **vor** dem rm -rf. Ein
|
||||||
|
# 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 install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
|
||||||
|
sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
|
||||||
|
sudo chown -R laufzettel:laufzettel "$ANHAENGE"
|
||||||
|
|
||||||
|
# 5. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||||||
sudo bash /opt/laufzettel/deploy/install.sh
|
sudo bash /opt/laufzettel/deploy/install.sh
|
||||||
|
|
||||||
# 5. Wieder anfahren und nachsehen
|
# 6. Wieder anfahren und nachsehen
|
||||||
sudo systemctl start laufzettel
|
sudo systemctl start laufzettel
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
```
|
```
|
||||||
|
|
||||||
Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
Schritt 5 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||||||
neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen:
|
neu startet — Schritt 6 ist dann nur noch die Gegenprobe.
|
||||||
|
|
||||||
|
**Der `chown` in Schritt 4 ist nicht Beiwerk.** `tar` legt die Dateien mit den Besitzern aus dem
|
||||||
|
Archiv an; läuft die Rücksicherung als `root`, gehören sie danach `root`, und der Dienstnutzer
|
||||||
|
`laufzettel` kommt nicht mehr an sie heran. Jeder Anhang meldet dann „Datei fehlt“, obwohl sie
|
||||||
|
daliegt. Willst du nur die Migrationen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/laufzettel
|
cd /opt/laufzettel
|
||||||
@@ -170,11 +242,32 @@ After=postgresql.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=postgres
|
# `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis,
|
||||||
# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles
|
# und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft
|
||||||
# aelter als 30 Tage weg.
|
# weiterhin als `postgres`, ueber `runuser`.
|
||||||
ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump'
|
User=root
|
||||||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete
|
ExecStart=/usr/local/sbin/laufzettel-sichern
|
||||||
|
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*' -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
`/usr/local/sbin/laufzettel-sichern`, Modus 0750:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Datenbank und Anhaenge zum **selben** Zeitpunkt. Getrennt gesichert ergeben
|
||||||
|
# beide Haelften kein vollstaendiges Bild (#032).
|
||||||
|
set -Eeuo pipefail
|
||||||
|
tag=$(date +%F)
|
||||||
|
runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.dump"
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. /etc/laufzettel/laufzettel.env
|
||||||
|
# Kein Rueckfallwert: ein geratener Pfad ergibt eine leere Sicherung, und die
|
||||||
|
# faellt erst beim Zuruckspielen auf.
|
||||||
|
anhaenge="${ANHAENGE_PFAD:?ANHAENGE_PFAD fehlt in /etc/laufzettel/laufzettel.env}"
|
||||||
|
if [ -d "$anhaenge" ]; then
|
||||||
|
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
|
||||||
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
`/etc/systemd/system/laufzettel-sicherung.timer`:
|
`/etc/systemd/system/laufzettel-sicherung.timer`:
|
||||||
@@ -193,19 +286,22 @@ RandomizedDelaySec=15m
|
|||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Einrichten, einschalten, und **einmal von Hand auslösen** — eine Sicherung, die
|
||||||
|
nur im Timer steht, hat noch nie eine Datei erzeugt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern
|
||||||
sudo install -d -o postgres -g postgres -m 0700 /var/backups
|
sudo install -d -o postgres -g postgres -m 0700 /var/backups
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now laufzettel-sicherung.timer
|
sudo systemctl enable --now laufzettel-sicherung.timer
|
||||||
|
sudo systemctl start laufzettel-sicherung.service
|
||||||
|
ls -l /var/backups/laufzettel-*
|
||||||
sudo systemctl list-timers laufzettel-sicherung.timer
|
sudo systemctl list-timers laufzettel-sicherung.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist:
|
Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-<Datum>.dump` und
|
||||||
|
`laufzettel-anhaenge-<Datum>.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder
|
||||||
```bash
|
`ANHAENGE_PFAD` zeigt woandershin als gedacht.
|
||||||
sudo systemctl start laufzettel-sicherung.service
|
|
||||||
ls -l /var/backups/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compose, als Cron-Eintrag
|
### Compose, als Cron-Eintrag
|
||||||
|
|
||||||
@@ -218,6 +314,12 @@ In der Crontab von root (`sudo crontab -e`):
|
|||||||
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
|
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
|
||||||
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
|
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
|
||||||
|
|
||||||
|
Und die Anhänge direkt danach, damit beide Hälften vom selben Zeitpunkt stammen:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
20 3 * * * docker run --rm -v laufzettel_anhaenge:/daten:ro -v /var/backups:/ziel debian:13 tar czf /ziel/laufzettel-anhaenge-$(date +\%F).tar.gz -C /daten . && find /var/backups -name 'laufzettel-anhaenge-*' -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
### Und dann weg vom Rechner
|
### Und dann weg vom Rechner
|
||||||
|
|
||||||
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
|
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
|
||||||
@@ -258,6 +360,21 @@ sudo -u postgres dropdb probe
|
|||||||
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
|
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
|
||||||
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
|
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
|
||||||
|
|
||||||
|
### Und die Anhänge mitprüfen
|
||||||
|
|
||||||
|
Die Zahl in der Datenbank gegen die Zahl der Dateien halten — passt sie nicht, fehlt eine der beiden
|
||||||
|
Hälften:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compose: Zeilen in der Datenbank …
|
||||||
|
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc 'SELECT count(*) FROM "Anhang"'
|
||||||
|
# … gegen Dateien im Archiv
|
||||||
|
tar tzf anhaenge-2026-09-24.tar.gz | grep -c '\.' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Stimmen die Zahlen nicht überein, ist entweder der Abzug älter als das Archiv oder umgekehrt. Beides
|
||||||
|
zum **selben Zeitpunkt** sichern — am einfachsten im selben Skript, direkt hintereinander.
|
||||||
|
|
||||||
## Umzug auf einen neuen Server
|
## Umzug auf einen neuen Server
|
||||||
|
|
||||||
Derselbe Weg, nur mit einer Zwischenstation:
|
Derselbe Weg, nur mit einer Zwischenstation:
|
||||||
@@ -284,6 +401,6 @@ Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere
|
|||||||
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
|
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
|
||||||
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
|
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
|
||||||
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
|
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
|
||||||
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. |
|
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 5 oben. |
|
||||||
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
|
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
|
||||||
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |
|
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |
|
||||||
|
|||||||
+677
-2
@@ -24,12 +24,28 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
|
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
|
||||||
| #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min |
|
| #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min |
|
||||||
| #026 | Zwei Konventionen präzisieren | 20 min |
|
| #026 | Zwei Konventionen präzisieren | 20 min |
|
||||||
| | **Summe** | **36 h 05 min** |
|
| #020 | Vorgesetzter auch für von Hand angelegte Personen | 1 h 20 min |
|
||||||
|
| #021 | Stabiler Schlüssel für Seed-Vorlagen | 55 min |
|
||||||
|
| #022 | Schnellstart je Gerätetyp pflegen | 1 h 10 min |
|
||||||
|
| #023 | Protokoll ansehen | 1 h 50 min |
|
||||||
|
| #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min |
|
||||||
|
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 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 |
|
||||||
|
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
|
||||||
|
| #035 | M22 – Mitarbeiter-Selbstauskunft | 2 h 50 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 |
|
||||||
|
| | **Summe** | **73 h 55 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 | 45 min | in Arbeit |
|
| v0.2.0 | 38 h 35 min | in Arbeit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1089,3 +1105,662 @@ Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen
|
|||||||
Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht
|
Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht
|
||||||
(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
|
(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
|
||||||
davon steht geschrieben, statt verschwiegen zu werden.
|
davon steht geschrieben, statt verschwiegen zu werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-04 · Sitzung 17 · der ganze Tag
|
||||||
|
|
||||||
|
**Issues:** #021, #020, #022, #023, #046/#047, Phase-2-Plan, #030 (M17), #031 (M18), #032 (M19)
|
||||||
|
**Version:** 0.2.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
Eine lange Sitzung mit acht zusammengeführten Änderungen. Der Devlog-Eintrag kommt am Ende des Tages
|
||||||
|
und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal trägt.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Der Rückstand für `v0.2.0` ist leer.** Stabiler Seed-Schlüssel (#021), Vorgesetzter für von Hand
|
||||||
|
angelegte Personen (#020), Schnellstart je Gerätetyp in der Oberfläche pflegen (#022), und das
|
||||||
|
Protokoll ist ansehbar geworden (#023) — geschrieben wurde es seit dem Datenmodell, ansehen konnte
|
||||||
|
es niemand.
|
||||||
|
- **Phase 2 in acht Meilensteine geschnitten** (#028–#035), mit drei Entscheidungen, die vor dem
|
||||||
|
ersten Codepfad fallen mussten.
|
||||||
|
- **M17 Bausteine** (#030): ein Abschnitt, den mehrere Vorlagen teilen, versioniert nach denselben
|
||||||
|
Regeln wie eine Vorlage. Eingesetzt wird er als tiefe Kopie beim **Veröffentlichen** — damit ändert
|
||||||
|
eine neue Baustein-Fassung keine veröffentlichte Vorlagenversion und keinen laufenden Lauf.
|
||||||
|
- **M18 Abteilungsabschnitte** (#031): ein Abschnitt kann sagen, für welche Abteilungen er gilt.
|
||||||
|
Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben.
|
||||||
|
- **M19 Anhänge** (#032): Fotos und Dateien an einem Schritt, am Handy direkt aus der Kamera. Dateien
|
||||||
|
im Dateisystem, Metadaten in der Datenbank, jeder Abruf prüft Recht und Mandant.
|
||||||
|
- **Sicherung und Rücksicherung der Anhänge wirklich gefahren** — Abzug gezogen, Datenbank verworfen,
|
||||||
|
Verzeichnis gelöscht, zurückgespielt, und die ausgelieferten Bytes gegen die gespeicherte
|
||||||
|
`pruefsumme` gehalten.
|
||||||
|
- 1246 Vitest-Tests (von 1130 am Morgen), alle Schranken grün.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Ein Baustein wird beim Veröffentlichen aufgelöst, nicht beim Lesen.** Eine Auflösung zur Laufzeit
|
||||||
|
wäre weniger Code gewesen und hätte die Zusage „eine veröffentlichte Version wird nie verändert“
|
||||||
|
gebrochen — über eine Hintertür, die niemand so nennt.
|
||||||
|
- **`Lauf.geltendeAbschnitte` ist leer heißt „alle“.** Damit verhalten sich Läufe von vor #031
|
||||||
|
unverändert, ohne Migration von Daten.
|
||||||
|
- **Anhänge ins Dateisystem, nicht als `bytea`.** Ein `pg_dump` mit Fotos ist nach einem Jahr kein
|
||||||
|
Abzug mehr, den jemand einspielt. Der Preis ist die zweite Hälfte der Sicherung, und der steht
|
||||||
|
jetzt in `docs/betrieb/sicherung.md`.
|
||||||
|
- **Hochgeladen wird über eine Server Action, nicht über einen zweiten Endpunkt.** Das Issue hatte
|
||||||
|
einen Route Handler vorgesehen; der wäre eine zweite Tür mit eigener Rechteprüfung. Der Route
|
||||||
|
Handler bleibt für das Ausliefern, und er ruft `rechtErforderlich` selbst — die Hülle unter `(app)`
|
||||||
|
deckt Route Handler nicht ab.
|
||||||
|
- **Die Regel „geltende Abschnitte“ liegt in `src/lib`**, nicht in einem Lesemodell. Drei Schichten
|
||||||
|
brauchen sie; lebte sie in einem Lesemodell, importierte ein Dienst daraus.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Ein Fehler, den neunzehn grüne Tests nicht sehen konnten.** Der Schritt hat zwei Felder `datei`
|
||||||
|
in einem Formular — „Foto“ und „Datei“ —, abgeschickt wird immer beides, und `FormData.get` nahm
|
||||||
|
das erste, also das leere. Wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging.
|
||||||
|
Kein Dienst-Test schickt zwei Felder; gefunden hat es erst die Klickprobe am gebauten Stand. Die
|
||||||
|
Lehre ist dieselbe wie in #019: **Fehler im dokumentierten Weg eines Dritten sind für eine grüne
|
||||||
|
Suite unsichtbar.** Seitdem gibt es `tests/e2e/anhaenge.spec.ts`, der bewusst über den zweiten
|
||||||
|
Knopf hochlädt, und die Gegenprobe wurde gefahren: mit `get` statt `getAll` wird er rot.
|
||||||
|
- **`docs/betrieb/sicherung.md` enthielt einen falschen Satz**, nicht bloß eine Lücke: „Kein
|
||||||
|
Upload-Verzeichnis, keine erzeugten Dateien.“ Mit den Anhängen hätte eine Sicherung nach dieser
|
||||||
|
Anleitung still Daten verloren. Die Datei ist neu geschrieben.
|
||||||
|
- **Zweimal habe ich dem Produkt angelastet, was mein Prüfskript falsch machte** — ein zu weiter
|
||||||
|
`role="status"`-Selektor und eine Prüfung direkt nach `reload()`, als `networkidle` zu früh
|
||||||
|
zurückkam. Die Datenbank hat beide Male entschieden, und sie hat dem Produkt recht gegeben.
|
||||||
|
- **Einmal die Ursache falsch benannt.** Ich hielt eine `positionenSauber`-Prüfung für die Behebung
|
||||||
|
des Schnellstart-Fehlers; die Gegenprobe zeigte, dass die Tests ohne sie grün bleiben. Die
|
||||||
|
Behebung ist der deterministische Stichentscheid beim Sortieren.
|
||||||
|
- **Ein Numerusfehler in einem eigenen Hinweistext** („Ein Abschnitt gilt … und *bleiben* weg“),
|
||||||
|
gefunden beim Durchklicken. Alles, was ein Mensch liest, schreibt sich richtig.
|
||||||
|
- **`tests/datenbankregeln.test.ts` wurde rot** — genau wie gewollt: er zählt die handgeschriebenen
|
||||||
|
Datenbankobjekte namentlich auf, und die zwei neuen CHECK-Bedingungen und zwei zusammengesetzten
|
||||||
|
Fremdschlüssel standen nicht drin.
|
||||||
|
- **`/daten/` fehlte in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten Commit.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **M20 (#033)** Live-Aktualisierung per SSE statt Polling, dann **M21 (#034)** Benachrichtigungen,
|
||||||
|
**M22 (#035)** Selbstauskunft. **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.
|
||||||
|
- **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-05 · Sitzung 18 · 09:00–10:40
|
||||||
|
|
||||||
|
**Issue:** #033 (M20)
|
||||||
|
**Version:** 0.2.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Die Live-Aktualisierung läuft über einen Ereignisstrom.** Vier Trigger schicken
|
||||||
|
`NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung je Prozess hört zu, der Route Handler
|
||||||
|
schiebt weiter. Gemessen: der Haken ist nach **485 ms** beim Zuschauer (Handy: 487 ms), vorher
|
||||||
|
bis zu zehn Sekunden.
|
||||||
|
- **Das Polling bleibt als Rückfall** — an derselben Adresse, unterschieden nur am `Accept`-Kopf.
|
||||||
|
- 1266 Vitest-Tests (vorher 1247), dazu `tests/e2e/live.spec.ts` mit zwei Browser-Kontexten.
|
||||||
|
- Der Herzschlag ist **echt über 70 Sekunden** gefahren: `readyState` blieb `OPEN`, ein einziges
|
||||||
|
Ereignis, kein Neuaufbau.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Das Ereignis kommt aus der Datenbank, nicht aus dem Dienst.** Ein Ereignis, das der Service
|
||||||
|
verschickt, entsteht nur auf dem Weg durch den Service — ein Trigger sieht auch den Seed, eine
|
||||||
|
Migration und die Reparatur per `psql`. Und `NOTIFY` erreicht jede Instanz, ohne einen zweiten
|
||||||
|
Dienst wie Redis.
|
||||||
|
- **Ein Handler für beide Wege.** Damit ist „das Polling bleibt als Rückfall, nicht als zweite
|
||||||
|
Wahrheit“ keine Absichtserklärung, sondern Bauart: eine Rechteprüfung, ein Lesemodell, eine
|
||||||
|
Nutzlast.
|
||||||
|
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
|
||||||
|
mit JSON; `EventSource` meldet darauf einen Fehler und versucht es nicht wieder, und der Client
|
||||||
|
fällt von sich aus zurück. Kein eigener Fehlercode.
|
||||||
|
- **Keine Verbindung aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurück und verteilt sie neu;
|
||||||
|
ein `LISTEN` darauf gilt danach für eine fremde Anfrage oder für niemanden.
|
||||||
|
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert — sonst belegte jede
|
||||||
|
Hintergrundlasche genau die Verbindung, die die Obergrenze schützen soll.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Das Issue hätte einen Rückschritt gebaut.** Es nannte Trigger auf `Schrittergebnis` und `Lauf`.
|
||||||
|
`laufZustand` trägt aber auch die Zahl der Kommentare, und seit #032 gibt es Anhänge: der Strom
|
||||||
|
sieht **nur**, was gemeldet wird, während das Polling den ganzen Stand verglich. Mit den zwei
|
||||||
|
Triggern aus dem Issue wäre der Kommentar des Kollegen nie angekommen — und die Seite hätte
|
||||||
|
ausgesehen wie immer. Das ist derselbe Fehlertyp wie in #032: eine Zusage, die niemand prüft.
|
||||||
|
Jetzt melden vier Tabellen, `laufZustand` trägt `anzahlAnhaenge`, und beide Tests zählen namentlich
|
||||||
|
auf, was dazugehört.
|
||||||
|
- **Zwei Fehler in `reverse-proxy.md`, beide von vorgestern.** `client_max_body_size 8m` bei
|
||||||
|
`ANHANG_MAX_MB=10`: ein 10-MB-Foto wäre an nginx mit 413 gescheitert, und die eigene Meldung
|
||||||
|
(„Erlaubt sind höchstens 10,0 MB“) wäre nie erschienen. Dazu ein Kommentar, der noch das alte
|
||||||
|
Polling beschrieb. **Gefunden beim Schreiben der Doku, nicht von einem Test** — Dateien, die ein
|
||||||
|
Dritter liest, prüft hier niemand.
|
||||||
|
- **`## 5e` war in `docs/architektur.md` schon belegt.** Statt alles umzunummerieren und Verweise zu
|
||||||
|
riskieren, hängt der neue Text als `###` unter 5d.
|
||||||
|
- **Zwei eigene Testfehler, keine Produktfehler:** `schritte` zählte die „erledigt“-Knöpfe der
|
||||||
|
ganzen Seite (34) statt die des ersten Abschnitts (5), und ein `const` nach einem `?.`-Guard
|
||||||
|
verlor in der Closure seine Verengung. Beide Male war die Meldung eindeutig.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **M21 (#034)** Benachrichtigungen per Mail und Webhook — baut direkt auf den Ereignissen auf, die
|
||||||
|
jetzt stehen. Danach **M22 (#035)** Selbstauskunft.
|
||||||
|
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||||
|
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-05 · Sitzung 19 · 10:45–14:15
|
||||||
|
|
||||||
|
**Issue:** #034 (M21)
|
||||||
|
**Version:** 0.2.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Vier Anlässe gehen hinaus**, per Mail über SMTP und optional per Webhook mit
|
||||||
|
HMAC-SHA256-Signatur. Je Person abschaltbar, und niemand bekommt Post über die eigene Tat.
|
||||||
|
- **Eine Warteschlange in der Datenbank**, abgearbeitet von einem eigenen Zeitplan im selben
|
||||||
|
Prozess. Fehlschläge nach 1, 5, 30 und 120 Minuten wiederholt, danach sichtbar aufgegeben.
|
||||||
|
- **Verwaltung → Zustellungen** zeigt, was hinausging und was nicht, mit Grund.
|
||||||
|
- 1302 Vitest-Tests (vorher 1266), davon 36 neue — **gegen einen echten SMTP-Fänger** (mailpit),
|
||||||
|
nicht gegen eine abgefangene Funktion.
|
||||||
|
- Im gebauten Stand gefahren: SMTP eingetragen, Testmail angekommen, ein überfälliger Lauf angelegt,
|
||||||
|
und der Zeitplan hat die Meldung **von selbst** verschickt.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Eingereiht im Anfrage-Pfad, gesendet vom Zeitplan.** Ein hängender Mailserver darf kein Abhaken
|
||||||
|
blockieren. Das Einreihen passiert nach der Transaktion und mit abgefangenem Fehler.
|
||||||
|
- **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was *damals* war; würde der
|
||||||
|
Text erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
|
||||||
|
- **Die Texte liegen in `src/lib/meldungen.ts` und nehmen nur Kopfdaten.** Der Typ lässt keinen
|
||||||
|
Schrittinhalt zu — das ist die Absicht, und es ist die einzige Stelle, die man dafür ändern müsste.
|
||||||
|
- **Die Sperre wurde aus dem Abgleich herausgezogen** nach `services/sperre.ts`. Zwei Fassungen
|
||||||
|
derselben Mechanik wären die schlechteste aller Lösungen.
|
||||||
|
- **Die Mandantengrenze gilt auch für Empfänger.** Ohne diese Prüfung wäre die Benachrichtigung der
|
||||||
|
eine Weg nach draußen, den `sichtbareKundenIds` nicht abdeckt.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **pnpm wollte eine Sicherheitsregel umgehen, und ich hätte es fast committet.** `pnpm add
|
||||||
|
nodemailer` trug von sich aus `minimumReleaseAgeExclude: nodemailer@10.0.15` in
|
||||||
|
`pnpm-workspace.yaml` ein — die Version war **vom selben Tag**. Das ist kein Formfehler, sondern
|
||||||
|
genau die Warnung, vor der die Regel schützt. Gepinnt ist jetzt `10.0.10` vom 14. September, die
|
||||||
|
Ausnahme ist weg, und die Konvention steht in `CLAUDE.md`. Aufgefallen beim Durchsehen des Diffs,
|
||||||
|
nicht durch einen Test — eine Zeile in einer Datei, die man leicht übersieht.
|
||||||
|
- **Drei Tests waren stillschweigend übersprungen.** `it.runIf(mailpitDa)` wird beim **Einlesen** der
|
||||||
|
Datei ausgewertet; mein Flag stand in `beforeAll` und kam zu spät. Der erste Lauf meldete
|
||||||
|
„3 skipped“, war grün — und hatte den echten SMTP-Dialog nie geprüft. Genau das Muster, das
|
||||||
|
`ldap` in #019 schon einmal gekostet hat. `mailpit` steht jetzt im Tor vor dem Commit, und
|
||||||
|
`pruefe:doku` erzwingt es; gegengeprobt.
|
||||||
|
- **Die Wartezeiten waren um eins verschoben.** Nach dem ersten Fehlschlag wartete meine erste
|
||||||
|
Fassung fünf Minuten statt einer. Der Test hat es sofort gezeigt — und die Umbenennung des
|
||||||
|
Parameters auf `versucheGesamt` macht die Semantik jetzt lesbar.
|
||||||
|
- **Zwei Knöpfe „Speichern“ auf einer Seite**, beim Durchklicken gefunden. Der neue heißt jetzt
|
||||||
|
„Benachrichtigungen speichern“.
|
||||||
|
- **Drei eigene Testfehler**, keine Produktfehler: die `Verzeichnisquelle` hat `url`, nicht
|
||||||
|
`host`/`port`; `Lauf` mit Status `ABGESCHLOSSEN` braucht `abgeschlossenAm` (die Datenbank verlangt
|
||||||
|
es); und ein fester Testzeitpunkt lag **vor** der echten Einreihung, sodass der Durchgang die
|
||||||
|
Zeile gar nicht erst anfasste.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **M22 (#035)** Mitarbeiter-Selbstauskunft — dort kommt auch die Profilseite, auf der jeder seine
|
||||||
|
eigenen Benachrichtigungen abschalten kann.
|
||||||
|
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||||
|
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-06 · Sitzung 20 · 06:00–08:50
|
||||||
|
|
||||||
|
**Issue:** #035 (M22)
|
||||||
|
**Version:** 0.2.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Die fünfte Rolle `MITARBEITER`** mit genau einem Recht: `selbstauskunft`. Zugeordnet beim
|
||||||
|
Anmelden über den UPN, festgehalten in `Benutzer.mitarbeiterId`.
|
||||||
|
- **`/meine-geraete`**: meine Geräte, je Gerät die Arbeiten chronologisch, dazu meine Stammdaten aus
|
||||||
|
dem Verzeichnis. Titel, Zeitraum, Status — nichts weiter.
|
||||||
|
- **Drei PRs von gestern sind auf `main`** (#59, #60, #61); die beiden Folgezweige mussten nach dem
|
||||||
|
ersten Squash rebased werden.
|
||||||
|
- 1341 Vitest-Tests (vorher 1302), davon 25 neue. Im gebauten Stand mit einem echten
|
||||||
|
MITARBEITER-Konto durchgeklickt.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **`selbstauskunft` ist ein Ersatz für `lesen`, kein Zusatz** — deshalb hat auch ein ADMIN es
|
||||||
|
nicht. Ein zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die
|
||||||
|
Einschränkung auf die eigene Person vergisst.
|
||||||
|
- **Was zurückkommt, entscheidet das Lesemodell und nicht die Seite.** Eine Seite, die filtert, ist
|
||||||
|
eine Seite, die es vergessen kann.
|
||||||
|
- **Kein zusammengesetzter Fremdschlüssel**, obwohl die Aufgabe ihn verlangte: ein `Benutzer` trägt
|
||||||
|
keine `kundeId`. Stattdessen ein CHECK, dass Rolle und Zuordnung zusammengehören, und ein
|
||||||
|
eindeutiger Index, dass eine Person höchstens ein Konto hat.
|
||||||
|
- **Ein mehrdeutiger UPN wird abgewiesen**, nicht geraten. Zwei Kunden können dieselbe Person
|
||||||
|
führen; ein `findFirst` entschiede nach Sortierung darüber, wessen Geräte jemand sieht.
|
||||||
|
- **Die Weiche greift nur mit konfigurierten Rollengruppen.** Ein Update, das jeden aussperrt, dem
|
||||||
|
die Gruppen fehlen, wäre ein kaputtes Update.
|
||||||
|
- **Nicht `/kein-zugriff` bei gescheiterter Anmeldung**, wie das Issue vorsah: diese Seite verlangt
|
||||||
|
eine Sitzung, die es dann nicht gibt — das wäre eine Schleife. Die Begründung steht auf der
|
||||||
|
Anmeldeseite, mit zwei eigenen Codes.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Eine Gegenprobe blieb grün, und das war der eigentliche Befund.** Der Test „sieht keine Notizen“
|
||||||
|
prüft die Antwort, und die ist durch `laufBauen` sicher. Als ich im `select` versuchsweise
|
||||||
|
`ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der Kopfkommentar behauptet, das
|
||||||
|
`select` hole es „gar nicht erst“. **Eine Zusage ohne eigenen Test ist keine Zusage**, auch wenn
|
||||||
|
ein Nachbartest sie zufällig mitträgt. Jetzt liest ein Test die Datei selbst.
|
||||||
|
- **Ein Test von #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als Startwert und
|
||||||
|
damit als Untergrenze; mit einer Rolle darunter lieferte sie **mehr** Rechte als übergeben. Drei
|
||||||
|
Jahre Projektzeit hätte das niemand gemerkt, weil es keine Rolle unter `LESER` gab.
|
||||||
|
- **Zwei Bedienfehler beim Durchklicken**, beide dieselbe Ursache: das Standardziel nach der
|
||||||
|
Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür fehlt
|
||||||
|
dir die Berechtigung“ — und der Knopf dort führte zurück auf `/`, also im Kreis. Ohne Klickprobe
|
||||||
|
wäre das bis zum ersten echten Nutzer unentdeckt geblieben: **alle Tests waren grün.**
|
||||||
|
- **Die Navigation hätte sechs tote Links angeboten.** `recht` stand nur am Admin-Bereich, weil
|
||||||
|
bisher jede Rolle `lesen` durfte.
|
||||||
|
- **Ein falscher Satz in `docs/betrieb/authentik.md`:** „Ist der Benutzer in keiner der vier
|
||||||
|
Gruppen, bekommt er `LESER`.“ Richtig war er bis zu dieser Sitzung.
|
||||||
|
- **Drei eigene Testfehler**, keine Produktfehler: Tag-Codes müssen dem Alphabet ohne `0`, `1`, `I`
|
||||||
|
und `O` folgen; die Kundenbindungstests gaben nur eine Kundengruppe mit und hingen damit an der
|
||||||
|
neuen Weiche; und ein Einfüge-Skript meldete „ok“, ohne geschrieben zu haben — seitdem prüfe ich
|
||||||
|
nach dem Einfügen mit `grep`.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **Phase 2 ist bis auf die beiden großen Verzeichnis-Themen fertig:** **M15 (#028)**
|
||||||
|
Microsoft-Graph-Quelle und **M16 (#029)** SCIM-Endpunkt.
|
||||||
|
- Vor beiden wäre ein unabhängiger Review über die sechs Meilensteine dieser Phase angebracht —
|
||||||
|
M17 bis M22 liegen ohne fremden Blick auf `main`.
|
||||||
|
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|||||||
@@ -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.2.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** phase-2, infra
|
**Labels:** phase-2, infra
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||||
|
|
||||||
@@ -53,3 +53,13 @@ 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.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
|
# #027 Die Editor-Abläufe lassen ihre Vorlagen stehen
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**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:** –
|
**Geschätzt:** 1 h **Tatsächlich:** 2 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -22,14 +22,69 @@ Vorlagenliste in der Oberfläche, Screenshots, und jede Abfrage von Hand. Bei ei
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Die Abläufe unter `tests/e2e/`, die Vorlagen anlegen, räumen sie in einem Nachlauf wieder weg —
|
- [x] 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)
|
||||||
- [ ] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
|
- [x] Dasselbe für Geräte, Personen und Läufe prüfen, die die Abläufe anlegen
|
||||||
- [ ] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
|
- [x] Einmalig aufräumen: die vorhandenen Reste aus der Entwicklungsdatenbank entfernen
|
||||||
- [ ] Prüfen, ob ein Nachlauf das überhaupt leisten kann — ein Ablauf, der mitten im Lauf scheitert,
|
- [x] 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
|
||||||
|
|
||||||
- [ ] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
|
- [x] Nach `pnpm test:e2e` steht in `Vorlage` keine Zeile mit `ZZ E2E`
|
||||||
- [ ] Ein abgebrochener Lauf lässt ebenfalls nichts stehen
|
- [x] 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).
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
# #028 M15 – Quellenarten und Microsoft-Graph-Quelle
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**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:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 4 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -17,39 +17,94 @@ Dieser Meilenstein legt dazu die Weiche, die auch [#029](029-scim-endpunkt.md) b
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **Ein Enum `QuellenArt {LDAP GRAPH SCIM}` auf `Verzeichnisquelle`** — Empfehlung. Die Felder
|
- [x] **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.
|
||||||
- [ ] **Wie `{{ou}}` in Entra ID aussieht.** Es gibt keinen DN und keine OU. Ehrlich ist, den
|
- [x] **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
|
||||||
|
|
||||||
- [ ] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
- [x] `QuellenArt` als Enum, Migration mit `LDAP` für alle bestehenden Zeilen
|
||||||
- [ ] Graph-Zugang: `tenantId`, `clientId`, `clientSecret` (verschlüsselt wie das Bind-Passwort, nie
|
- [x] 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
|
||||||
- [ ] Lesen über `GET /v1.0/users` mit `$select`, Seitenabruf über `@odata.nextLink`; der
|
- [x] 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)`
|
||||||
- [ ] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
- [x] Attributzuordnung mit Graph-Vorgaben (`givenName`, `surname`, `displayName`, `mail`,
|
||||||
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
`userPrincipalName`, `onPremisesSamAccountName`, `department`, `jobTitle`, `accountEnabled`)
|
||||||
- [ ] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
- [x] `externeId` ist die Entra-`id` (GUID). Sie ist stabil und übersteht eine Umbenennung
|
||||||
- [ ] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
- [x] „Verbindung testen“ wie bei LDAP: die ersten zehn zugeordneten Treffer zeigen
|
||||||
- [ ] Der Abgleich läuft durch **denselben** Dienst wie LDAP — nur das Holen der Einträge
|
- [x] 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
|
||||||
- [ ] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
- [x] Admin → Verzeichnis zeigt die Art je Quelle und bietet beim Anlegen die Wahl
|
||||||
- [ ] Anleitung Kapitel 06 und `docs/betrieb/`: was in Entra ID einzurichten ist
|
- [x] 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
|
||||||
|
|
||||||
- [ ] Test gegen einen **abspielenden HTTP-Doppelgänger** mit aufgezeichneten Graph-Antworten: erster
|
- [x] 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`
|
||||||
- [ ] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
- [x] Test: der Seitenabruf folgt `@odata.nextLink` über mindestens drei Seiten
|
||||||
- [ ] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
- [x] Test: `clientSecret` erscheint in keiner Antwort und in keinem Protokolleintrag
|
||||||
- [ ] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
- [x] Test: eine Graph-Quelle lässt `{{ou}}` leer, statt einen Wert zu erfinden
|
||||||
- [ ] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
- [x] Der LDAP-Weg verhält sich unverändert — alle Tests aus #016 bleiben grün
|
||||||
- [ ] **Offen und benannt:** gegen einen echten Entra-Mandanten ist das nicht gelaufen; die
|
- [x] **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).
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
|
# #029 M16 – SCIM-Endpunkt als Alternative zum Abgleich
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**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:** –
|
**Geschätzt:** 12 h **Tatsächlich:** 4 h 05 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -16,41 +16,137 @@ Setzt [#028](028-graph-quelle.md) voraus: die Quellenart `SCIM` braucht die Weic
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **Ein Token je Quelle, als Hash gespeichert** — Empfehlung, dasselbe Verfahren wie beim
|
- [x] **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.
|
||||||
- [ ] **Was bei `DELETE /Users/{id}` geschieht:** `aktiv = false`, nicht löschen. Das ist die Regel
|
- [x] **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
|
||||||
|
|
||||||
- [ ] Route Handler unter `src/app/api/scim/v2/`: `Users` (GET mit Filter `userName eq`, POST),
|
- [x] 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
|
||||||
- [ ] **Jeder dieser Handler prüft selbst**: das Token, und daraus die Quelle und den Kunden. Die
|
- [x] **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)
|
||||||
- [ ] Zuordnung: `externalId` → `externeId`, `userName` → `upn`, `name.givenName`/`familyName`,
|
- [x] 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`
|
||||||
- [ ] Antwortform nach RFC 7644: `schemas`, `id`, `meta.resourceType`, `meta.location`, bei Listen
|
- [x] 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`
|
||||||
- [ ] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
- [x] `PATCH` mit `op: add|replace|remove` auf den Pfaden, die die Anbieter wirklich schicken —
|
||||||
nicht der ganze Standard
|
nicht der ganze Standard
|
||||||
- [ ] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
- [x] Idempotenz: zweimal dasselbe `POST` ergibt `409`, nicht eine zweite Person
|
||||||
- [ ] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
- [x] Protokolleintrag je Vorgang, mit der Quelle als Urheber (Systemlauf, kein Benutzer)
|
||||||
- [ ] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
- [x] Admin → Verzeichnis: Token erzeugen, einmal anzeigen, widerrufen; letzte Zustellung sichtbar
|
||||||
- [ ] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
- [x] Anleitung Kapitel 06: Einrichtung in authentik und in Entra ID
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
- [x] Test gegen **aufgezeichnete Anfragen** von authentik und Entra ID (Form, nicht erfunden):
|
||||||
anlegen, aktualisieren, deaktivieren, wieder aktivieren
|
anlegen, aktualisieren, deaktivieren, wieder aktivieren
|
||||||
- [ ] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
- [x] Test: ohne Token `401`, mit fremdem Token kein Zugriff auf die Personen einer anderen Quelle
|
||||||
- [ ] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
- [x] Test: `DELETE` setzt `aktiv = false` und löscht nichts
|
||||||
- [ ] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
- [x] Test: zweimal dasselbe `POST` ergibt `409` und keine zweite Zeile
|
||||||
- [ ] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
- [x] Test: das Token steht nur als Hash in der Datenbank und in keiner Antwort
|
||||||
- [ ] Test: eine Quelle der Art `SCIM` wird vom Zeitplan **nicht** abgeglichen — sie bekommt
|
- [x] 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`.
|
||||||
|
|||||||
+56
-18
@@ -1,10 +1,10 @@
|
|||||||
# #032 M19 – Anhänge und Fotos an Schritten
|
# #032 M19 – Anhänge und Fotos an Schritten
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52)
|
**Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend, frontend, infra
|
**Labels:** phase-2, backend, frontend, infra
|
||||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 3 h 05 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ können, am Handy direkt aus der Kamera.
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der
|
- [x] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der
|
||||||
Umgebung (`ANHAENGE_PFAD`), darunter nach Kunde und Lauf gegliedert. Gegen `bytea` in Postgres
|
Umgebung (`ANHAENGE_PFAD`), darunter nach Kunde und Lauf gegliedert. Gegen `bytea` in Postgres
|
||||||
spricht die Sicherung: ein `pg_dump` mit Fotos ist nach einem Jahr kein `pg_dump` mehr, das
|
spricht die Sicherung: ein `pg_dump` mit Fotos ist nach einem Jahr kein `pg_dump` mehr, das
|
||||||
jemand einspielt. Gegen S3 spricht, dass diese Anwendung selbst gehostet läuft und keinen
|
jemand einspielt. Gegen S3 spricht, dass diese Anwendung selbst gehostet läuft und keinen
|
||||||
@@ -23,32 +23,70 @@ können, am Handy direkt aus der Kamera.
|
|||||||
ein Volume, und `docs/betrieb/sicherung.md` muss beides nennen. Eine Sicherung, die nur die
|
ein Volume, und `docs/betrieb/sicherung.md` muss beides nennen. Eine Sicherung, die nur die
|
||||||
Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt
|
Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt
|
||||||
sonst teuer bezahlt hat.
|
sonst teuer bezahlt hat.
|
||||||
- [ ] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine
|
- [x] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine
|
||||||
Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die
|
Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die
|
||||||
Metadaten verliert und eine Bildbibliothek ins Projekt zieht.
|
Metadaten verliert und eine Bildbibliothek ins Projekt zieht.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme,
|
- [x] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme,
|
||||||
`erstelltVonId`, `erstelltAm`; Datei daneben im Dateisystem
|
`erstelltVonId`, `erstelltAm`; Datei daneben im Dateisystem
|
||||||
- [ ] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und
|
- [x] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und
|
||||||
**jeder** Abruf prüft Recht und Mandant, auch der des Bildes selbst
|
**jeder** Abruf prüft Recht und Mandant, auch der des Bildes selbst
|
||||||
- [ ] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der
|
- [x] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der
|
||||||
Umgebung einstellbar; eine abgewiesene Datei nennt den Grund
|
Umgebung einstellbar; eine abgewiesene Datei nennt den Grund
|
||||||
- [ ] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
|
- [x] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
|
||||||
- [ ] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr
|
- [x] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr
|
||||||
dazu und keiner weg. `laufMussVeraenderbarSein` sitzt an genau einer Stelle — dort anschließen
|
dazu und keiner weg. `laufMussVeraenderbarSein` sitzt an genau einer Stelle — dort anschließen
|
||||||
- [ ] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
|
- [x] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
|
||||||
- [ ] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
|
- [x] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
|
||||||
Sicherung **und** Rückspielung
|
Sicherung **und** Rückspielung
|
||||||
- [ ] Anleitung Kapitel 02 und 08
|
- [x] Anleitung Kapitel 02 und 08
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
|
- [x] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
|
||||||
- [ ] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
|
- [x] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
|
||||||
- [ ] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
|
- [x] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
|
||||||
- [ ] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
|
- [x] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
|
||||||
- [ ] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie
|
- [x] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie
|
||||||
`tests/betriebsstand.test.ts` es für die Datenbank schon prüft)
|
`tests/betriebsstand.test.ts` es für die Datenbank schon prüft)
|
||||||
- [ ] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben
|
- [x] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **Hochgeladen wird über eine Server Action, nicht über einen eigenen Route Handler.** Die Aufgabe
|
||||||
|
oben nannte einen Route Handler; der wäre eine zweite Tür mit eigener Rechteprüfung. Eine Server
|
||||||
|
Action prüft dasselbe Recht an derselben Stelle wie jede andere Schreiboperation am Lauf. Ein
|
||||||
|
Route Handler bleibt für das **Ausliefern** — dort braucht es eine Adresse, die ein `<img>` holen
|
||||||
|
kann, und `src/app/api/anhaenge/[id]/route.ts` ruft selbst `rechtErforderlich("lesen")`, weil die
|
||||||
|
Hülle unter `(app)` Route Handler nicht abdeckt.
|
||||||
|
- **Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen.** `<ANHAENGE_PFAD>/<kundeId>/<laufId>/<id>.<endung>`.
|
||||||
|
Der Name vom Browser wird trotzdem gesäubert, weil er in einer Antwortkopfzeile landet.
|
||||||
|
- **Kein SVG**, obwohl es ein Bild ist: ein SVG ist ein Dokument, das Skripte ausführt. Darum eine
|
||||||
|
Liste erlaubter Medientypen und kein `image/*`.
|
||||||
|
- **Reihenfolge beim Anlegen:** prüfen, Datei schreiben (`flag: "wx"`), dann die Zeile — und bei
|
||||||
|
einem Fehler die Datei wieder weg. Eine Datei ohne Zeile findet niemand; eine Zeile ohne Datei ist
|
||||||
|
ein Anhang, der beim Öffnen fehlschlägt. Beim Entfernen umgekehrt: erst die Zeile, dann die Datei.
|
||||||
|
- **Zwei Felder `datei` in einem Formular — und `FormData.get` nahm das falsche.** „Foto“ und
|
||||||
|
„Datei“ stehen im selben Formular, abgeschickt wird immer beides, und das leere Feld kam zuerst:
|
||||||
|
wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. Neunzehn grüne Dienst-Tests
|
||||||
|
sahen davon nichts, weil kein Dienst-Test zwei Felder schickt. Gefunden hat es erst die Klickprobe
|
||||||
|
am gebauten Stand. Die Regel liegt jetzt als reine Funktion `gewaehlteDatei()` in `src/lib`, und
|
||||||
|
`tests/e2e/anhaenge.spec.ts` lädt bewusst **über den zweiten Knopf** hoch. Gegenprobe gefahren:
|
||||||
|
mit `get` statt `getAll` wird der Ablauf rot.
|
||||||
|
- **`docs/betrieb/sicherung.md` war nicht nur unvollständig, sondern an einer Stelle falsch** — dort
|
||||||
|
stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“. Die Datei ist neu geschrieben: drei
|
||||||
|
Dinge statt zwei, `tar` für beide Betriebswege, der `chown`-Fallstrick nach einer Rücksicherung als
|
||||||
|
`root`, ein Sicherungsskript für den systemd-Timer, das Datenbank und Anhänge im **selben** Lauf
|
||||||
|
mitnimmt, und die Gegenprobe über die Zahlen.
|
||||||
|
- **Sicherung und Rücksicherung wirklich gefahren**, nicht beschrieben: Abzug gezogen, Datenbank
|
||||||
|
verworfen und neu angelegt, Verzeichnis gelöscht, erst nur die Datenbank zurückgespielt — und da
|
||||||
|
meldete der Abruf genau den dokumentierten Satz „Die Datei zu … fehlt im Dateisystem“ —, dann die
|
||||||
|
Anhänge dazu, und die ausgelieferten Bytes gegen die gespeicherte `pruefsumme` geprüft. Beide
|
||||||
|
stimmten.
|
||||||
|
- **`tests/datenbankregeln.test.ts` musste nachziehen.** Er zählt die handgeschriebenen
|
||||||
|
Datenbankobjekte namentlich auf; die zwei neuen CHECK-Bedingungen und die beiden zusammengesetzten
|
||||||
|
Fremdschlüssel standen nicht drin, und der Test wurde dadurch rot — genau wie gewollt.
|
||||||
|
- **`/daten/` ist jetzt in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten
|
||||||
|
Commit.
|
||||||
|
|||||||
+57
-15
@@ -1,10 +1,10 @@
|
|||||||
# #033 M20 – Live-Aktualisierung per SSE statt Polling
|
# #033 M20 – Live-Aktualisierung per SSE statt Polling
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53)
|
**Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend, frontend
|
**Labels:** phase-2, backend, frontend
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 1 h 40 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -17,36 +17,78 @@ Die Vorleistung steht: das Polling liegt in genau einem Route Handler
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis`
|
- [x] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis`
|
||||||
und `Lauf` schickt `NOTIFY lauf_geaendert, '<laufId>'`; der Route Handler hält eine Verbindung
|
und `Lauf` schickt `NOTIFY lauf_geaendert, '<laufId>'`; der Route Handler hält eine Verbindung
|
||||||
und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne
|
und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne
|
||||||
einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre
|
einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre
|
||||||
dasselbe Polling, nur an anderer Stelle.
|
dasselbe Polling, nur an anderer Stelle.
|
||||||
- [ ] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter
|
- [x] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter
|
||||||
auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der
|
auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der
|
||||||
Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner —
|
Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner —
|
||||||
deshalb ein Test je Weg.
|
deshalb ein Test je Weg.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
|
- [x] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
|
||||||
kennt Trigger nicht und will sie löschen)
|
kennt Trigger nicht und will sie löschen)
|
||||||
- [ ] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag
|
- [x] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag
|
||||||
(sonst schließen Proxys die Verbindung) und beendet sauber bei `abort`
|
(sonst schließen Proxys die Verbindung) und beendet sauber bei `abort`
|
||||||
- [ ] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder
|
- [x] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder
|
||||||
andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab)
|
andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab)
|
||||||
- [ ] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
|
- [x] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
|
||||||
`error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch
|
`error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch
|
||||||
- [ ] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für
|
- [x] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für
|
||||||
Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben
|
Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben
|
||||||
- [ ] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
|
- [x] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
|
||||||
Verbindungen nicht aufbraucht
|
Verbindungen nicht aufbraucht
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
|
- [x] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
|
||||||
Sekunde, ohne neu zu laden
|
Sekunde, ohne neu zu laden
|
||||||
- [ ] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
|
- [x] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
|
||||||
- [ ] Test: ein Lauf eines fremden Kunden liefert keinen Strom
|
- [x] Test: ein Lauf eines fremden Kunden liefert keinen Strom
|
||||||
- [ ] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
|
- [x] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
|
||||||
- [ ] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
|
- [x] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **Es wäre ein Rückschritt geworden.** Das Issue nannte Trigger auf `Schrittergebnis` und `Lauf`.
|
||||||
|
`laufZustand` trägt aber auch die Zahl der **Kommentare** — das Polling verglich alle zehn
|
||||||
|
Sekunden den ganzen Stand und sah sie damit, ein Strom sieht nur, was gemeldet wird. Mit den zwei
|
||||||
|
Triggern aus dem Issue wäre der Kommentar des Kollegen **nie** angekommen, und niemandem wäre
|
||||||
|
etwas aufgefallen: die Seite sieht aus wie immer. Jetzt melden vier Tabellen, und
|
||||||
|
`tests/datenbankregeln.test.ts` zählt sie namentlich auf. Gegenprobe gefahren: ohne die Trigger auf
|
||||||
|
`Kommentar` und `Anhang` wird `tests/lauf-ereignisse.test.ts` rot.
|
||||||
|
- **`laufZustand` hat `anzahlAnhaenge` bekommen.** Anhänge gibt es seit #032, im Zustand standen sie
|
||||||
|
nicht. Ein Ereignis ohne Entsprechung im Zustand löst nichts aus, weil der Client vergleicht,
|
||||||
|
bevor er neu lädt — ein angehängtes Foto wäre also auch mit Trigger unsichtbar geblieben.
|
||||||
|
- **Ein Handler für beide Wege, unterschieden am `Accept`-Kopf.** So steht „Polling bleibt als
|
||||||
|
Rückfall, nicht als zweite Wahrheit“ nicht nur im Issue: es ist dieselbe Rechteprüfung, dasselbe
|
||||||
|
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand
|
||||||
|
`rechtErforderlich` vergessen kann.
|
||||||
|
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
|
||||||
|
mit JSON. `EventSource` verlangt `text/event-stream`, meldet einen Fehler und versucht es **nicht
|
||||||
|
wieder** — der Client fällt damit von sich aus auf Polling zurück. Kein eigener Fehlercode, kein
|
||||||
|
zweiter Pfad.
|
||||||
|
- **Eine LISTEN-Verbindung je Prozess, nicht je Strom** — und ausdrücklich **nicht** aus dem
|
||||||
|
Prisma-Pool: ein Pool gibt Verbindungen zurück und verteilt sie neu, ein `LISTEN` darauf gilt
|
||||||
|
danach für eine fremde Anfrage oder für niemanden.
|
||||||
|
- **Nach einem Verbindungsabbruch werden alle Zuhörer geweckt.** `NOTIFY` hat keinen
|
||||||
|
Zwischenspeicher; was während des Abbruchs passierte, ist verloren. Lieber ein Abruf zu viel als
|
||||||
|
ein stiller Stand, der falsch ist.
|
||||||
|
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert. Eine offene
|
||||||
|
Verbindung je Hintergrundlasche ist genau das, was die Obergrenze verhindern soll.
|
||||||
|
- **Zwei Fehler in `docs/betrieb/reverse-proxy.md` gefunden, beide aus #032:**
|
||||||
|
`client_max_body_size` stand auf **8m**, während `ANHANG_MAX_MB` 10 erlaubt — ein 10-MB-Foto wäre
|
||||||
|
an nginx mit „413“ gescheitert, und die freundliche Meldung der Anwendung wäre nie zum Zuge
|
||||||
|
gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören. Dazu beschrieb der
|
||||||
|
Kommentar am `proxy_read_timeout` noch das alte Polling.
|
||||||
|
- **Der Herzschlag ist echt über 70 Sekunden gefahren**, nicht nur im Test mit verkürztem Intervall:
|
||||||
|
`readyState` blieb `OPEN`, ein einziges Zustands-Ereignis, kein Fehler, kein Neuaufbau.
|
||||||
|
- **Gemessen statt behauptet:** der Haken war nach **485 ms** beim Zuschauer (Desktop) und nach
|
||||||
|
**487 ms** am Handy. Mit abgeschaltetem `EventSource` dauert derselbe Ablauf rund 13 Sekunden —
|
||||||
|
das ist zugleich der Nachweis, dass der Strom wirklich benutzt wird und nicht heimlich gepollt
|
||||||
|
wird.
|
||||||
|
- **Der Testname versprach `tests/e2e/live.spec.ts`**, also gibt es die Datei. Zwei Browser-Kontexte,
|
||||||
|
und für den Rückfall wird dem Zuschauer `EventSource` vor dem Laden weggenommen.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #034 M21 – Benachrichtigungen per Mail und Webhook
|
# #034 M21 – Benachrichtigungen per Mail und Webhook
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
|
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, backend, frontend, infra
|
**Labels:** phase-2, backend, frontend, infra
|
||||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
**Geschätzt:** 10 h **Tatsächlich:** 3 h 30 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -14,44 +14,93 @@ Ein zugewiesener Lauf fällt heute nur auf, wenn jemand die Übersicht öffnet.
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
|
- [x] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
|
||||||
Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb
|
Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb
|
||||||
wirklich fehlt.
|
wirklich fehlt.
|
||||||
- [ ] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
|
- [x] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
|
||||||
Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das
|
Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das
|
||||||
LDAP-Bind-Passwort.
|
LDAP-Bind-Passwort.
|
||||||
- [ ] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
|
- [x] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
|
||||||
Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich
|
Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich
|
||||||
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
|
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
|
- [x] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
|
||||||
Zustand; mit Index auf das, was der Zeitplan abholt
|
Zustand; mit Index auf das, was der Zeitplan abholt
|
||||||
- [ ] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
|
- [x] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
|
||||||
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
|
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
|
||||||
- [ ] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
|
- [x] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
|
||||||
dazu ein Zeitstempel gegen Wiedereinspielen
|
dazu ein Zeitstempel gegen Wiedereinspielen
|
||||||
- [ ] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
|
- [x] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
|
||||||
machen. Eine stille Warteschlange ist schlimmer als keine
|
machen. Eine stille Warteschlange ist schlimmer als keine
|
||||||
- [ ] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
|
- [x] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
|
||||||
Dienst
|
Dienst
|
||||||
- [ ] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
|
- [x] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
|
||||||
- [ ] Admin: die letzten Zustellungen mit Zustand und Grund
|
- [x] Admin: die letzten Zustellungen mit Zustand und Grund
|
||||||
- [ ] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
|
- [x] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
|
||||||
genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht
|
genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht
|
||||||
enthält Titel, Status und einen Link — **keine Schrittinhalte**
|
enthält Titel, Status und einen Link — **keine Schrittinhalte**
|
||||||
- [ ] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
|
- [x] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
|
||||||
- [ ] Anleitung Kapitel 07
|
- [x] Anleitung Kapitel 07
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
|
- [x] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
|
||||||
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
|
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
|
||||||
- [ ] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
|
- [x] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
|
||||||
einem falschen nicht
|
einem falschen nicht
|
||||||
- [ ] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
|
- [x] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
|
||||||
- [ ] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
|
- [x] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
|
||||||
- [ ] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
|
- [x] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
|
||||||
in #012
|
in #012
|
||||||
- [ ] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
|
- [x] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **Die Sperre wurde aus dem Abgleich herausgezogen.** `mitAbgleichSperre` lag in
|
||||||
|
`services/abgleich.ts`; die Warteschlange braucht dieselbe Mechanik, und zwei Fassungen davon
|
||||||
|
wären die schlechteste aller Lösungen. Sie heißt jetzt `mitSperre` in `services/sperre.ts`. Die
|
||||||
|
Tabelle behält den Namen `AbgleichSperre` — ein Umbenennen wäre eine Migration, die nichts
|
||||||
|
verbessert, und der Schlüssel sagt ohnehin, wer sperrt.
|
||||||
|
- **Niemand bekommt Post über die eigene Tat.** Steht nicht im Issue, ist aber der häufigste Grund,
|
||||||
|
Benachrichtigungen ganz abzuschalten. Der Handelnde fällt immer aus der Empfängerliste.
|
||||||
|
- **Die Mandantengrenze gilt auch für Empfänger.** Eine Meldung trägt Lauftitel und Gerätenamen;
|
||||||
|
ohne diese Prüfung wäre die Benachrichtigung der eine Weg nach draußen, den `sichtbareKundenIds`
|
||||||
|
nicht abdeckt. Ein Benutzer, der den Kunden des Laufs nicht sehen darf, bekommt keine Mail — mit
|
||||||
|
eigenem Test.
|
||||||
|
- **Die Einmaligkeit der Überfälligkeitsmeldung liegt in der Datenbank**, als partieller eindeutiger
|
||||||
|
Index. Der Zeitplan sieht jede Minute nach; ohne ihn bekäme der Zuständige für einen Lauf, der
|
||||||
|
über das Wochenende steht, **2880 Mails** — und jede einzelne davon wäre für sich korrekt, kein
|
||||||
|
Test rot. Gegenprobe gefahren: ohne den Index werden zwei Tests rot.
|
||||||
|
- **Fünf Versuche, nicht vier.** Die vier Wartezeiten aus dem Issue (1, 5, 30, 120 Minuten) liegen
|
||||||
|
*zwischen* den Versuchen — der fünfte Fehlschlag gibt auf. Meine erste Fassung indizierte um eins
|
||||||
|
verschoben und wartete nach dem ersten Fehlschlag fünf Minuten; der Test hat es sofort gezeigt.
|
||||||
|
- **Ein eigener Zeitplan, nicht derselbe wie der Abgleich.** Der Mailversand darf nicht warten, weil
|
||||||
|
gerade ein großes Verzeichnis gelesen wird. Zwei Takte sind billig.
|
||||||
|
- **`mailpit` gehört ins Tor vor dem Commit.** Ohne den Container überspringt die Suite **3 Tests**
|
||||||
|
(gemessen: 36 statt 33) — genau die, die den echten SMTP-Dialog prüfen. `CLAUDE.md`, das README
|
||||||
|
und `scripts/pruefe-doku.mjs` nennen ihn jetzt, und die Prüfung ist gegengeprobt: ohne ihn im
|
||||||
|
Block wird `pnpm pruefe:doku` rot.
|
||||||
|
- **`it.runIf(mailpitDa)` wird beim Einlesen der Datei ausgewertet.** Das Flag stand in `beforeAll`
|
||||||
|
und kam zu spät: der erste Lauf meldete „3 skipped“, war grün und hatte den Mailversand nie
|
||||||
|
geprüft. Jetzt wird auf Modulebene geprüft, wie bei `datenbankVerfuegbar`.
|
||||||
|
- **pnpm wollte eine Mindestalter-Sperre umgehen.** `pnpm add nodemailer` trug von sich aus
|
||||||
|
`minimumReleaseAgeExclude: nodemailer@10.0.15` ein — die Version war **vom selben Tag**. Das ist
|
||||||
|
kein Formfehler, sondern genau die Warnung, vor der die Regel schützt: ein frisches Release eines
|
||||||
|
Mailpakets ungeprüft einzubauen. Gepinnt ist jetzt `10.0.10` vom 14. September, die Ausnahme ist
|
||||||
|
weg, und die Konvention steht in `CLAUDE.md`.
|
||||||
|
- **Zwei Knöpfe „Speichern“ auf einer Seite.** Beim Durchklicken aufgefallen: die Einstellungsseite
|
||||||
|
hatte zwei gleich benannte. Der neue heißt jetzt „Benachrichtigungen speichern“ — für einen
|
||||||
|
Menschen genauso verwirrend wie für einen Test.
|
||||||
|
- **Die Testmail geht direkt hinaus, nicht über die Warteschlange.** Ein Knopf, der „eingereiht“
|
||||||
|
meldet und den Fehler eine Minute später woanders zeigt, prüft nichts.
|
||||||
|
- **Der Mail-Schalter gilt auch für das eigene Konto**, anders als „aktiv“: sich selbst zu
|
||||||
|
deaktivieren wäre ein Eigentor, seine eigene Post abzustellen der häufigste Wunsch überhaupt. Eine
|
||||||
|
**Profilseite**, auf der das jeder selbst könnte, fehlt noch — sie kommt mit der Selbstauskunft
|
||||||
|
(#035). Bis dahin stellt ein Administrator es um, und so steht es in der Anleitung.
|
||||||
|
- **Im gebauten Stand wirklich gefahren:** SMTP eingetragen, Testmail bei mailpit angekommen, ein
|
||||||
|
überfälliger Lauf angelegt — und der Zeitplan hat die Meldung **von selbst** verschickt, ohne
|
||||||
|
Zutun. Dazu geprüft: das Passwort steht nicht im Quelltext der Seite, Speichern ohne Passwortfeld
|
||||||
|
behält es, die Zustellungsliste zeigt keinen Rumpf, und 390 px ohne waagerechtes Scrollen auf allen
|
||||||
|
drei Seiten.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# #035 M22 – Mitarbeiter-Selbstauskunft
|
# #035 M22 – Mitarbeiter-Selbstauskunft
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**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:** –
|
**Geschätzt:** 8 h **Tatsächlich:** 2 h 50 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -20,42 +20,95 @@ Anmeldung zu einer `Mitarbeiter`-Zeile zu kommen.
|
|||||||
|
|
||||||
## Zu entscheiden
|
## Zu entscheiden
|
||||||
|
|
||||||
- [ ] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das
|
- [x] **Anmeldung über OIDC, Zuordnung über den UPN, neue Rolle `MITARBEITER`** — Empfehlung. Das
|
||||||
Haus hat seine Leute ohnehin im Identitätsanbieter; wer sich anmeldet und keiner
|
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.
|
||||||
- [ ] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die
|
- [x] **Was der Mitarbeiter sieht:** Titel, Status, Zeitraum und Gerät eines Laufs. **Nicht** die
|
||||||
Schritte, nicht die Notizen, nicht die Kommentare, nicht die Anhänge. In einem Lauf stehen
|
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.
|
||||||
- [ ] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt,
|
- [x] **Kein Zugang ohne Zuordnung.** Wer sich anmeldet und zu keiner `Mitarbeiter`-Zeile passt,
|
||||||
bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
|
bekommt `/kein-zugriff` mit Begründung — nicht eine leere Seite, die wie ein Fehler aussieht.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
|
- [x] `Rolle` um `MITARBEITER` erweitern; `RECHTE` bekommt `selbstauskunft: ["MITARBEITER", …]`.
|
||||||
**Jede** Rollenprüfung in `tests/rollen.test.ts` wächst um eine Spalte — die Matrix ist
|
**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
|
||||||
- [ ] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
|
- [x] `Benutzer.mitarbeiterId` mit Mandantengrenze als zusammengesetzter Fremdschlüssel
|
||||||
- [ ] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
|
- [x] Zuordnung beim Login über `upn`, unabhängig von Groß- und Kleinschreibung; mehrdeutig ist ein
|
||||||
Fehler und kein Zufallstreffer
|
Fehler und kein Zufallstreffer
|
||||||
- [ ] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
|
- [x] Eigene Seite `/meine-geraete`: meine Geräte, je Gerät die Läufe chronologisch, Titel und Status
|
||||||
- [ ] **Jede Abfrage schränkt auf die eigene Person ein** — nicht die Seite. Ein Lesemodell trägt
|
- [x] **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)
|
||||||
- [ ] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
|
- [x] Die Navigation zeigt einem `MITARBEITER` nur diese Seite; alles andere ist weg, nicht grau
|
||||||
- [ ] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
|
- [x] Anleitung: ein eigenes kurzes Kapitel, an Mitarbeiter gerichtet und nicht an Techniker
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
|
- [x] Test: ein `MITARBEITER` sieht die Läufe seiner Geräte und keinen anderen
|
||||||
- [ ] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
|
- [x] Test: er sieht **keine** Schritte, Notizen, Kommentare oder Anhänge — geprüft über die Antwort,
|
||||||
nicht über die Seite
|
nicht über die Seite
|
||||||
- [ ] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
|
- [x] Test: er kann nichts schreiben — jede Server Action weist ihn ab (`tests/zugang.test.ts`
|
||||||
erkennt eine ungeprüfte Aktion ohnehin)
|
erkennt eine ungeprüfte Aktion ohnehin)
|
||||||
- [ ] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
|
- [x] Test: ohne passende `Mitarbeiter`-Zeile führt die Anmeldung auf `/kein-zugriff` mit Begründung
|
||||||
- [ ] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
|
- [x] Test: ein mehrdeutiger UPN wird abgewiesen und protokolliert
|
||||||
- [ ] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
|
- [x] Test: die Rollenmatrix ist vollständig — jede der fünf Rollen gegen jedes Recht
|
||||||
- [ ] Am Handy kein horizontales Scrollen
|
- [x] Am Handy kein horizontales Scrollen
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **Kein zusammengesetzter Fremdschlüssel, und zwar aus einem Grund.** Die Aufgabe verlangte
|
||||||
|
`Benutzer.mitarbeiterId` „mit Mandantengrenze als zusammengesetzter Fremdschlüssel“. Das geht
|
||||||
|
nicht: ein `Benutzer` trägt keine `kundeId`, gegen die man zusammensetzen könnte — ein
|
||||||
|
Administrator sieht alle Kunden. Stattdessen hält die Datenbank das fest, was hier wirklich zählt:
|
||||||
|
ein **CHECK**, dass Rolle und Zuordnung zusammengehören (in beide Richtungen), und ein
|
||||||
|
**eindeutiger Index**, dass eine Person höchstens ein Konto hat. Die Zusage „sieht nur seine
|
||||||
|
eigenen Läufe“ hängt an der einen Kennung, und stärker als ein Fremdschlüssel darauf wird sie
|
||||||
|
nicht.
|
||||||
|
- **Nicht `/kein-zugriff`, sondern die Anmeldeseite.** Das Kriterium sah vor, dass eine Anmeldung
|
||||||
|
ohne passende Person auf `/kein-zugriff` führt. Diese Seite verlangt aber eine **Sitzung**, und
|
||||||
|
bei gescheiterter Anmeldung gibt es keine — sie würde auf die Anmeldeseite zurückleiten, also im
|
||||||
|
Kreis. Die Begründung steht deshalb dort, mit zwei eigenen Fehlercodes (`keine-person`,
|
||||||
|
`upn-mehrdeutig`). Der Geist des Kriteriums ist damit erfüllt: keine leere Seite, die wie ein
|
||||||
|
Fehler aussieht.
|
||||||
|
- **Ein bestehender Test seit #007 fand eine echte Lücke.** `staerksteRolle` benutzte `LESER` als
|
||||||
|
Startwert, und das war zugleich eine **Untergrenze**: `staerksteRolle(["MITARBEITER"])` ergab
|
||||||
|
`LESER`, also **mehr** Rechte als übergeben. Solange alle Rollen mindestens `LESER` waren, fiel
|
||||||
|
das nie auf. Der Test „gibt bei nur <Rolle> diese Rolle zurück“ war sofort rot.
|
||||||
|
- **Jeder Navigationseintrag nennt jetzt sein Recht.** Bis hierher stand `recht` nur am
|
||||||
|
Admin-Bereich — das genügte, weil jede Rolle `lesen` durfte. Mit `MITARBEITER` stimmt das nicht
|
||||||
|
mehr, und ohne diese Änderung hätte die Seitenleiste ihm sechs Links angeboten, die alle auf
|
||||||
|
`/kein-zugriff` führen.
|
||||||
|
- **Zwei Bedienfehler beim Durchklicken gefunden**, beide dieselbe Ursache: das Standardziel nach
|
||||||
|
der Anmeldung ist `/`, und die Übersicht verlangt `lesen`. Ein Mitarbeiter sah als Erstes „Dafür
|
||||||
|
fehlt dir die Berechtigung“ — und der Knopf auf jener Seite führte zurück auf `/`, also im Kreis.
|
||||||
|
Beides behebt `startseite(rolle)` in `src/server/auth/rollen.ts`, an einer Stelle und mit Test.
|
||||||
|
- **Eine Gegenprobe blieb grün, und das war der Befund.** Der Test „sieht keine Notizen“ prüft die
|
||||||
|
**Antwort**, und die ist sicher: `laufBauen` setzt sie aus den erlaubten Feldern zusammen. Als ich
|
||||||
|
im `select` versuchsweise `ergebnisse: { notiz }` ergänzte, blieb er deshalb grün — während der
|
||||||
|
Kopfkommentar der Datei behauptet, das `select` hole es „gar nicht erst“. Diese Behauptung hat
|
||||||
|
jetzt ihren eigenen Test, der die Datei selbst liest. Ein `select`, das Notizen mitliest, zieht
|
||||||
|
sie durch Protokolle und Fehlerberichte.
|
||||||
|
- **`selbstauskunft` hat auch der ADMIN nicht.** Es ist ein **Ersatz** für `lesen`, kein Zusatz: ein
|
||||||
|
zweiter Weg zu denselben Daten wäre eine zweite Stelle, an der jemand die Einschränkung auf die
|
||||||
|
eigene Person vergisst. Wer beides braucht, braucht zwei Konten — und der CHECK verlangt das.
|
||||||
|
- **Der Rang von `MITARBEITER` liegt unter `LESER`.** Der Rang entscheidet, wer gewinnt, wenn jemand
|
||||||
|
in mehreren abgebildeten Gruppen steht. Stünde er darüber, verlöre ein Techniker seine Rechte,
|
||||||
|
weil er auch Mitarbeiter des Hauses ist.
|
||||||
|
- **Ein struktureller Test, dass keine Server Action `selbstauskunft` verlangt.** Die Zusage „kann
|
||||||
|
nichts schreiben“ steht nicht in einer Prüfung, sondern in der **Abwesenheit** einer Aktion mit
|
||||||
|
diesem Recht — und die muss jemand bewachen.
|
||||||
|
- **Die Weiche greift nur mit eingerichteter Gruppenzuordnung.** Ohne abgebildete Gruppen kann die
|
||||||
|
Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist. Ein Update, das jeden
|
||||||
|
aussperrt, dem die Gruppen fehlen, wäre ein kaputtes Update — mit eigenem Test.
|
||||||
|
- **Die authentik-Doku enthielt jetzt einen falschen Satz:** „Ist der Benutzer in keiner der vier
|
||||||
|
Gruppen, bekommt er `LESER`.“ Richtiggestellt, mit einem eigenen Abschnitt zur Selbstauskunft und
|
||||||
|
dem Hinweis, wie man das alte Verhalten behält.
|
||||||
|
- **Das Anleitungskapitel 09 ist an Mitarbeiter gerichtet**, nicht an die IT — das erste Kapitel
|
||||||
|
dieser Anleitung, das sich an jemanden außerhalb der IT wendet. Es lässt sich weitergeben, ohne
|
||||||
|
den Rest mitzuschicken, und sagt ausdrücklich, **warum** man die Schritte nicht sieht.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# #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.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# #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.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# #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.
|
||||||
+12
-9
@@ -28,16 +28,19 @@ 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.2.0 | 2 h | – |
|
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.3.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) | offen | v0.2.0 | 1 h | – |
|
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | erledigt | v0.2.0 | 1 h | 2 h 10 min |
|
||||||
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | offen | v0.2.0 | 10 h | – |
|
| #028 | [M15 – Quellenarten und Microsoft-Graph-Quelle](028-graph-quelle.md) | erledigt | v0.2.0 | 10 h | 4 h 10 min |
|
||||||
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
|
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | erledigt | v0.2.0 | 12 h | 4 h 05 min |
|
||||||
| #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) | offen | v0.2.0 | 10 h | – |
|
| #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) | offen | v0.2.0 | 6 h | – |
|
| #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) | offen | v0.2.0 | 10 h | – |
|
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
|
||||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | erledigt | v0.2.0 | 8 h | 2 h 50 min |
|
||||||
| | **Summe** | | | **229 h** | **45 h 20 min** |
|
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 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 |
|
||||||
|
| | **Summe** | | | **238 h** | **73 h 55 min** |
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laufzettel",
|
"name": "laufzettel",
|
||||||
"version": "0.1.0",
|
"version": "0.2.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",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
|
"nodemailer": "10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.13.6",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ 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,
|
||||||
|
|||||||
Generated
+19
@@ -220,6 +220,9 @@ importers:
|
|||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.6.0
|
specifier: ^4.6.0
|
||||||
version: 4.6.0
|
version: 4.6.0
|
||||||
|
nodemailer:
|
||||||
|
specifier: 10.0.10
|
||||||
|
version: 10.0.10
|
||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^6.8.8
|
specifier: ^6.8.8
|
||||||
version: 6.8.8
|
version: 6.8.8
|
||||||
@@ -269,6 +272,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.13.6
|
specifier: ^24.13.6
|
||||||
version: 24.13.6
|
version: 24.13.6
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.2
|
||||||
|
version: 8.0.2
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.23.1
|
specifier: ^8.23.1
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -2321,6 +2327,9 @@ packages:
|
|||||||
'@types/node@24.13.6':
|
'@types/node@24.13.6':
|
||||||
resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
|
resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.2':
|
||||||
|
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||||
|
|
||||||
@@ -4113,6 +4122,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
|
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@10.0.10:
|
||||||
|
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
oauth4webapi@3.8.8:
|
oauth4webapi@3.8.8:
|
||||||
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||||
|
|
||||||
@@ -6880,6 +6893,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.13.6
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.13.6
|
'@types/node': 24.13.6
|
||||||
@@ -9011,6 +9028,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.57: {}
|
node-releases@2.0.57: {}
|
||||||
|
|
||||||
|
nodemailer@10.0.10: {}
|
||||||
|
|
||||||
oauth4webapi@3.8.8: {}
|
oauth4webapi@3.8.8: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||||
|
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||||
|
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||||
|
-- veroeffentlichten Versionen: 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.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Anhaenge und Fotos an Schritten (#032).
|
||||||
|
--
|
||||||
|
-- Die Datei liegt im Dateisystem unter ANHAENGE_PFAD, hier stehen die
|
||||||
|
-- Metadaten. Gegen bytea spricht die Sicherung: ein pg_dump mit Fotos ist nach
|
||||||
|
-- einem Jahr keines, das jemand einspielt.
|
||||||
|
|
||||||
|
CREATE TABLE "Anhang" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"schrittergebnisId" TEXT NOT NULL,
|
||||||
|
"laufId" TEXT NOT NULL,
|
||||||
|
"kundeId" TEXT NOT NULL,
|
||||||
|
"dateiname" TEXT NOT NULL,
|
||||||
|
"medientyp" TEXT NOT NULL,
|
||||||
|
"groesse" INTEGER NOT NULL,
|
||||||
|
"pruefsumme" TEXT NOT NULL,
|
||||||
|
"erstelltVonId" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Anhang_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Anhang_schrittergebnisId_idx" ON "Anhang"("schrittergebnisId");
|
||||||
|
CREATE INDEX "Anhang_laufId_idx" ON "Anhang"("laufId");
|
||||||
|
CREATE INDEX "Anhang_kundeId_idx" ON "Anhang"("kundeId");
|
||||||
|
|
||||||
|
-- Schluesselkandidaten fuer die Mandantengrenze unten. Lauf hatte bisher keinen –
|
||||||
|
-- die vier bestehenden zusammengesetzten Fremdschluessel zeigen auf Geraet,
|
||||||
|
-- Mitarbeiter und Abteilung, nicht auf Lauf.
|
||||||
|
CREATE UNIQUE INDEX "Schrittergebnis_id_laufId_key" ON "Schrittergebnis"("id", "laufId");
|
||||||
|
CREATE UNIQUE INDEX "Lauf_id_kundeId_key" ON "Lauf"("id", "kundeId");
|
||||||
|
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_schrittergebnisId_fkey"
|
||||||
|
FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_laufId_fkey"
|
||||||
|
FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_kundeId_fkey"
|
||||||
|
FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_erstelltVonId_fkey"
|
||||||
|
FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ---- Mandantengrenze und Zusammenhalt --------------------------------------
|
||||||
|
--
|
||||||
|
-- Dieselbe Bauart wie bei Lauf und Geraet: ein Anhang kann strukturell nicht zu
|
||||||
|
-- einem Schrittergebnis eines anderen Laufs und nicht zu einem anderen Kunden
|
||||||
|
-- als sein Lauf gehoeren.
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_ergebnis_gleicher_lauf"
|
||||||
|
FOREIGN KEY ("schrittergebnisId", "laufId") REFERENCES "Schrittergebnis" ("id", "laufId")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_lauf_gleicher_kunde"
|
||||||
|
FOREIGN KEY ("laufId", "kundeId") REFERENCES "Lauf" ("id", "kundeId")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Eine Groesse von 0 waere eine leere Datei, und eine negative gibt es nicht.
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_groesse_positiv" CHECK ("groesse" > 0);
|
||||||
|
|
||||||
|
-- SHA-256 als Hex: 64 Zeichen, nur Ziffern und a-f. Eine Pruefsumme, die keine
|
||||||
|
-- ist, wuerde nach einer Ruecksicherung nichts beantworten.
|
||||||
|
ALTER TABLE "Anhang"
|
||||||
|
ADD CONSTRAINT "Anhang_pruefsumme_form" CHECK ("pruefsumme" ~ '^[0-9a-f]{64}$');
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 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.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Live-Aktualisierung per SSE statt Polling (#033).
|
||||||
|
--
|
||||||
|
-- Die Datenbank sagt selbst, wenn sich an einem Lauf etwas geaendert hat:
|
||||||
|
-- `NOTIFY lauf_geaendert, '<laufId>'`. Der Route Handler haelt eine
|
||||||
|
-- LISTEN-Verbindung und schiebt weiter.
|
||||||
|
--
|
||||||
|
-- **Warum in der Datenbank und nicht im Dienst.** Ein Ereignis, das der Service
|
||||||
|
-- verschickt, entsteht nur auf dem Weg durch den Service. Die Regeln dieses
|
||||||
|
-- Projekts stehen aber absichtlich auch in der Datenbank, weil jemand an der
|
||||||
|
-- Service-Schicht vorbei schreiben kann — der Seed, eine Migration, eine
|
||||||
|
-- Reparatur per psql. Ein Trigger sieht all das.
|
||||||
|
--
|
||||||
|
-- **Warum es mit mehreren Instanzen funktioniert.** NOTIFY geht an alle
|
||||||
|
-- Verbindungen, die LISTEN auf dem Kanal haben, also an jede Instanz der
|
||||||
|
-- Anwendung. Ein zweiter Dienst (Redis, ein Nachrichtenbus) waere dafuer nicht
|
||||||
|
-- noetig.
|
||||||
|
--
|
||||||
|
-- **Zugestellt wird erst beim COMMIT.** Das ist hier genau richtig: ein
|
||||||
|
-- Abonnent, der eine noch nicht festgeschriebene Aenderung meldet, wuerde die
|
||||||
|
-- Seite mit Daten neu bauen lassen, die es gleich wieder nicht gibt.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
betroffener TEXT;
|
||||||
|
BEGIN
|
||||||
|
/*
|
||||||
|
* Bei DELETE gibt es kein NEW. Der Kanalname ist fest, die Nutzlast nicht –
|
||||||
|
* deshalb pg_notify() und nicht NOTIFY, das keine Variablen nimmt.
|
||||||
|
*/
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
betroffener := OLD."laufId";
|
||||||
|
ELSE
|
||||||
|
betroffener := NEW."laufId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF betroffener IS NOT NULL THEN
|
||||||
|
PERFORM pg_notify('lauf_geaendert', betroffener);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Derselbe Rumpf fuer die Laufzeile selbst: dort heisst der Schluessel "id".
|
||||||
|
CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert_selbst() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('lauf_geaendert', NEW."id");
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Welche Tabellen melden, haengt daran, was `laufZustand` liefert** — nicht
|
||||||
|
* daran, was sich "irgendwie" aendert. Das Lesemodell traegt Status,
|
||||||
|
* Schrittzustaende, die Zahl der Kommentare und (seit #033) die Zahl der
|
||||||
|
* Anhaenge. Wuerde eine davon nicht melden, waere der Strom gegenueber dem
|
||||||
|
* Polling, das alle zehn Sekunden vollstaendig vergleicht, ein **Rueckschritt**:
|
||||||
|
* ein neuer Kommentar kaeme nie an.
|
||||||
|
*/
|
||||||
|
CREATE TRIGGER "Lauf_geaendert_melden"
|
||||||
|
AFTER UPDATE ON "Lauf"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert_selbst();
|
||||||
|
|
||||||
|
CREATE TRIGGER "Schrittergebnis_geaendert_melden"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "Schrittergebnis"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
|
||||||
|
|
||||||
|
CREATE TRIGGER "Kommentar_geaendert_melden"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "Kommentar"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
|
||||||
|
|
||||||
|
CREATE TRIGGER "Anhang_geaendert_melden"
|
||||||
|
AFTER INSERT OR DELETE ON "Anhang"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 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.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Benachrichtigungen per Mail und Webhook (#034).
|
||||||
|
|
||||||
|
CREATE TYPE "Zustellweg" AS ENUM ('MAIL', 'WEBHOOK');
|
||||||
|
CREATE TYPE "Zustellzustand" AS ENUM ('OFFEN', 'GESENDET', 'FEHLGESCHLAGEN', 'AUFGEGEBEN');
|
||||||
|
CREATE TYPE "Meldeanlass" AS ENUM (
|
||||||
|
'LAUF_ZUGEWIESEN',
|
||||||
|
'LAUF_UEBERFAELLIG',
|
||||||
|
'LAUF_ABGESCHLOSSEN',
|
||||||
|
'ABGLEICH_FEHLGESCHLAGEN'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Je Empfaenger abschaltbar.
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "meldungenAktiv" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- SMTP und Webhook gehoeren zur einen Einstellungszeile.
|
||||||
|
ALTER TABLE "Einstellung"
|
||||||
|
ADD COLUMN "smtpHost" TEXT,
|
||||||
|
ADD COLUMN "smtpPort" INTEGER,
|
||||||
|
ADD COLUMN "smtpTls" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "smtpBenutzer" TEXT,
|
||||||
|
ADD COLUMN "smtpPasswortVerschluesselt" TEXT,
|
||||||
|
ADD COLUMN "smtpAbsender" TEXT,
|
||||||
|
ADD COLUMN "webhookUrl" TEXT,
|
||||||
|
ADD COLUMN "webhookGeheimnisVerschluesselt" TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE "Zustellung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"anlass" "Meldeanlass" NOT NULL,
|
||||||
|
"weg" "Zustellweg" NOT NULL,
|
||||||
|
"empfaenger" TEXT NOT NULL,
|
||||||
|
"betreff" TEXT NOT NULL,
|
||||||
|
"rumpf" TEXT NOT NULL,
|
||||||
|
"zustand" "Zustellzustand" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"versuche" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"naechsterVersuchAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"letzterFehler" TEXT,
|
||||||
|
"gesendetAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"benutzerId" TEXT,
|
||||||
|
"laufId" TEXT,
|
||||||
|
"verzeichnisquelleId" TEXT,
|
||||||
|
CONSTRAINT "Zustellung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Der Index, auf dem der Zeitplan arbeitet: er fragt jede Minute nach offenen
|
||||||
|
-- und faelligen Zeilen. Ohne ihn waere das ein vollstaendiger Durchlauf der
|
||||||
|
-- Tabelle, die mit der Zeit nur waechst.
|
||||||
|
CREATE INDEX "Zustellung_zustand_naechsterVersuchAm_idx"
|
||||||
|
ON "Zustellung"("zustand", "naechsterVersuchAm");
|
||||||
|
CREATE INDEX "Zustellung_erstelltAm_idx" ON "Zustellung"("erstelltAm");
|
||||||
|
CREATE INDEX "Zustellung_laufId_idx" ON "Zustellung"("laufId");
|
||||||
|
CREATE INDEX "Zustellung_benutzerId_idx" ON "Zustellung"("benutzerId");
|
||||||
|
|
||||||
|
ALTER TABLE "Zustellung"
|
||||||
|
ADD CONSTRAINT "Zustellung_benutzerId_fkey" FOREIGN KEY ("benutzerId")
|
||||||
|
REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT "Zustellung_laufId_fkey" FOREIGN KEY ("laufId")
|
||||||
|
REFERENCES "Lauf"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT "Zustellung_verzeichnisquelleId_fkey" FOREIGN KEY ("verzeichnisquelleId")
|
||||||
|
REFERENCES "Verzeichnisquelle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Regeln, die auch dann gelten, wenn jemand an der Service-Schicht vorbei
|
||||||
|
-- schreibt.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Ein Empfaenger ohne Inhalt ist eine Zeile, die nie ankommen kann.
|
||||||
|
ALTER TABLE "Zustellung"
|
||||||
|
ADD CONSTRAINT "Zustellung_empfaenger_nicht_leer"
|
||||||
|
CHECK (length(btrim("empfaenger")) > 0);
|
||||||
|
|
||||||
|
ALTER TABLE "Zustellung"
|
||||||
|
ADD CONSTRAINT "Zustellung_versuche_nicht_negativ" CHECK ("versuche" >= 0);
|
||||||
|
|
||||||
|
-- „Gesendet“ und „wann“ gehoeren zusammen, in beide Richtungen. Dieselbe Form
|
||||||
|
-- wie bei `Lauf_abgeschlossen_mit_zeitpunkt`: ein Zustand ohne Zeitpunkt macht
|
||||||
|
-- jede spaetere Auswertung zur Schaetzung.
|
||||||
|
ALTER TABLE "Zustellung"
|
||||||
|
ADD CONSTRAINT "Zustellung_gesendet_mit_zeitpunkt"
|
||||||
|
CHECK (("zustand" = 'GESENDET') = ("gesendetAm" IS NOT NULL));
|
||||||
|
|
||||||
|
-- Eine Mail geht an eine Person, ein Webhook nicht. Ohne das koennte eine
|
||||||
|
-- Webhook-Zeile an einem Benutzer haengen und in dessen Mailverlauf auftauchen.
|
||||||
|
ALTER TABLE "Zustellung"
|
||||||
|
ADD CONSTRAINT "Zustellung_webhook_ohne_benutzer"
|
||||||
|
CHECK ("weg" <> 'WEBHOOK' OR "benutzerId" IS NULL);
|
||||||
|
|
||||||
|
-- Jeder Anlass haengt an dem, worum es geht: die drei Lauf-Anlaesse an einem
|
||||||
|
-- Lauf, der Abgleich an einer Quelle. Eine Meldung ohne Bezug waere ein Satz
|
||||||
|
-- ohne Gegenstand — und der Link in der Mail zeigte ins Leere.
|
||||||
|
ALTER TABLE "Zustellung"
|
||||||
|
ADD CONSTRAINT "Zustellung_bezug_passt_zum_anlass"
|
||||||
|
CHECK (
|
||||||
|
("anlass" = 'ABGLEICH_FEHLGESCHLAGEN' AND "verzeichnisquelleId" IS NOT NULL AND "laufId" IS NULL)
|
||||||
|
OR ("anlass" <> 'ABGLEICH_FEHLGESCHLAGEN' AND "laufId" IS NOT NULL AND "verzeichnisquelleId" IS NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Verschluesselt, nicht im Klartext – dieselbe Pruefung wie beim
|
||||||
|
-- LDAP-Bind-Passwort. `v<n>.` ist das Praefix aus `src/lib/geheimnisse.ts`.
|
||||||
|
ALTER TABLE "Einstellung"
|
||||||
|
ADD CONSTRAINT "Einstellung_smtp_passwort_verschluesselt"
|
||||||
|
CHECK ("smtpPasswortVerschluesselt" ~ '^v[0-9]+\.');
|
||||||
|
|
||||||
|
ALTER TABLE "Einstellung"
|
||||||
|
ADD CONSTRAINT "Einstellung_webhook_geheimnis_verschluesselt"
|
||||||
|
CHECK ("webhookGeheimnisVerschluesselt" ~ '^v[0-9]+\.');
|
||||||
|
|
||||||
|
ALTER TABLE "Einstellung"
|
||||||
|
ADD CONSTRAINT "Einstellung_smtp_port_bereich"
|
||||||
|
CHECK ("smtpPort" IS NULL OR ("smtpPort" BETWEEN 1 AND 65535));
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Und die Regel, die der Zeitplan braucht.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Ueberfaellig wird je Lauf und Empfaenger genau einmal gemeldet.**
|
||||||
|
*
|
||||||
|
* Der Zeitplan sieht jede Minute nach. Ohne diesen Index bekaeme der
|
||||||
|
* Zustaendige fuer einen Lauf, der ueber das Wochenende ueberfaellig steht,
|
||||||
|
* 2880 Mails — und zwar ohne dass irgendein Test rot waere, denn jede einzelne
|
||||||
|
* davon ist korrekt. Der Dienst prueft dasselbe noch einmal, nur mit besserer
|
||||||
|
* Meldung.
|
||||||
|
*
|
||||||
|
* Partiell, weil es nur fuer diesen einen Anlass gilt: „abgeschlossen“ darf nach
|
||||||
|
* einem Zurueckholen und erneuten Abschliessen ein zweites Mal hinausgehen, und
|
||||||
|
* „zugewiesen“ bei jeder neuen Zuweisung.
|
||||||
|
*/
|
||||||
|
CREATE UNIQUE INDEX "Zustellung_ueberfaellig_einmal"
|
||||||
|
ON "Zustellung"("laufId", "empfaenger")
|
||||||
|
WHERE "anlass" = 'LAUF_UEBERFAELLIG';
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||||
|
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||||
|
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
|
||||||
|
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
|
||||||
|
-- Loeschbefehle sind bewusst gestrichen.
|
||||||
|
--
|
||||||
|
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||||
|
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Mitarbeiter-Selbstauskunft (#035).
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die fuenfte Rolle.
|
||||||
|
*
|
||||||
|
* `ALTER TYPE ... ADD VALUE` laeuft in PostgreSQL seit 12 auch innerhalb einer
|
||||||
|
* Transaktion — Prisma fuehrt jede Migration in einer. Der neue Wert kommt
|
||||||
|
* **ans Ende**: die Reihenfolge eines Enums ist seine Sortierreihenfolge, und
|
||||||
|
* ein `MITARBEITER` zwischen `ADMIN` und `REDAKTEUR` wuerde jede Liste, die nach
|
||||||
|
* Rolle sortiert, still umstellen.
|
||||||
|
*/
|
||||||
|
ALTER TYPE "Rolle" ADD VALUE 'MITARBEITER';
|
||||||
|
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "mitarbeiterId" TEXT;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Eindeutig**: eine Person hat hoechstens ein Konto, mit dem sie sich selbst
|
||||||
|
* ansieht. Ohne das koennten zwei Anmeldungen auf dieselbe Person zeigen, und
|
||||||
|
* ein entzogener Zugang waere nicht entzogen, solange das zweite Konto bleibt.
|
||||||
|
*
|
||||||
|
* `NULL` gilt in Postgres als verschieden, die vielen Konten ohne Zuordnung
|
||||||
|
* stoeren hier also nicht.
|
||||||
|
*/
|
||||||
|
CREATE UNIQUE INDEX "Benutzer_mitarbeiterId_key" ON "Benutzer"("mitarbeiterId");
|
||||||
|
|
||||||
|
ALTER TABLE "Benutzer"
|
||||||
|
ADD CONSTRAINT "Benutzer_mitarbeiterId_fkey" FOREIGN KEY ("mitarbeiterId")
|
||||||
|
REFERENCES "Mitarbeiter"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Die eigentliche Zusage dieses Meilensteins, in der Datenbank.**
|
||||||
|
*
|
||||||
|
* Die Rolle `MITARBEITER` und die Zuordnung gehoeren zusammen, in beide
|
||||||
|
* Richtungen:
|
||||||
|
*
|
||||||
|
* - Ein `MITARBEITER` **ohne** `mitarbeiterId` haette ein Recht und niemanden,
|
||||||
|
* auf den es sich bezieht. Die Lesemodelle schraenken auf diese Kennung ein;
|
||||||
|
* fehlte sie, waere die Einschraenkung leer — und „leer“ heisst in SQL je nach
|
||||||
|
* Formulierung „nichts“ oder „alles“. Das ist genau die Art Zweideutigkeit,
|
||||||
|
* die hier nicht entstehen darf.
|
||||||
|
* - Eine `mitarbeiterId` an einem `ADMIN` waere die Umkehrung: ein Konto, das
|
||||||
|
* alles sieht und zusaetzlich als eine bestimmte Person gilt. Wer das braucht,
|
||||||
|
* braucht zwei Konten.
|
||||||
|
*
|
||||||
|
* `SET NULL` am Fremdschluessel oben wuerde diese Bedingung verletzen, wenn ein
|
||||||
|
* Mitarbeiter geloescht wird — deshalb der Trigger darunter, der die Rolle
|
||||||
|
* mitzieht. Geloescht wird ein Mitarbeiter ohnehin nie (CLAUDE.md: nur
|
||||||
|
* `aktiv = false`), aber eine Regel, die sich auf „kommt nicht vor“ verlaesst,
|
||||||
|
* ist keine Regel.
|
||||||
|
*/
|
||||||
|
ALTER TABLE "Benutzer"
|
||||||
|
ADD CONSTRAINT "Benutzer_mitarbeiter_nur_bei_rolle"
|
||||||
|
CHECK (("rolle" = 'MITARBEITER') = ("mitarbeiterId" IS NOT NULL));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verliert ein Konto seine Person, verliert es auch die Rolle.
|
||||||
|
*
|
||||||
|
* Ohne das schlaegt das `ON DELETE SET NULL` des Fremdschluessels am CHECK
|
||||||
|
* darueber fehl, und das Loeschen eines Mitarbeiters waere unmoeglich statt
|
||||||
|
* folgerichtig. `LESER` und nicht `aktiv = false`: das Konto soll sich weiter
|
||||||
|
* anmelden koennen, nur nichts Fremdes sehen — und wer sich anmeldet, bekommt
|
||||||
|
* bei der naechsten OIDC-Anmeldung ohnehin eine neue Rolle zugewiesen.
|
||||||
|
*/
|
||||||
|
CREATE OR REPLACE FUNCTION laufzettel_konto_ohne_person() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE "Benutzer"
|
||||||
|
SET "rolle" = 'LESER', "mitarbeiterId" = NULL
|
||||||
|
WHERE "mitarbeiterId" = OLD."id";
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER "Mitarbeiter_konto_mitziehen"
|
||||||
|
BEFORE DELETE ON "Mitarbeiter"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION laufzettel_konto_ohne_person();
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 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 $$;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 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 $$;
|
||||||
+219
-19
@@ -31,6 +31,12 @@ 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 {
|
||||||
@@ -110,8 +116,97 @@ model Einstellung {
|
|||||||
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
|
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
|
||||||
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
|
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
|
||||||
schnellstartGepflegt Boolean @default(false)
|
schnellstartGepflegt Boolean @default(false)
|
||||||
erstelltAm DateTime @default(now())
|
|
||||||
geaendertAm DateTime @default(now()) @updatedAt
|
/// SMTP fuer die Benachrichtigungen (#034). Leerer Host heisst: keine Mail.
|
||||||
|
/// Kein Dienstanbieter, sondern SMTP — eine selbst gehostete Anwendung soll an
|
||||||
|
/// den Mailserver des Hauses koennen.
|
||||||
|
smtpHost String?
|
||||||
|
smtpPort Int?
|
||||||
|
/// `true` = StartTLS oder implizites TLS (Port 465). Klartext nur, wenn jemand
|
||||||
|
/// das ausdruecklich abschaltet.
|
||||||
|
smtpTls Boolean @default(true)
|
||||||
|
smtpBenutzer String?
|
||||||
|
/// AES-256-GCM aus APP_SECRET, wie `Verzeichnisquelle.bindPasswortVerschluesselt`.
|
||||||
|
/// Erreicht **nie** eine Antwort.
|
||||||
|
smtpPasswortVerschluesselt String?
|
||||||
|
smtpAbsender String?
|
||||||
|
|
||||||
|
/// Ein Webhook-Ziel fuer dieselben vier Anlaesse. Leere URL heisst: keiner.
|
||||||
|
webhookUrl String?
|
||||||
|
/// Geheimnis fuer die HMAC-SHA256-Signatur, verschluesselt wie oben.
|
||||||
|
webhookGeheimnisVerschluesselt String?
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
geaendertAm DateTime @default(now()) @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie eine Meldung hinausgeht (#034).
|
||||||
|
enum Zustellweg {
|
||||||
|
MAIL
|
||||||
|
WEBHOOK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wo eine Zustellung steht (#034).
|
||||||
|
enum Zustellzustand {
|
||||||
|
OFFEN
|
||||||
|
GESENDET
|
||||||
|
/// Fehlgeschlagen, wird aber noch einmal versucht.
|
||||||
|
FEHLGESCHLAGEN
|
||||||
|
/// Endgueltig aufgegeben. Bleibt sichtbar — eine stille Warteschlange ist
|
||||||
|
/// schlimmer als keine.
|
||||||
|
AUFGEGEBEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was gemeldet wird. Bewusst genau vier Anlaesse (#034).
|
||||||
|
enum Meldeanlass {
|
||||||
|
LAUF_ZUGEWIESEN
|
||||||
|
LAUF_UEBERFAELLIG
|
||||||
|
LAUF_ABGESCHLOSSEN
|
||||||
|
ABGLEICH_FEHLGESCHLAGEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine einzelne Meldung in der Warteschlange (#034).
|
||||||
|
///
|
||||||
|
/// **In der Datenbank und nicht im Anfrage-Pfad.** Ein haengender Mailserver
|
||||||
|
/// darf kein Abhaken blockieren: der Dienst reiht ein, der Zeitplan schickt.
|
||||||
|
///
|
||||||
|
/// Betreff und Rumpf stehen fertig in der Zeile. Das kostet Platz und ist es
|
||||||
|
/// wert: eine Meldung sagt, was **damals** war. Baute der Zeitplan den Text erst
|
||||||
|
/// beim Senden, stuende in der Mail der Stand von jetzt — und in einer
|
||||||
|
/// aufgegebenen Zustellung von naechster Woche etwas voellig anderes.
|
||||||
|
model Zustellung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
anlass Meldeanlass
|
||||||
|
weg Zustellweg
|
||||||
|
/// Mailadresse oder Webhook-URL. Als Text, damit die Zeile auch dann noch
|
||||||
|
/// sagt, wohin sie ging, wenn das Konto spaeter deaktiviert wurde.
|
||||||
|
empfaenger String
|
||||||
|
betreff String
|
||||||
|
rumpf String
|
||||||
|
zustand Zustellzustand @default(OFFEN)
|
||||||
|
versuche Int @default(0)
|
||||||
|
/// Vor diesem Zeitpunkt holt der Zeitplan die Zeile nicht ab. Traegt das
|
||||||
|
/// wachsende Warten zwischen zwei Versuchen.
|
||||||
|
naechsterVersuchAm DateTime @default(now())
|
||||||
|
letzterFehler String?
|
||||||
|
gesendetAm DateTime?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
geaendertAm DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// Wer gemeint ist. Leer bei einem Webhook — der gehoert keiner Person.
|
||||||
|
benutzerId String?
|
||||||
|
laufId String?
|
||||||
|
verzeichnisquelleId String?
|
||||||
|
|
||||||
|
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||||
|
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Cascade)
|
||||||
|
verzeichnisquelle Verzeichnisquelle? @relation(fields: [verzeichnisquelleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Der Index, auf dem der Zeitplan arbeitet.
|
||||||
|
@@index([zustand, naechsterVersuchAm])
|
||||||
|
@@index([erstelltAm])
|
||||||
|
@@index([laufId])
|
||||||
|
@@index([benutzerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Kunde {
|
model Kunde {
|
||||||
@@ -134,6 +229,7 @@ model Kunde {
|
|||||||
benutzerbindungen BenutzerKunde[]
|
benutzerbindungen BenutzerKunde[]
|
||||||
geraete Geraet[]
|
geraete Geraet[]
|
||||||
laeufe Lauf[]
|
laeufe Lauf[]
|
||||||
|
anhaenge Anhang[]
|
||||||
mitarbeiter Mitarbeiter[]
|
mitarbeiter Mitarbeiter[]
|
||||||
protokolle Protokoll[]
|
protokolle Protokoll[]
|
||||||
verzeichnisquellen Verzeichnisquelle[]
|
verzeichnisquellen Verzeichnisquelle[]
|
||||||
@@ -160,6 +256,19 @@ model Benutzer {
|
|||||||
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
|
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
|
||||||
sitzungenUngueltigAb DateTime?
|
sitzungenUngueltigAb DateTime?
|
||||||
letzteAnmeldungAm DateTime?
|
letzteAnmeldungAm DateTime?
|
||||||
|
/// Bekommt diese Person Benachrichtigungen (#034)? Je Empfaenger abschaltbar.
|
||||||
|
/// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein
|
||||||
|
/// Dienstkonto will keine Post.
|
||||||
|
meldungenAktiv Boolean @default(true)
|
||||||
|
/// Die Person im Verzeichnis, als die dieses Konto sich selbst sehen darf
|
||||||
|
/// (#035). Gesetzt **genau dann**, wenn die Rolle `MITARBEITER` ist.
|
||||||
|
///
|
||||||
|
/// Hier steht **kein** zusammengesetzter Fremdschluessel, anders als sonst bei
|
||||||
|
/// jedem kundenbezogenen Verweis: ein `Benutzer` traegt keine `kundeId`, gegen
|
||||||
|
/// die man zusammensetzen koennte — ein Administrator sieht alle Kunden. Die
|
||||||
|
/// Zusage „sieht nur seine eigenen Laeufe“ haengt an dieser einen Kennung, und
|
||||||
|
/// staerker als ein Fremdschluessel darauf wird sie nicht.
|
||||||
|
mitarbeiterId String? @unique
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @default(now()) @updatedAt
|
geaendertAm DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@@ -172,6 +281,9 @@ model Benutzer {
|
|||||||
protokolle Protokoll[]
|
protokolle Protokoll[]
|
||||||
schrittergebnisse Schrittergebnis[]
|
schrittergebnisse Schrittergebnis[]
|
||||||
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
|
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
|
||||||
|
anhaenge Anhang[]
|
||||||
|
zustellungen Zustellung[]
|
||||||
|
mitarbeiter Mitarbeiter? @relation(fields: [mitarbeiterId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@index([rolle])
|
@@index([rolle])
|
||||||
@@ -202,33 +314,72 @@ 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)
|
||||||
url String
|
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
|
||||||
bindDn String
|
art QuellenArt @default(LDAP)
|
||||||
|
/// 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.
|
||||||
caZertifikatPem String?
|
caZertifikatPem String?
|
||||||
abgleichIntervallMinuten Int @default(60)
|
abgleichIntervallMinuten Int @default(60)
|
||||||
letzterAbgleichAm DateTime?
|
letzterAbgleichAm DateTime?
|
||||||
letzterAbgleichStatus String?
|
letzterAbgleichStatus String?
|
||||||
/// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie
|
/// Nimmt die Meldung der LDAP-Bibliothek auf. #016 muss sie filtern, bevor sie
|
||||||
/// hier landet – rohe Bind-Fehler koennen das Passwort enthalten.
|
/// hier landet – rohe Bind-Fehler koennen das Passwort enthalten.
|
||||||
letzterAbgleichFehler String?
|
letzterAbgleichFehler String?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @default(now()) @updatedAt
|
geaendertAm DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||||
mitarbeiter Mitarbeiter[]
|
mitarbeiter Mitarbeiter[]
|
||||||
|
zustellungen Zustellung[]
|
||||||
|
|
||||||
@@index([kundeId])
|
@@index([kundeId])
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@ -279,6 +430,9 @@ 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.
|
||||||
@@ -635,7 +789,12 @@ model Lauf {
|
|||||||
ergebnisse Schrittergebnis[]
|
ergebnisse Schrittergebnis[]
|
||||||
kommentare Kommentar[]
|
kommentare Kommentar[]
|
||||||
protokolle Protokoll[]
|
protokolle Protokoll[]
|
||||||
|
anhaenge Anhang[]
|
||||||
|
|
||||||
|
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
|
||||||
|
zustellungen Zustellung[]
|
||||||
|
|
||||||
|
@@unique([id, kundeId])
|
||||||
@@index([kundeId, status])
|
@@index([kundeId, status])
|
||||||
@@index([geraetId, status])
|
@@index([geraetId, status])
|
||||||
@@index([mitarbeiterId])
|
@@index([mitarbeiterId])
|
||||||
@@ -665,11 +824,52 @@ model Schrittergebnis {
|
|||||||
/// Codepfad darf annehmen, dass immer ein Mensch dahintersteht.
|
/// Codepfad darf annehmen, dass immer ein Mensch dahintersteht.
|
||||||
aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict)
|
aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict)
|
||||||
kommentare Kommentar[]
|
kommentare Kommentar[]
|
||||||
|
anhaenge Anhang[]
|
||||||
|
|
||||||
@@unique([laufId, vorlagenSchrittId])
|
@@unique([laufId, vorlagenSchrittId])
|
||||||
|
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
|
||||||
|
@@unique([id, laufId])
|
||||||
@@index([laufId, zustand])
|
@@index([laufId, zustand])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine Datei oder ein Foto an einem Schritt (#032).
|
||||||
|
///
|
||||||
|
/// Die Datei selbst liegt im Dateisystem unter `ANHAENGE_PFAD`; hier stehen nur
|
||||||
|
/// die Metadaten. Gegen `bytea` spricht die Sicherung: ein `pg_dump` mit Fotos
|
||||||
|
/// ist nach einem Jahr kein `pg_dump` mehr, das jemand einspielt. **Folge:** die
|
||||||
|
/// Sicherung muss Datenbank **und** Verzeichnis mitnehmen, siehe
|
||||||
|
/// `docs/betrieb/sicherung.md`.
|
||||||
|
model Anhang {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schrittergebnisId String
|
||||||
|
laufId String
|
||||||
|
/// Jede kundenbezogene Zeile traegt kundeId (CLAUDE.md). Die Mandantengrenze
|
||||||
|
/// steht zusaetzlich als zusammengesetzter Fremdschluessel in der Migration.
|
||||||
|
kundeId String
|
||||||
|
/// Der Name, den der Browser mitgeschickt hat – nur zur Anzeige. Der Pfad auf
|
||||||
|
/// der Platte entsteht aus `id`, damit kein Dateiname von aussen einen Pfad
|
||||||
|
/// bestimmt.
|
||||||
|
dateiname String
|
||||||
|
medientyp String
|
||||||
|
groesse Int
|
||||||
|
/// SHA-256 der Datei. Beantwortet nach einer Ruecksicherung die Frage, ob die
|
||||||
|
/// Datei noch die ist, die hier beschrieben steht.
|
||||||
|
pruefsumme String
|
||||||
|
erstelltVonId String?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
|
schrittergebnis Schrittergebnis @relation(fields: [schrittergebnisId], references: [id], onDelete: Cascade)
|
||||||
|
lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
|
||||||
|
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||||
|
/// Leer bei einem Systemlauf – kein Codepfad nimmt an, dass ein Mensch
|
||||||
|
/// dahintersteht.
|
||||||
|
erstelltVon Benutzer? @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([schrittergebnisId])
|
||||||
|
@@index([laufId])
|
||||||
|
@@index([kundeId])
|
||||||
|
}
|
||||||
|
|
||||||
model Kommentar {
|
model Kommentar {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
laufId String
|
laufId String
|
||||||
|
|||||||
+16
-4
@@ -175,11 +175,23 @@ function feld(text, name) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const [datei, block] of bloecke) {
|
for (const [datei, block] of bloecke) {
|
||||||
if (!/docker compose -f docker-compose\.test\.yml up .*ldap/.test(block)) {
|
/*
|
||||||
melde(
|
* Beide Container namentlich. Ohne sie ueberspringt die Suite Tests und
|
||||||
`${datei}: der Schranken-Block nennt den LDAP-Container nicht. ` +
|
* sieht gruen aus — genau die Art Luecke, die dieses Skript finden soll.
|
||||||
`Ohne ihn ueberspringt die Suite den Verzeichnis-Abgleich, und der Lauf sieht gruen aus.`,
|
*/
|
||||||
|
for (const [dienst, was] of [
|
||||||
|
["ldap", "den Verzeichnis-Abgleich"],
|
||||||
|
["mailpit", "den echten Mailversand"],
|
||||||
|
]) {
|
||||||
|
const gefunden = new RegExp(`docker compose -f docker-compose\\.test\\.yml up [^\n]*${dienst}`).test(
|
||||||
|
block,
|
||||||
);
|
);
|
||||||
|
if (!gefunden) {
|
||||||
|
melde(
|
||||||
|
`${datei}: der Schranken-Block nennt den Container "${dienst}" nicht. ` +
|
||||||
|
`Ohne ihn ueberspringt die Suite ${was}, und der Lauf sieht gruen aus.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Rolle } from "@/generated/prisma/enums";
|
import { Rolle } from "@/generated/prisma/enums";
|
||||||
|
import { Bell, BellOff } from "lucide-react";
|
||||||
import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
|
import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
|
||||||
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
|
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
|
||||||
|
import { meldungenSchalterAktion } from "@/server/actions/benachrichtigungen";
|
||||||
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
|
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
|
||||||
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";
|
||||||
@@ -59,6 +61,36 @@ function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolea
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Benachrichtigungen auch **fuer sich selbst** umschaltbar, anders als
|
||||||
|
* „aktiv“: sich selbst zu deaktivieren waere ein Eigentor, seine
|
||||||
|
* eigene Post abzustellen ist der haeufigste Wunsch ueberhaupt. Eine
|
||||||
|
* Profilseite, auf der das jeder selbst koennte, kommt mit der
|
||||||
|
* Selbstauskunft (#035).
|
||||||
|
*/}
|
||||||
|
<form action={meldungenSchalterAktion}>
|
||||||
|
<input type="hidden" name="benutzerId" value={zeile.id} />
|
||||||
|
<input type="hidden" name="aktiv" value={zeile.meldungenAktiv ? "aus" : "an"} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={`Benachrichtigungen für ${zeile.name} ${
|
||||||
|
zeile.meldungenAktiv ? "abschalten" : "einschalten"
|
||||||
|
}`}
|
||||||
|
className={`flex min-h-11 items-center gap-1.5 rounded-pille px-3 text-sm md:min-h-9 ${
|
||||||
|
zeile.meldungenAktiv
|
||||||
|
? "bg-flaeche-hoch text-nebentext hover:text-text"
|
||||||
|
: "bg-flaeche-hoch text-nebentext line-through hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{zeile.meldungenAktiv ? (
|
||||||
|
<Bell className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
Mail
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{selbst ? (
|
{selbst ? (
|
||||||
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
|
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const REITER = [
|
|||||||
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
||||||
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
|
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
|
||||||
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
|
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
|
||||||
|
{ pfad: "/admin/zustellungen", beschriftung: "Zustellungen" },
|
||||||
/*
|
/*
|
||||||
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
|
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
|
||||||
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
|
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { EinstellungsFormular } from "@/components/einstellungen-formular";
|
|||||||
import { ModusWechsel } from "@/components/modus-wechsel";
|
import { ModusWechsel } from "@/components/modus-wechsel";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
|
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
|
||||||
|
import { meldungseinstellungenHolen } from "@/server/services/benachrichtigungen";
|
||||||
|
import { BenachrichtigungenFormular } from "@/components/benachrichtigungen-formular";
|
||||||
|
|
||||||
const MODUS_NAME: Record<Betriebsmodus, string> = {
|
const MODUS_NAME: Record<Betriebsmodus, string> = {
|
||||||
INTERN: "Intern",
|
INTERN: "Intern",
|
||||||
@@ -17,8 +19,8 @@ const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function EinstellungenSeite() {
|
export default async function EinstellungenSeite() {
|
||||||
await sitzungErforderlich("verwaltung", "/admin");
|
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin");
|
||||||
const einstellungen = await einstellungenHolen();
|
const [einstellungen, meldungen] = await Promise.all([einstellungenHolen(), meldungseinstellungenHolen()]);
|
||||||
|
|
||||||
const gegenteil =
|
const gegenteil =
|
||||||
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
|
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
|
||||||
@@ -68,6 +70,23 @@ export default async function EinstellungenSeite() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<BenachrichtigungenFormular
|
||||||
|
werte={{
|
||||||
|
smtpHost: meldungen.smtp.host ?? "",
|
||||||
|
smtpPort: meldungen.smtp.port === null ? "" : String(meldungen.smtp.port),
|
||||||
|
smtpTls: meldungen.smtp.tls,
|
||||||
|
smtpBenutzer: meldungen.smtp.benutzer ?? "",
|
||||||
|
smtpAbsender: meldungen.smtp.absender ?? "",
|
||||||
|
smtpPasswortGesetzt: meldungen.smtp.passwortGesetzt,
|
||||||
|
webhookUrl: meldungen.webhook.url ?? "",
|
||||||
|
webhookGeheimnisGesetzt: meldungen.webhook.geheimnisGesetzt,
|
||||||
|
appUrlFehlt: meldungen.appUrl === null,
|
||||||
|
eigeneMail: benutzer.email,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
|
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
|
||||||
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
|
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Clock, Inbox, 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";
|
||||||
@@ -30,6 +34,13 @@ 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">
|
||||||
@@ -123,11 +134,26 @@ 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.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
|
quellen
|
||||||
|
// 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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,9 +161,10 @@ 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 Active Directory, damit beim Start eines Laufs nichts
|
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts
|
||||||
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
|
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus
|
||||||
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
|
Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "}
|
||||||
|
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{quellen.length === 0 ? (
|
{quellen.length === 0 ? (
|
||||||
@@ -152,7 +179,23 @@ 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
|
||||||
@@ -164,23 +207,65 @@ 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.url} · {quelle.basisDn}
|
{quelle.art === "GRAPH" ? (
|
||||||
|
<>
|
||||||
|
{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">
|
||||||
<VerbindungTesten quelleId={quelle.id} />
|
{/*
|
||||||
<JetztAbgleichen quelleId={quelle.id} />
|
* **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} />
|
||||||
|
<JetztAbgleichen quelleId={quelle.id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="mt-4 border-t border-linie pt-4">
|
{quelle.art === "SCIM" ? null : (
|
||||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
<details className="mt-4 border-t border-linie pt-4">
|
||||||
Abgleich-Log
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
</summary>
|
Abgleich-Log
|
||||||
<Log zeilen={logs.get(quelle.id) ?? []} />
|
</summary>
|
||||||
</details>
|
<Log zeilen={logs.get(quelle.id) ?? []} />
|
||||||
|
</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">
|
||||||
@@ -198,8 +283,9 @@ 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">
|
||||||
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
|
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur
|
||||||
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die
|
||||||
|
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
|
||||||
</p>
|
</p>
|
||||||
<QuelleFormular />
|
<QuelleFormular />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle, Check, Clock, RotateCcw } from "lucide-react";
|
||||||
|
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { zustellungenHolen, zustellungenZaehlen } from "@/server/queries/zustellungen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin → Zustellungen (#034).
|
||||||
|
*
|
||||||
|
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
|
||||||
|
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
|
||||||
|
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder Lauf funktioniert
|
||||||
|
* weiter, nur erfaehrt niemand davon.
|
||||||
|
*
|
||||||
|
* Gezeigt wird der **Betreff**, nicht der Rumpf: fuer „laeuft der Versand?“
|
||||||
|
* braucht es ihn nicht, und er traegt Lauftitel und Geraetenamen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANLASS_TEXT: Record<Meldeanlass, string> = {
|
||||||
|
LAUF_ZUGEWIESEN: "zugewiesen",
|
||||||
|
LAUF_UEBERFAELLIG: "überfällig",
|
||||||
|
LAUF_ABGESCHLOSSEN: "abgeschlossen",
|
||||||
|
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUSTAND_TEXT: Record<Zustellzustand, string> = {
|
||||||
|
OFFEN: "offen",
|
||||||
|
GESENDET: "gesendet",
|
||||||
|
FEHLGESCHLAGEN: "wird wiederholt",
|
||||||
|
AUFGEGEBEN: "aufgegeben",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUSTAND_FARBE: Record<Zustellzustand, string> = {
|
||||||
|
OFFEN: "text-nebentext",
|
||||||
|
GESENDET: "text-erledigt",
|
||||||
|
FEHLGESCHLAGEN: "text-faellig",
|
||||||
|
AUFGEGEBEN: "text-fehler",
|
||||||
|
};
|
||||||
|
|
||||||
|
function zeitpunkt(wert: Date): string {
|
||||||
|
return wert.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ZustellungenSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ zustand?: string }>;
|
||||||
|
}) {
|
||||||
|
// Jede Seite prüft selbst – die Hülle ist das zweite Netz (CLAUDE.md).
|
||||||
|
await sitzungErforderlich("verwaltung", "/admin/zustellungen");
|
||||||
|
|
||||||
|
const { zustand: roherZustand } = await searchParams;
|
||||||
|
const gefiltert =
|
||||||
|
roherZustand && roherZustand in Zustellzustand ? (roherZustand as Zustellzustand) : undefined;
|
||||||
|
|
||||||
|
const [zeilen, zaehler] = await Promise.all([
|
||||||
|
zustellungenHolen(gefiltert ? { zustand: gefiltert } : {}),
|
||||||
|
zustellungenZaehlen(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const aufgegeben = zaehler[Zustellzustand.AUFGEGEBEN];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold">Zustellungen</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||||
|
Was Laufzettel per Mail und Webhook hinausgeschickt hat. Eine fehlgeschlagene Zustellung wird
|
||||||
|
wiederholt — nach 1, 5, 30 und 120 Minuten — und bleibt nach dem fünften Fehlschlag als{" "}
|
||||||
|
<strong>aufgegeben</strong> stehen, damit es jemand sieht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{aufgegeben > 0 ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-fehler/40 bg-flaeche p-3 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{aufgegeben}</strong> {aufgegeben === 1 ? "Zustellung wurde" : "Zustellungen wurden"}{" "}
|
||||||
|
aufgegeben. Der Grund steht jeweils unter der Zeile; meist stimmt der SMTP-Zugang nicht mehr.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Nach Zustand filtern">
|
||||||
|
<Link
|
||||||
|
href="/admin/zustellungen"
|
||||||
|
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
|
||||||
|
gefiltert === undefined
|
||||||
|
? "border-akzent bg-akzent-flaeche text-akzent"
|
||||||
|
: "border-linie text-nebentext hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</Link>
|
||||||
|
{Object.values(Zustellzustand).map((wert) => (
|
||||||
|
<Link
|
||||||
|
key={wert}
|
||||||
|
href={`/admin/zustellungen?zustand=${wert}`}
|
||||||
|
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
|
||||||
|
gefiltert === wert
|
||||||
|
? "border-akzent bg-akzent-flaeche text-akzent"
|
||||||
|
: "border-linie text-nebentext hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ZUSTAND_TEXT[wert]}
|
||||||
|
<span className="text-xs">{zaehler[wert]}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{zeilen.length === 0 ? (
|
||||||
|
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||||
|
{gefiltert === undefined
|
||||||
|
? "Noch nichts verschickt. Sobald ein Lauf jemandem zugewiesen, überfällig oder " +
|
||||||
|
"abgeschlossen wird, steht es hier."
|
||||||
|
: "In diesem Zustand steht gerade nichts."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
/* Am Handy nie waagerecht scrollen: dort Karten statt einer Tabelle. */
|
||||||
|
<ul className="mt-6 flex flex-col gap-3">
|
||||||
|
{zeilen.map((zeile) => (
|
||||||
|
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4 text-sm">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span className={`flex items-center gap-1.5 font-semibold ${ZUSTAND_FARBE[zeile.zustand]}`}>
|
||||||
|
{zeile.zustand === Zustellzustand.GESENDET ? (
|
||||||
|
<Check className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||||
|
) : zeile.zustand === Zustellzustand.AUFGEGEBEN ? (
|
||||||
|
<AlertTriangle className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||||
|
) : zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
|
||||||
|
<RotateCcw className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Clock className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{ZUSTAND_TEXT[zeile.zustand]}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||||
|
{ANLASS_TEXT[zeile.anlass]}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||||
|
{zeile.weg === Zustellweg.MAIL ? "Mail" : "Webhook"}
|
||||||
|
</span>
|
||||||
|
{zeile.versuche > 1 ? (
|
||||||
|
<span className="text-xs text-nebentext">{zeile.versuche} Versuche</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 break-words">{zeile.betreff}</p>
|
||||||
|
<p className="mt-1 break-all text-xs text-nebentext">{zeile.empfaenger}</p>
|
||||||
|
|
||||||
|
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
|
||||||
|
<div>
|
||||||
|
<dt className="inline">Eingereiht: </dt>
|
||||||
|
<dd className="inline">{zeitpunkt(zeile.erstelltAm)}</dd>
|
||||||
|
</div>
|
||||||
|
{zeile.gesendetAm ? (
|
||||||
|
<div>
|
||||||
|
<dt className="inline">Gesendet: </dt>
|
||||||
|
<dd className="inline">{zeitpunkt(zeile.gesendetAm)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
|
||||||
|
<div>
|
||||||
|
<dt className="inline">Nächster Versuch: </dt>
|
||||||
|
<dd className="inline">{zeitpunkt(zeile.naechsterVersuchAm)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{zeile.laufId ? (
|
||||||
|
<div>
|
||||||
|
<Link href={`/laeufe/${zeile.laufId}`} className="underline hover:text-text">
|
||||||
|
Zum Lauf
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{zeile.letzterFehler ? (
|
||||||
|
<p className="mono mt-3 rounded-karte border border-linie bg-grund px-3 py-2 text-xs break-words text-fehler">
|
||||||
|
{zeile.letzterFehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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).
|
||||||
@@ -30,6 +31,7 @@ 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">
|
||||||
@@ -56,11 +58,16 @@ 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="/"
|
href={einstieg}
|
||||||
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
|
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
|
||||||
>
|
>
|
||||||
Zur Übersicht
|
{einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
LaufMitAdKontoVerknuepfen,
|
LaufMitAdKontoVerknuepfen,
|
||||||
LaufZurueckholen,
|
LaufZurueckholen,
|
||||||
} from "@/components/laeufe/lauf-aktionen";
|
} from "@/components/laeufe/lauf-aktionen";
|
||||||
|
import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
|
||||||
import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
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 { protokollDesLaufs } from "@/server/queries/protokoll";
|
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||||
import {
|
import {
|
||||||
kommentareDesLaufs,
|
kommentareDesLaufs,
|
||||||
@@ -82,12 +84,14 @@ function Schritt({
|
|||||||
lesend,
|
lesend,
|
||||||
ausfuehrlich,
|
ausfuehrlich,
|
||||||
kommentare,
|
kommentare,
|
||||||
|
anhaenge,
|
||||||
}: {
|
}: {
|
||||||
schritt: AbarbeitungsSchritt;
|
schritt: AbarbeitungsSchritt;
|
||||||
laufId: string;
|
laufId: string;
|
||||||
lesend: boolean;
|
lesend: boolean;
|
||||||
ausfuehrlich: boolean;
|
ausfuehrlich: boolean;
|
||||||
kommentare: readonly KommentarZeile[];
|
kommentare: readonly KommentarZeile[];
|
||||||
|
anhaenge: readonly SchrittAnhang[];
|
||||||
}) {
|
}) {
|
||||||
const vomTisch =
|
const vomTisch =
|
||||||
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
|
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
|
||||||
@@ -197,6 +201,22 @@ function Schritt({
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem
|
||||||
|
* archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild
|
||||||
|
* ist Teil des Belegs.
|
||||||
|
*/}
|
||||||
|
{anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<SchrittAnhaenge
|
||||||
|
laufId={laufId}
|
||||||
|
vorlagenSchrittId={schritt.schrittId}
|
||||||
|
anhaenge={anhaenge}
|
||||||
|
lesend={lesend || !schritt.ergebnisId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{lesend || !schritt.ergebnisId ? null : (
|
{lesend || !schritt.ergebnisId ? null : (
|
||||||
<details className="mt-2">
|
<details className="mt-2">
|
||||||
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
|
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
|
||||||
@@ -220,12 +240,14 @@ function Abschnitt({
|
|||||||
lesend,
|
lesend,
|
||||||
ausfuehrlich,
|
ausfuehrlich,
|
||||||
kommentare,
|
kommentare,
|
||||||
|
anhaenge,
|
||||||
}: {
|
}: {
|
||||||
abschnitt: AbarbeitungsAbschnitt;
|
abschnitt: AbarbeitungsAbschnitt;
|
||||||
laufId: string;
|
laufId: string;
|
||||||
lesend: boolean;
|
lesend: boolean;
|
||||||
ausfuehrlich: boolean;
|
ausfuehrlich: boolean;
|
||||||
kommentare: readonly KommentarZeile[];
|
kommentare: readonly KommentarZeile[];
|
||||||
|
anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
|
||||||
}) {
|
}) {
|
||||||
const erledigt = abschnitt.schritte.filter(
|
const erledigt = abschnitt.schritte.filter(
|
||||||
(schritt) =>
|
(schritt) =>
|
||||||
@@ -263,6 +285,7 @@ function Abschnitt({
|
|||||||
lesend={lesend}
|
lesend={lesend}
|
||||||
ausfuehrlich={ausfuehrlich}
|
ausfuehrlich={ausfuehrlich}
|
||||||
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
|
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
|
||||||
|
anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -297,6 +320,9 @@ export default async function LaufSeite({
|
|||||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
||||||
// Einträge dieses Laufs (#023).
|
// Einträge dieses Laufs (#023).
|
||||||
const laufProtokoll = await protokollDesLaufs(id);
|
const laufProtokoll = await protokollDesLaufs(id);
|
||||||
|
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
||||||
|
// Anhaenge dieses Laufs (#032).
|
||||||
|
const anhaenge = await anhaengeDesLaufs(id, benutzer);
|
||||||
/*
|
/*
|
||||||
* 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.
|
||||||
@@ -394,6 +420,7 @@ export default async function LaufSeite({
|
|||||||
lesend={lesend}
|
lesend={lesend}
|
||||||
ausfuehrlich={ausfuehrlich}
|
ausfuehrlich={ausfuehrlich}
|
||||||
kommentare={kommentare}
|
kommentare={kommentare}
|
||||||
|
anhaenge={anhaenge}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { Info, Laptop } from "lucide-react";
|
||||||
|
import { LaufStatus } from "@/generated/prisma/enums";
|
||||||
|
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
meineDaten,
|
||||||
|
meineGeraete,
|
||||||
|
meineLaeufeOhneGeraet,
|
||||||
|
type MeinLauf,
|
||||||
|
} from "@/server/queries/selbstauskunft";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selbstauskunft: meine Geräte und was mit ihnen geschehen ist (#035).
|
||||||
|
*
|
||||||
|
* **Diese Seite zeigt keine Schritte, Notizen, Kommentare oder Anhänge** — und
|
||||||
|
* zwar nicht, weil sie sie weglässt, sondern weil das Lesemodell sie nicht
|
||||||
|
* liefert. Die Einschränkung sitzt in `src/server/queries/selbstauskunft.ts`;
|
||||||
|
* eine Seite, die filtert, wäre eine Seite, die es vergessen kann (CLAUDE.md).
|
||||||
|
*
|
||||||
|
* Jede Seite prüft selbst — die Hülle ist das zweite Netz, nicht das erste.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUS_TEXT: Record<LaufStatus, string> = {
|
||||||
|
OFFEN: "offen",
|
||||||
|
IN_ARBEIT: "in Arbeit",
|
||||||
|
ABGESCHLOSSEN: "abgeschlossen",
|
||||||
|
ARCHIVIERT: "abgeschlossen",
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `ARCHIVIERT` heisst hier „abgeschlossen“, nicht „archiviert“.
|
||||||
|
*
|
||||||
|
* Der Unterschied ist eine Frage der Aktenfuehrung und interessiert den
|
||||||
|
* Mitarbeiter nicht — fuer ihn ist beides „fertig“. „Archiviert“ klaenge
|
||||||
|
* ausserdem, als waere etwas weggelegt worden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUS_FARBE: Record<LaufStatus, string> = {
|
||||||
|
OFFEN: "text-nebentext",
|
||||||
|
IN_ARBEIT: "text-faellig",
|
||||||
|
ABGESCHLOSSEN: "text-erledigt",
|
||||||
|
ARCHIVIERT: "text-erledigt",
|
||||||
|
};
|
||||||
|
|
||||||
|
function datum(wert: Date): string {
|
||||||
|
return wert.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Zeitraum eines Laufs, so kurz wie möglich. */
|
||||||
|
function zeitraum(lauf: MeinLauf): string {
|
||||||
|
const beginn = lauf.begonnenAm ?? lauf.erstelltAm;
|
||||||
|
if (!lauf.abgeschlossenAm) return `seit ${datum(beginn)}`;
|
||||||
|
const anfang = datum(beginn);
|
||||||
|
const ende = datum(lauf.abgeschlossenAm);
|
||||||
|
return anfang === ende ? anfang : `${anfang} – ${ende}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LaufZeile({ lauf }: { lauf: MeinLauf }) {
|
||||||
|
return (
|
||||||
|
<li className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-3 first:border-t-0">
|
||||||
|
<span className="min-w-0 flex-1 break-words">{lauf.titel}</span>
|
||||||
|
<span className="text-xs text-nebentext">{zeitraum(lauf)}</span>
|
||||||
|
<span className={`text-xs font-semibold ${STATUS_FARBE[lauf.status]}`}>{STATUS_TEXT[lauf.status]}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MeineGeraeteSeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("selbstauskunft", "/meine-geraete");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ohne Zuordnung gibt es die Rolle nicht — das hält die Datenbank fest
|
||||||
|
* (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`). Der Fall kann hier also nicht
|
||||||
|
* eintreten; die Prüfung steht trotzdem da, weil ein `!` an dieser Stelle eine
|
||||||
|
* Behauptung wäre und kein Beweis.
|
||||||
|
*/
|
||||||
|
if (!benutzer.mitarbeiterId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold">Meine Geräte</h1>
|
||||||
|
<p role="alert" className="mt-6 max-w-prose rounded-karte border border-fehler/40 bg-flaeche p-4">
|
||||||
|
Dieses Konto ist keiner Person im Verzeichnis zugeordnet. Ein Administrator muss das einrichten —
|
||||||
|
bis dahin gibt es hier nichts zu zeigen.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [geraete, ohneGeraet, daten] = await Promise.all([
|
||||||
|
meineGeraete(benutzer.mitarbeiterId),
|
||||||
|
meineLaeufeOhneGeraet(benutzer.mitarbeiterId),
|
||||||
|
meineDaten(benutzer.mitarbeiterId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nichts = geraete.length === 0 && ohneGeraet.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold">Meine Geräte</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||||
|
Die Geräte, die auf {daten?.anzeigename ?? "dich"} laufen, und was die IT daran gemacht hat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-3 text-sm text-nebentext">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Hier stehen <strong>Titel, Zeitraum und Status</strong> einer Arbeit — nicht die einzelnen Schritte,
|
||||||
|
Notizen oder Fotos. Wenn du wissen willst, was genau gemacht wurde, frag die IT.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{daten ? (
|
||||||
|
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Meine Daten</h2>
|
||||||
|
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||||
|
So steht es im Verzeichnis. Stimmt etwas nicht, meldet das die IT dem Personalbüro — ändern lässt
|
||||||
|
es sich hier nicht.
|
||||||
|
</p>
|
||||||
|
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
|
||||||
|
<dt className="text-nebentext">Name</dt>
|
||||||
|
<dd>{daten.anzeigename}</dd>
|
||||||
|
{daten.titel ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-nebentext">Funktion</dt>
|
||||||
|
<dd>{daten.titel}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{daten.abteilung ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-nebentext">Abteilung</dt>
|
||||||
|
<dd>{daten.abteilung}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{daten.mail ? (
|
||||||
|
<>
|
||||||
|
<dt className="text-nebentext">E-Mail</dt>
|
||||||
|
<dd className="break-all">{daten.mail}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{nichts ? (
|
||||||
|
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||||
|
Auf dich läuft gerade kein Gerät, und es ist auch keine Arbeit zu dir vermerkt. Das ist kein Fehler
|
||||||
|
— es heißt nur, dass die IT nichts zu dir festgehalten hat.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{geraete.length > 0 ? (
|
||||||
|
<ul className="mt-6 flex flex-col gap-4">
|
||||||
|
{geraete.map((geraet) => (
|
||||||
|
<li key={geraet.id} className="rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<Laptop
|
||||||
|
className="size-[18px] shrink-0 self-center text-nebentext"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2 className="text-lg font-semibold">{geraet.bezeichnung}</h2>
|
||||||
|
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||||
|
{GERAETE_TYP_TEXT[geraet.typ]}
|
||||||
|
</span>
|
||||||
|
{geraet.archiviert ? (
|
||||||
|
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
|
||||||
|
nicht mehr in Benutzung
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
|
||||||
|
{geraet.seriennummer ? (
|
||||||
|
<div>
|
||||||
|
<dt className="inline">Seriennummer: </dt>
|
||||||
|
<dd className="mono inline">{geraet.seriennummer}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<dt className="inline">Aufkleber: </dt>
|
||||||
|
<dd className="mono inline">{geraet.tagCode}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{geraet.laeufe.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-nebentext">
|
||||||
|
Zu diesem Gerät ist noch keine Arbeit festgehalten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 text-sm">
|
||||||
|
{geraet.laeufe.map((lauf) => (
|
||||||
|
<LaufZeile key={lauf.id} lauf={lauf} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ohneGeraet.length > 0 ? (
|
||||||
|
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Zu mir persönlich</h2>
|
||||||
|
<p className="mt-1 mb-2 max-w-prose text-sm text-nebentext">
|
||||||
|
Arbeiten, die nicht an einem Gerät hängen — etwa das Einrichten deines Kontos.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm">
|
||||||
|
{ohneGeraet.map((lauf) => (
|
||||||
|
<LaufZeile key={lauf.id} lauf={lauf} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
-1
@@ -2,7 +2,8 @@ import Link from "next/link";
|
|||||||
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
|
import { 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 } from "@/server/auth/rollen";
|
import { darf, startseite } 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,
|
||||||
@@ -223,6 +224,17 @@ 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");
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ 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.",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { AnhangFehler, anhangLesen } from "@/server/services/anhaenge";
|
||||||
|
import { MandantFehler } from "@/server/mandant";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { istBild } from "@/lib/anhaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Anhang ausliefern (#032).
|
||||||
|
*
|
||||||
|
* **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx`
|
||||||
|
* deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist, nie was
|
||||||
|
* drinsteht (CLAUDE.md). Hier zählt das doppelt: eine Adresse, die eine Datei
|
||||||
|
* ohne Prüfung ausliefert, umgeht alles andere.
|
||||||
|
*
|
||||||
|
* `Content-Disposition: inline` nur für Bilder. Ein PDF wird **heruntergeladen**
|
||||||
|
* statt im Browser gerendert: ein PDF kann Skripte enthalten, und ein Anhang,
|
||||||
|
* den jemand aus einem fremden Lauf öffnet, soll nichts ausführen.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
): Promise<Response> {
|
||||||
|
let benutzer: { id: string; rolle: Parameters<typeof anhangLesen>[1]["rolle"] };
|
||||||
|
try {
|
||||||
|
const zugang = await rechtErforderlich("lesen");
|
||||||
|
benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) {
|
||||||
|
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const datei = await anhangLesen(id, benutzer);
|
||||||
|
// Der Dateiname steckt in einer Kopfzeile. `dateinameSaeubern` hat
|
||||||
|
// Steuerzeichen und Anführungszeichen schon beim Anlegen entfernt.
|
||||||
|
const anzeige = istBild(datei.medientyp) ? "inline" : "attachment";
|
||||||
|
return new Response(new Uint8Array(datei.inhalt), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": datei.medientyp,
|
||||||
|
"Content-Length": String(datei.groesse),
|
||||||
|
"Content-Disposition": `${anzeige}; filename="${datei.dateiname}"`,
|
||||||
|
// Privat: ein Anhang gehört einem Kunden, kein Zwischenspeicher darf ihn
|
||||||
|
// an den nächsten Betrachter weitergeben.
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof MandantFehler) {
|
||||||
|
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
|
||||||
|
// Unterscheidung wäre schon eine Auskunft über fremde Kunden.
|
||||||
|
return Response.json({ fehler: "Diesen Anhang gibt es nicht." }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (fehler instanceof AnhangFehler) {
|
||||||
|
return Response.json({ fehler: fehler.message }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
|
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
|
||||||
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
|
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,8 +35,27 @@ 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=oidc-rueckruf", anfrage.nextUrl.origin),
|
oeffentlicheAdresse(`/anmelden?fehler=${schluessel}`, anfrage.nextUrl.origin),
|
||||||
);
|
);
|
||||||
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
|
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
|
||||||
return antwort;
|
return antwort;
|
||||||
|
|||||||
@@ -1,29 +1,54 @@
|
|||||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } 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 { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013).
|
* Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033).
|
||||||
*
|
*
|
||||||
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
|
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
|
||||||
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
|
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
|
||||||
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md).
|
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein
|
||||||
|
* offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das
|
||||||
|
* erste Ereignis fliesst.
|
||||||
|
*
|
||||||
|
* **Eine Adresse fuer beide Wege.** `EventSource` schickt
|
||||||
|
* `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe
|
||||||
|
* Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt
|
||||||
|
* waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei
|
||||||
|
* Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter
|
||||||
|
* Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt.
|
||||||
*
|
*
|
||||||
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
|
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
|
||||||
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte
|
* entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das
|
||||||
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt.
|
* Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur
|
||||||
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser
|
* Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das,
|
||||||
* am selben Lauf voneinander wissen muessen.
|
* was zwei Browser am selben Lauf voneinander wissen muessen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend
|
||||||
|
* NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des
|
||||||
|
* Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und
|
||||||
|
* fassen einen Schwung zusammen.
|
||||||
|
*/
|
||||||
|
const SAMMELFENSTER_MS = 150;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_anfrage: Request,
|
anfrage: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
): 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 { benutzer } = await rechtErforderlich("lesen");
|
const zugang = await rechtErforderlich("lesen");
|
||||||
benutzerId = benutzer.id;
|
benutzerId = zugang.benutzer.id;
|
||||||
rolle = benutzer.rolle;
|
rolle = zugang.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
|
||||||
@@ -34,16 +59,151 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const zustand = await laufZustand(id, { id: benutzerId, rolle });
|
let benutzer = { id: benutzerId, rolle };
|
||||||
|
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
|
||||||
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
|
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
|
||||||
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
|
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json(zustand, {
|
/*
|
||||||
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
|
* Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze
|
||||||
// aus einem Zwischenspeicher kommt.
|
* antwortet der Handler **gewoehnlich**: `EventSource` verlangt
|
||||||
headers: { "Cache-Control": "no-store" },
|
* `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht
|
||||||
|
* wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass
|
||||||
|
* es dafuer einen Sonderfall oder einen Fehlercode braucht.
|
||||||
|
*/
|
||||||
|
if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) {
|
||||||
|
return Response.json(zustand, {
|
||||||
|
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
|
||||||
|
// aus einem Zwischenspeicher kommt.
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const kodierer = new TextEncoder();
|
||||||
|
let abmelden: (() => void) | undefined;
|
||||||
|
let herzschlag: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let sammler: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const strom = new ReadableStream<Uint8Array>({
|
||||||
|
async start(steuerung) {
|
||||||
|
let offen = true;
|
||||||
|
|
||||||
|
function schicken(text: string): void {
|
||||||
|
if (!offen) return;
|
||||||
|
try {
|
||||||
|
steuerung.enqueue(kodierer.encode(text));
|
||||||
|
} catch {
|
||||||
|
// Gegenseite ist weg, bevor `abort` ankam.
|
||||||
|
offen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufraeumen(): void {
|
||||||
|
if (!offen) return;
|
||||||
|
offen = false;
|
||||||
|
abmelden?.();
|
||||||
|
if (herzschlag) clearInterval(herzschlag);
|
||||||
|
if (sammler) clearTimeout(sammler);
|
||||||
|
try {
|
||||||
|
steuerung.close();
|
||||||
|
} catch {
|
||||||
|
// Schon geschlossen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function standSchicken(): Promise<void> {
|
||||||
|
if (!offen) return;
|
||||||
|
try {
|
||||||
|
const jetzt = await laufZustand(id, benutzer);
|
||||||
|
if (!jetzt) {
|
||||||
|
/*
|
||||||
|
* Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde
|
||||||
|
* entzogen wurde. Der Strom endet; der Client holt sich an der
|
||||||
|
* gewoehnlichen Adresse die 404 und stellt ein.
|
||||||
|
*/
|
||||||
|
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" })));
|
||||||
|
aufraeumen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schicken(sseEreignis("zustand", JSON.stringify(jetzt)));
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der erste Stand sofort, damit der Client einen Vergleichspunkt hat,
|
||||||
|
// ohne zusaetzlich zu pollen.
|
||||||
|
schicken(sseEreignis("zustand", JSON.stringify(zustand)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
abmelden = await aufLaufHoeren(id, () => {
|
||||||
|
if (sammler) return;
|
||||||
|
sammler = setTimeout(() => {
|
||||||
|
sammler = undefined;
|
||||||
|
void standSchicken();
|
||||||
|
}, SAMMELFENSTER_MS);
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
/*
|
||||||
|
* Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind
|
||||||
|
* zu lassen: Strom beenden, der Client fuellt auf Polling zurueck.
|
||||||
|
*/
|
||||||
|
console.error("LISTEN fuer den Strom nicht moeglich:", fehler);
|
||||||
|
aufraeumen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **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);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
abmelden?.();
|
||||||
|
if (herzschlag) clearInterval(herzschlag);
|
||||||
|
if (sammler) clearTimeout(sammler);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(strom, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
/*
|
||||||
|
* Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn
|
||||||
|
* in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx
|
||||||
|
* den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus,
|
||||||
|
* als sei sie hangen geblieben. Die Einstellung steht trotzdem in
|
||||||
|
* `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx.
|
||||||
|
*/
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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] },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { Mail, Send, Webhook } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FELD } from "@/lib/formular";
|
||||||
|
import {
|
||||||
|
meldungenSpeichernAktion,
|
||||||
|
testmailAktion,
|
||||||
|
type AktionsErgebnis,
|
||||||
|
} from "@/server/actions/benachrichtigungen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP und Webhook einrichten (#034).
|
||||||
|
*
|
||||||
|
* Zwei Dinge sind hier bewusst:
|
||||||
|
*
|
||||||
|
* 1. **Ein gesetztes Passwort wird nie angezeigt**, nur dass es eines gibt. Das
|
||||||
|
* Feld bleibt leer, und leer heisst „unverändert lassen“ — sonst loeschte
|
||||||
|
* jedes Speichern der uebrigen Felder das Passwort mit.
|
||||||
|
* 2. **Die Testmail ist ein eigenes Formular** und geht direkt hinaus, nicht
|
||||||
|
* ueber die Warteschlange. Ein Knopf, der „eingereiht“ meldet und den Fehler
|
||||||
|
* eine Minute spaeter woanders zeigt, prueft nichts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MeldungsWerte = {
|
||||||
|
readonly smtpHost: string;
|
||||||
|
readonly smtpPort: string;
|
||||||
|
readonly smtpTls: boolean;
|
||||||
|
readonly smtpBenutzer: string;
|
||||||
|
readonly smtpAbsender: string;
|
||||||
|
readonly smtpPasswortGesetzt: boolean;
|
||||||
|
readonly webhookUrl: string;
|
||||||
|
readonly webhookGeheimnisGesetzt: boolean;
|
||||||
|
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
|
||||||
|
readonly appUrlFehlt: boolean;
|
||||||
|
readonly eigeneMail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Rueckmeldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
|
||||||
|
if (ergebnis.fehler) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
{ergebnis.fehler}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ergebnis.erfolg) {
|
||||||
|
return (
|
||||||
|
<p role="status" className="max-w-prose text-sm text-erledigt">
|
||||||
|
{ergebnis.erfolg}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BenachrichtigungenFormular({ werte }: { werte: MeldungsWerte }) {
|
||||||
|
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(meldungenSpeichernAktion, {});
|
||||||
|
const [testErgebnis, testAktion, testLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
testmailAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Mail className="size-[18px] text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Benachrichtigungen
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||||
|
Vier Anlässe gehen hinaus: ein Lauf wird jemandem <strong>zugewiesen</strong>, ein Lauf ist{" "}
|
||||||
|
<strong>überfällig</strong>, ein Lauf wird <strong>abgeschlossen</strong>, und ein{" "}
|
||||||
|
<strong>Verzeichnisabgleich schlägt fehl</strong>. Die Nachricht enthält Titel, Status und einen
|
||||||
|
Link — <strong>keine Schrittinhalte</strong>, und damit auch kein erzeugtes Passwort.
|
||||||
|
</p>
|
||||||
|
{werte.appUrlFehlt ? (
|
||||||
|
<p role="alert" className="mb-4 max-w-prose text-sm text-faellig">
|
||||||
|
<code className="mono">APP_URL</code> ist nicht gesetzt. Die Meldungen gehen trotzdem hinaus, aber
|
||||||
|
der Link darin führt nirgendwohin.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form action={aktion} className="flex flex-col gap-8">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold">SMTP</h3>
|
||||||
|
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||||
|
Ohne Host geht keine Mail hinaus — die Webhook-Zustellung unten funktioniert davon unabhängig.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Host</span>
|
||||||
|
<input
|
||||||
|
name="smtpHost"
|
||||||
|
defaultValue={werte.smtpHost}
|
||||||
|
placeholder="mail.firma.de"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Port</span>
|
||||||
|
<input
|
||||||
|
name="smtpPort"
|
||||||
|
defaultValue={werte.smtpPort}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="587"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Benutzer</span>
|
||||||
|
<input
|
||||||
|
name="smtpBenutzer"
|
||||||
|
defaultValue={werte.smtpBenutzer}
|
||||||
|
autoComplete="off"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">
|
||||||
|
Passwort
|
||||||
|
{werte.smtpPasswortGesetzt ? (
|
||||||
|
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="smtpPasswort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={werte.smtpPasswortGesetzt ? "unverändert" : ""}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Absender</span>
|
||||||
|
<input
|
||||||
|
name="smtpAbsender"
|
||||||
|
defaultValue={werte.smtpAbsender}
|
||||||
|
placeholder="laufzettel@firma.de"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-h-11 items-center gap-3 self-end text-sm md:min-h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="smtpTls"
|
||||||
|
value="an"
|
||||||
|
defaultChecked={werte.smtpTls}
|
||||||
|
className="size-4 accent-akzent"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
TLS verlangen
|
||||||
|
<span className="block text-xs text-nebentext">
|
||||||
|
Port 465 verschlüsselt von Anfang an, 587 per StartTLS.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Webhook className="size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Webhook
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||||
|
Dieselben vier Anlässe als <code className="mono">POST</code> mit JSON. Mit Geheimnis trägt jede
|
||||||
|
Anfrage eine HMAC-SHA256-Signatur im Kopf <code className="mono">X-Laufzettel-Signatur</code>; wie
|
||||||
|
sie nachzurechnen ist, steht in <code className="mono">docs/betrieb/benachrichtigungen.md</code>.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Adresse</span>
|
||||||
|
<input
|
||||||
|
name="webhookUrl"
|
||||||
|
defaultValue={werte.webhookUrl}
|
||||||
|
placeholder="https://hook.firma.de/laufzettel"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">
|
||||||
|
Geheimnis
|
||||||
|
{werte.webhookGeheimnisGesetzt ? (
|
||||||
|
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="webhookGeheimnis"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={werte.webhookGeheimnisGesetzt ? "unverändert" : "ohne Signatur"}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{/*
|
||||||
|
* Eigene Beschriftung, nicht bloss „Speichern“: auf dieser Seite gibt
|
||||||
|
* es zwei Formulare, und zwei gleich benannte Knoepfe sind fuer einen
|
||||||
|
* Menschen genauso verwirrend wie fuer einen Test.
|
||||||
|
*/}
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft ? "Wird gespeichert …" : "Benachrichtigungen speichern"}
|
||||||
|
</Button>
|
||||||
|
<Rueckmeldung ergebnis={ergebnis} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={testAktion} className="flex flex-col gap-3 border-t border-linie pt-6">
|
||||||
|
<h3 className="text-sm font-semibold">Testmail</h3>
|
||||||
|
<p className="max-w-prose text-sm text-nebentext">
|
||||||
|
Geht <strong>direkt</strong> hinaus, nicht über die Warteschlange — ein Fehler erscheint deshalb
|
||||||
|
sofort und im Klartext. Gespeichert sein muss der Zugang vorher.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="flex max-w-md flex-1 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">An</span>
|
||||||
|
<input name="an" type="email" defaultValue={werte.eigeneMail} className={FELD} />
|
||||||
|
</label>
|
||||||
|
<Button type="submit" variant="secondary" disabled={testLaeuft}>
|
||||||
|
<Send className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||||
|
{testLaeuft ? "Wird geschickt …" : "Testmail schicken"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Rueckmeldung ergebnis={testErgebnis} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
|
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
|
||||||
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
||||||
import { Wortmarke } from "@/components/wortmarke";
|
import { Wortmarke } from "@/components/wortmarke";
|
||||||
|
import { groesseText, istBild } from "@/lib/anhaenge";
|
||||||
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
|
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
|
||||||
import type { KommentarZeile } from "@/server/queries/laeufe";
|
import type { KommentarZeile } from "@/server/queries/laeufe";
|
||||||
|
|
||||||
@@ -173,6 +174,51 @@ function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anhaenge auf dem Ausdruck (#032).
|
||||||
|
*
|
||||||
|
* Bilder als Miniatur, ein PDF nur als Zeile — ein eingebettetes PDF liesse sich
|
||||||
|
* auf Papier ohnehin nicht ansehen. Die Datei kommt ueber ihre Adresse, nicht
|
||||||
|
* eingebettet: der Ausdruck entsteht im Browser des Benutzers, und dessen
|
||||||
|
* Sitzung laesst den Abruf ohnehin pruefen.
|
||||||
|
*/
|
||||||
|
function Anhaenge({ anhaenge }: { anhaenge: DruckSchritt["anhaenge"] }) {
|
||||||
|
if (anhaenge.length === 0) return null;
|
||||||
|
const bilder = anhaenge.filter((anhang) => istBild(anhang.medientyp));
|
||||||
|
const dateien = anhaenge.filter((anhang) => !istBild(anhang.medientyp));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
{bilder.length > 0 ? (
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{bilder.map((anhang) => (
|
||||||
|
<li key={anhang.id} className="flex flex-col items-start gap-0.5">
|
||||||
|
{/* Ohne next/image: der Optimierer holte die Datei erneut und ohne
|
||||||
|
Rechteprüfung. */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={`/api/anhaenge/${anhang.id}`}
|
||||||
|
alt={anhang.dateiname}
|
||||||
|
className="h-24 w-auto rounded border border-linie object-contain"
|
||||||
|
/>
|
||||||
|
<span className="max-w-32 truncate text-[10px] text-nebentext">{anhang.dateiname}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
{dateien.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{dateien.map((anhang) => (
|
||||||
|
<li key={anhang.id} className="text-xs text-nebentext">
|
||||||
|
Anhang: {anhang.dateiname} ({groesseText(anhang.groesse)})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||||
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
|
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
|
||||||
return (
|
return (
|
||||||
@@ -204,6 +250,7 @@ function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Anhaenge anhaenge={schritt.anhaenge} />
|
||||||
<Kommentare kommentare={schritt.kommentare} />
|
<Kommentare kommentare={schritt.kommentare} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Laptop,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -33,6 +34,7 @@ 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 }) {
|
||||||
@@ -184,6 +186,15 @@ 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 */}
|
||||||
@@ -191,7 +202,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" />
|
||||||
<ScannenKnopf className="ml-auto" />
|
{darfScannen ? <ScannenKnopf className="ml-auto" /> : null}
|
||||||
<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"
|
||||||
@@ -217,7 +228,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>
|
||||||
<ScannenKnopf className="mb-6" />
|
{darfScannen ? <ScannenKnopf className="mb-6" /> : null}
|
||||||
<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,34 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
|
* Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033).
|
||||||
*
|
*
|
||||||
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
|
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
|
||||||
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
|
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
|
||||||
*
|
*
|
||||||
* Vier Entscheidungen stecken darin:
|
* **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank
|
||||||
|
* meldet die Aenderung, der Server schiebt sie weiter, und der Haken des
|
||||||
|
* Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu
|
||||||
|
* zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit
|
||||||
|
* derselben Nutzlast.
|
||||||
|
*
|
||||||
|
* Fuenf Entscheidungen stecken darin:
|
||||||
*
|
*
|
||||||
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
|
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
|
||||||
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
|
* Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert:
|
||||||
* niemanden.
|
* eine offene Verbindung je Hintergrundlasche ist genau das, was die
|
||||||
|
* Obergrenze im Handler verhindern soll.
|
||||||
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
|
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
|
||||||
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
|
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
|
||||||
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
|
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
|
||||||
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn
|
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem
|
||||||
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
* Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
||||||
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
|
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
|
||||||
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
|
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
|
||||||
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
|
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
|
||||||
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine
|
* Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe
|
||||||
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
|
* `laufZustand`) — und diese Zusage waere nichts wert, wenn die
|
||||||
* das Polling stattdessen die ganze Seite neu bauen liesse.
|
* Aktualisierung stattdessen die ganze Seite neu bauen liesse.
|
||||||
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
|
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
|
||||||
* verschwundener Lauf sind kein Grund weiterzufragen.
|
* verschwundener Lauf sind kein Grund weiterzufragen.
|
||||||
|
* 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst
|
||||||
|
* schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe
|
||||||
|
* Zeile ins Protokoll der Konsole.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was ein geaenderter Zustand ausloesen soll.
|
* Was ein geaenderter Zustand ausloesen soll.
|
||||||
*
|
*
|
||||||
@@ -40,6 +51,22 @@ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "
|
|||||||
return passwortSichtbar ? "fragen" : "neu-laden";
|
return passwortSichtbar ? "fragen" : "neu-laden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */
|
||||||
|
export const STROM_GESCHLOSSEN = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung?
|
||||||
|
*
|
||||||
|
* `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht
|
||||||
|
* `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben.
|
||||||
|
* Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort
|
||||||
|
* nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter
|
||||||
|
* Obergrenze, und genau dann soll gepollt werden.
|
||||||
|
*/
|
||||||
|
export function sollZurueckfallen(readyState: number): boolean {
|
||||||
|
return readyState === STROM_GESCHLOSSEN;
|
||||||
|
}
|
||||||
|
|
||||||
export function LaufAktualisierung({
|
export function LaufAktualisierung({
|
||||||
laufId,
|
laufId,
|
||||||
passwortSichtbar = false,
|
passwortSichtbar = false,
|
||||||
@@ -57,29 +84,46 @@ export function LaufAktualisierung({
|
|||||||
const letzterStand = useRef<string | null>(null);
|
const letzterStand = useRef<string | null>(null);
|
||||||
const [neues, setzeNeues] = useState(false);
|
const [neues, setzeNeues] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren.
|
||||||
|
*
|
||||||
|
* Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal
|
||||||
|
* geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der
|
||||||
|
* Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten.
|
||||||
|
*/
|
||||||
|
const standVerarbeiten = useCallback(
|
||||||
|
(stand: string): void => {
|
||||||
|
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
||||||
|
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
||||||
|
else router.refresh();
|
||||||
|
}
|
||||||
|
letzterStand.current = stand;
|
||||||
|
},
|
||||||
|
[passwortSichtbar, router],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let abgebrochen = false;
|
const adresse = `/api/laeufe/${laufId}/zustand`;
|
||||||
|
let eingestellt = false;
|
||||||
|
let quelle: EventSource | undefined;
|
||||||
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let polltJetzt = false;
|
||||||
|
let rueckfallGemeldet = false;
|
||||||
|
|
||||||
|
/* ---------------- Polling: der Rueckfall ---------------- */
|
||||||
|
|
||||||
async function holen(): Promise<void> {
|
async function holen(): Promise<void> {
|
||||||
if (abgebrochen || document.visibilityState === "hidden") {
|
if (eingestellt || document.visibilityState === "hidden") {
|
||||||
plane();
|
plane();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
|
const antwort = await fetch(adresse, { cache: "no-store" });
|
||||||
if (antwort.status === 401 || antwort.status === 404) {
|
if (antwort.status === 401 || antwort.status === 404) {
|
||||||
abgebrochen = true;
|
eingestellt = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (antwort.ok) {
|
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
|
||||||
const stand = JSON.stringify(await antwort.json());
|
|
||||||
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
|
||||||
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
|
||||||
else router.refresh();
|
|
||||||
}
|
|
||||||
letzterStand.current = stand;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
|
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
|
||||||
}
|
}
|
||||||
@@ -87,23 +131,99 @@ export function LaufAktualisierung({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function plane(): void {
|
function plane(): void {
|
||||||
if (abgebrochen) return;
|
if (eingestellt || !polltJetzt) return;
|
||||||
zeitgeber = setTimeout(() => void holen(), 10_000);
|
zeitgeber = setTimeout(() => void holen(), 10_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
void holen();
|
function aufPollingUmstellen(grund: string): void {
|
||||||
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
|
if (eingestellt || polltJetzt) return;
|
||||||
const beiSichtbarkeit = () => {
|
polltJetzt = true;
|
||||||
if (document.visibilityState === "visible") void holen();
|
stromSchliessen();
|
||||||
|
if (!rueckfallGemeldet) {
|
||||||
|
rueckfallGemeldet = true;
|
||||||
|
// Einmal, nicht bei jedem Versuch – siehe Kopfkommentar.
|
||||||
|
console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`);
|
||||||
|
}
|
||||||
|
void holen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Strom: der Normalfall ---------------- */
|
||||||
|
|
||||||
|
function stromSchliessen(): void {
|
||||||
|
quelle?.close();
|
||||||
|
quelle = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stromOeffnen(): void {
|
||||||
|
if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return;
|
||||||
|
const neu = new EventSource(adresse);
|
||||||
|
quelle = neu;
|
||||||
|
|
||||||
|
neu.addEventListener("zustand", (ereignis) => {
|
||||||
|
standVerarbeiten((ereignis as MessageEvent<string>).data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen.
|
||||||
|
neu.addEventListener("ende", () => {
|
||||||
|
eingestellt = true;
|
||||||
|
stromSchliessen();
|
||||||
|
});
|
||||||
|
|
||||||
|
neu.addEventListener("error", () => {
|
||||||
|
if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen");
|
||||||
|
// Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Sichtbarkeit ---------------- */
|
||||||
|
|
||||||
|
const beiSichtbarkeit = (): void => {
|
||||||
|
if (eingestellt) return;
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
// Verbindung wirklich freigeben, nicht bloss ignorieren.
|
||||||
|
stromSchliessen();
|
||||||
|
if (zeitgeber) clearTimeout(zeitgeber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren.
|
||||||
|
* Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf
|
||||||
|
* stuende der alte Stand da, bis das naechste Mal jemand etwas aendert.
|
||||||
|
*/
|
||||||
|
if (polltJetzt) void holen();
|
||||||
|
else {
|
||||||
|
stromOeffnen();
|
||||||
|
void einmalHolen();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ein einzelner Abruf, ohne das Polling einzuschalten. */
|
||||||
|
async function einmalHolen(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(adresse, { cache: "no-store" });
|
||||||
|
if (antwort.status === 401 || antwort.status === 404) {
|
||||||
|
eingestellt = true;
|
||||||
|
stromSchliessen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
|
||||||
|
} catch {
|
||||||
|
// Der Strom liefert den Stand ohnehin gleich mit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource");
|
||||||
|
else stromOeffnen();
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", beiSichtbarkeit);
|
document.addEventListener("visibilitychange", beiSichtbarkeit);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abgebrochen = true;
|
eingestellt = true;
|
||||||
|
stromSchliessen();
|
||||||
if (zeitgeber) clearTimeout(zeitgeber);
|
if (zeitgeber) clearTimeout(zeitgeber);
|
||||||
document.removeEventListener("visibilitychange", beiSichtbarkeit);
|
document.removeEventListener("visibilitychange", beiSichtbarkeit);
|
||||||
};
|
};
|
||||||
}, [laufId, router, passwortSichtbar]);
|
}, [laufId, standVerarbeiten]);
|
||||||
|
|
||||||
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
|
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
|
||||||
if (!neues) return null;
|
if (!neues) return null;
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useRef, useState } from "react";
|
||||||
|
import { Camera, FileText, Paperclip, X } from "lucide-react";
|
||||||
|
import { ERLAUBTE_MEDIENTYPEN, groesseText, istBild, maxGroesseBytes } from "@/lib/anhaenge";
|
||||||
|
import {
|
||||||
|
anhangEntfernenAktion,
|
||||||
|
anhangHochladenAktion,
|
||||||
|
type AktionsErgebnis,
|
||||||
|
} from "@/server/actions/anhaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anhaenge eines Schritts (#032).
|
||||||
|
*
|
||||||
|
* Zwei Wege zum Anhaengen, weil zwei Geraete gemeint sind: **Foto** oeffnet am
|
||||||
|
* Handy direkt die Kamera (`capture="environment"`), **Datei** den Dateiwaehler.
|
||||||
|
* Am Rechner tun beide dasselbe — dort gibt es keine Kamera-App, und der Browser
|
||||||
|
* zeigt in beiden Faellen den Waehler.
|
||||||
|
*
|
||||||
|
* Abgeschickt wird sofort nach der Wahl, ohne zweiten Knopf: ein Foto, das man
|
||||||
|
* gerade gemacht hat, will man nicht noch bestaetigen. Ein eigener
|
||||||
|
* „Hochladen“-Knopf waere der Schritt, den man vergisst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SchrittAnhang = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly dateiname: string;
|
||||||
|
readonly medientyp: string;
|
||||||
|
readonly groesse: number;
|
||||||
|
readonly erstelltVon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SchrittAnhaenge({
|
||||||
|
laufId,
|
||||||
|
vorlagenSchrittId,
|
||||||
|
anhaenge,
|
||||||
|
lesend,
|
||||||
|
}: {
|
||||||
|
laufId: string;
|
||||||
|
vorlagenSchrittId: string;
|
||||||
|
anhaenge: readonly SchrittAnhang[];
|
||||||
|
lesend: boolean;
|
||||||
|
}) {
|
||||||
|
const [hochladen, hochladenAktion, laedt] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
anhangHochladenAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [entfernen, entfernenAktion] = useActionState<AktionsErgebnis, FormData>(anhangEntfernenAktion, {});
|
||||||
|
const formular = useRef<HTMLFormElement>(null);
|
||||||
|
const [zuGross, setzeZuGross] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function gewaehlt(ereignis: React.ChangeEvent<HTMLInputElement>): void {
|
||||||
|
const datei = ereignis.target.files?.[0];
|
||||||
|
if (!datei) return;
|
||||||
|
/*
|
||||||
|
* Die Groesse schon hier pruefen. Sonst wandern bei einem Fehlgriff 200 MB
|
||||||
|
* durch die Leitung, nur damit der Server sie ablehnt — am Handy im
|
||||||
|
* Mobilfunk ist das der Unterschied zwischen einer Meldung und einer
|
||||||
|
* Minute Warten. Dienst und Action pruefen es trotzdem noch einmal.
|
||||||
|
*/
|
||||||
|
if (datei.size > maxGroesseBytes()) {
|
||||||
|
setzeZuGross(
|
||||||
|
`„${datei.name}“ ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
|
||||||
|
`${groesseText(maxGroesseBytes())}.`,
|
||||||
|
);
|
||||||
|
ereignis.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setzeZuGross(null);
|
||||||
|
formular.current?.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const meldung = zuGross ?? hochladen.fehler ?? entfernen.fehler;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{anhaenge.length > 0 ? (
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{anhaenge.map((anhang) => (
|
||||||
|
<li key={anhang.id} className="flex flex-col gap-1">
|
||||||
|
<a
|
||||||
|
href={`/api/anhaenge/${anhang.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={`${anhang.dateiname} · ${groesseText(anhang.groesse)}${
|
||||||
|
anhang.erstelltVon ? ` · ${anhang.erstelltVon}` : ""
|
||||||
|
}`}
|
||||||
|
className="flex items-center gap-2 rounded-karte border border-linie bg-flaeche-hoch p-1.5"
|
||||||
|
>
|
||||||
|
{istBild(anhang.medientyp) ? (
|
||||||
|
// Das Bild selbst als Miniatur – ohne next/image, weil der
|
||||||
|
// Optimierer die Datei erneut und ohne Rechteprüfung holen
|
||||||
|
// würde.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`/api/anhaenge/${anhang.id}`}
|
||||||
|
alt={anhang.dateiname}
|
||||||
|
className="size-14 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex size-14 items-center justify-center rounded bg-flaeche">
|
||||||
|
<FileText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex min-w-0 max-w-40 flex-col text-xs">
|
||||||
|
<span className="truncate">{anhang.dateiname}</span>
|
||||||
|
<span className="text-nebentext">{groesseText(anhang.groesse)}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{lesend ? null : (
|
||||||
|
<form action={entfernenAktion}>
|
||||||
|
<input type="hidden" name="anhangId" value={anhang.id} />
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={`${anhang.dateiname} entfernen`}
|
||||||
|
className="flex min-h-11 items-center gap-1 text-xs text-nebentext hover:text-fehler md:min-h-8"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||||
|
entfernen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lesend ? null : (
|
||||||
|
<form ref={formular} action={hochladenAktion} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
|
||||||
|
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||||
|
<Camera className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Foto
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="datei"
|
||||||
|
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||||
|
capture="environment"
|
||||||
|
onChange={gewaehlt}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||||
|
<Paperclip className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Datei
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="datei"
|
||||||
|
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||||
|
onChange={gewaehlt}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{laedt ? <span className="text-sm text-nebentext">wird hochgeladen …</span> : null}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meldung ? (
|
||||||
|
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||||
|
{meldung}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ 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";
|
||||||
@@ -36,6 +38,22 @@ 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">
|
||||||
@@ -52,132 +70,274 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/*
|
{bearbeiten ? (
|
||||||
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
<>
|
||||||
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
|
<input type="hidden" name="art" value={art} />
|
||||||
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
|
<p className="text-sm text-nebentext">
|
||||||
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
|
Art: <strong className="text-text">{QUELLEN_ART_TEXT[art]}</strong> — sie lässt sich nicht ändern.
|
||||||
* Namen.
|
Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein Mitarbeiter
|
||||||
*/}
|
wiedererkannt.
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
</p>
|
||||||
<label htmlFor="quelle-url" className="text-nebentext">
|
</>
|
||||||
Adresse
|
) : (
|
||||||
</label>
|
<fieldset className="flex flex-col gap-2 text-sm">
|
||||||
<input
|
<legend className="mb-1 text-nebentext">Art</legend>
|
||||||
id="quelle-url"
|
{(
|
||||||
name="url"
|
[
|
||||||
value={adresse}
|
{
|
||||||
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
|
wert: "LDAP" as const,
|
||||||
required
|
titel: "LDAP",
|
||||||
aria-describedby="quelle-url-hinweis"
|
text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
|
||||||
placeholder="ldaps://dc01.firma.local:636"
|
},
|
||||||
className={`${FELD} mono`}
|
{
|
||||||
/>
|
wert: "GRAPH" as const,
|
||||||
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
|
titel: "Microsoft Graph",
|
||||||
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
|
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
|
||||||
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
|
},
|
||||||
hochgestuft) und <span className="mono">ldap://</span>.
|
{
|
||||||
</span>
|
wert: "SCIM" as const,
|
||||||
{klartext ? (
|
titel: "SCIM (wird geliefert)",
|
||||||
/*
|
text:
|
||||||
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
|
"Der Anbieter schickt Änderungen, sobald sie dort entstehen — authentik, Entra ID, Okta. " +
|
||||||
* dasteht, liest nach dem dritten Mal niemand mehr.
|
"Laufzettel holt dabei nichts ab und braucht keine Zugangsdaten.",
|
||||||
*/
|
},
|
||||||
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
|
] satisfies readonly { wert: "LDAP" | "GRAPH" | "SCIM"; titel: string; text: string }[]
|
||||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
).map((wahl) => (
|
||||||
<span>
|
<label
|
||||||
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
|
key={wahl.wert}
|
||||||
Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
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>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
) : null}
|
<p className="max-w-prose text-xs text-nebentext">
|
||||||
</div>
|
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}
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{istLdap ? (
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
<>
|
||||||
<span className="text-nebentext">Anmelde-DN</span>
|
{/*
|
||||||
<input
|
* Die Erklärung steht **außerhalb** des `label` und hängt über
|
||||||
name="bindDn"
|
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
|
||||||
defaultValue={quelle?.bindDn ?? ""}
|
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
|
||||||
required
|
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
|
||||||
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
|
* Namen.
|
||||||
className={`${FELD} mono`}
|
*/}
|
||||||
/>
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
</label>
|
<label htmlFor="quelle-url" className="text-nebentext">
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
Adresse
|
||||||
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
|
</label>
|
||||||
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
<input
|
||||||
Passwort
|
id="quelle-url"
|
||||||
|
name="url"
|
||||||
|
value={adresse}
|
||||||
|
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
|
||||||
|
required
|
||||||
|
aria-describedby="quelle-url-hinweis"
|
||||||
|
placeholder="ldaps://dc01.firma.local:636"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
|
||||||
|
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die
|
||||||
|
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und
|
||||||
|
dann hochgestuft) und <span className="mono">ldap://</span>.
|
||||||
|
</span>
|
||||||
|
{klartext ? (
|
||||||
|
/*
|
||||||
|
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
|
||||||
|
* dasteht, liest nach dem dritten Mal niemand mehr.
|
||||||
|
*/
|
||||||
|
<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" />
|
||||||
|
<span>
|
||||||
|
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das
|
||||||
|
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Anmelde-DN</span>
|
||||||
|
<input
|
||||||
|
name="bindDn"
|
||||||
|
defaultValue={quelle?.bindDn ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
|
||||||
|
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-passwort"
|
||||||
|
name="bindPasswort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="quelle-passwort-hinweis"
|
||||||
|
placeholder={bearbeiten ? "unverändert lassen" : ""}
|
||||||
|
required={!bearbeiten}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
|
||||||
|
{bearbeiten
|
||||||
|
? "Leer lassen behält das gespeicherte Passwort."
|
||||||
|
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Basis-DN</span>
|
||||||
|
<input
|
||||||
|
name="basisDn"
|
||||||
|
defaultValue={quelle?.basisDn ?? ""}
|
||||||
|
required
|
||||||
|
placeholder="OU=Benutzer,DC=firma,DC=local"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor="quelle-filter" className="text-nebentext">
|
||||||
|
Filter (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="quelle-filter"
|
||||||
|
name="filter"
|
||||||
|
defaultValue={quelle?.filter ?? ""}
|
||||||
|
aria-describedby="quelle-filter-hinweis"
|
||||||
|
className={`${FELD} mono`}
|
||||||
|
/>
|
||||||
|
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
|
||||||
|
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer
|
||||||
|
aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
|
||||||
|
</span>
|
||||||
|
</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
|
<input
|
||||||
id="quelle-passwort"
|
type="hidden"
|
||||||
name="bindPasswort"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
aria-describedby="quelle-passwort-hinweis"
|
|
||||||
placeholder={bearbeiten ? "unverändert lassen" : ""}
|
|
||||||
required={!bearbeiten}
|
|
||||||
className={FELD}
|
|
||||||
/>
|
|
||||||
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
|
|
||||||
{bearbeiten
|
|
||||||
? "Leer lassen behält das gespeicherte Passwort."
|
|
||||||
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
|
||||||
<span className="text-nebentext">Basis-DN</span>
|
|
||||||
<input
|
|
||||||
name="basisDn"
|
|
||||||
defaultValue={quelle?.basisDn ?? ""}
|
|
||||||
required
|
|
||||||
placeholder="OU=Benutzer,DC=firma,DC=local"
|
|
||||||
className={`${FELD} mono`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
|
||||||
<label htmlFor="quelle-filter" className="text-nebentext">
|
|
||||||
Filter (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="quelle-filter"
|
|
||||||
name="filter"
|
|
||||||
defaultValue={quelle?.filter ?? ""}
|
|
||||||
aria-describedby="quelle-filter-hinweis"
|
|
||||||
className={`${FELD} mono`}
|
|
||||||
/>
|
|
||||||
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
|
|
||||||
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
|
|
||||||
dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
|
||||||
<span className="text-nebentext">Abgleich alle … Minuten</span>
|
|
||||||
<input
|
|
||||||
name="abgleichIntervallMinuten"
|
name="abgleichIntervallMinuten"
|
||||||
type="number"
|
value={quelle?.abgleichIntervallMinuten ?? 60}
|
||||||
min={INTERVALL_MINDESTENS}
|
|
||||||
max={INTERVALL_HOECHSTENS}
|
|
||||||
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
|
|
||||||
className={FELD}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
|
||||||
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
name="aktiv"
|
||||||
name="aktiv"
|
value="1"
|
||||||
value="1"
|
defaultChecked={quelle?.aktiv ?? false}
|
||||||
defaultChecked={quelle?.aktiv ?? false}
|
className={KAESTCHEN}
|
||||||
className={KAESTCHEN}
|
/>
|
||||||
/>
|
Zustellungen annehmen
|
||||||
Nach Zeitplan abgleichen
|
</label>
|
||||||
</label>
|
<p className="max-w-prose text-xs text-nebentext">
|
||||||
</div>
|
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">
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Abgleich alle … Minuten</span>
|
||||||
|
<input
|
||||||
|
name="abgleichIntervallMinuten"
|
||||||
|
type="number"
|
||||||
|
min={INTERVALL_MINDESTENS}
|
||||||
|
max={INTERVALL_HOECHSTENS}
|
||||||
|
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="aktiv"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={quelle?.aktiv ?? false}
|
||||||
|
className={KAESTCHEN}
|
||||||
|
/>
|
||||||
|
Nach Zeitplan abgleichen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
|
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
|
||||||
@@ -185,75 +345,97 @@ 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).
|
||||||
*/}
|
*/}
|
||||||
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
|
{istScim ? (
|
||||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
<p className="max-w-prose text-xs text-nebentext">
|
||||||
Attributzuordnung anpassen
|
Eine Attributzuordnung gibt es hier nicht: bei SCIM bestimmt der Anbieter die Feldnamen, und sie
|
||||||
</summary>
|
stehen in RFC 7643 fest. Abteilung und Titel kommen aus der Erweiterung{" "}
|
||||||
<p className="mt-2 max-w-prose text-xs text-nebentext">
|
<span className="mono">enterprise:2.0:User</span>; eine OU kennt SCIM nicht, deshalb bleibt{" "}
|
||||||
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
|
<span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
|
||||||
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
) : null}
|
||||||
{ZUORDNUNGS_FELDER.map((feld) => (
|
|
||||||
<label key={feld} className="flex flex-col gap-1.5 text-sm">
|
|
||||||
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
|
|
||||||
<input
|
|
||||||
name={`attr.${feld}`}
|
|
||||||
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
|
||||||
placeholder={AD_ZUORDNUNG[feld]}
|
|
||||||
className={`${FELD} mono`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 max-w-prose text-xs text-nebentext">
|
|
||||||
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
|
|
||||||
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<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">
|
* Bei SCIM gar nicht gerendert und nicht nur verborgen: ein verstecktes
|
||||||
Eigenes CA-Zertifikat hinterlegen
|
* Feld wird mitgeschickt, und dann staende in der Datenbank eine
|
||||||
</summary>
|
* Zuordnung, die nie etwas bedeutet.
|
||||||
{/*
|
*/}
|
||||||
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
|
{istScim ? null : (
|
||||||
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
|
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
|
||||||
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
|
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
* darunter behauptete es auch (Reviewbefund zu #016).
|
Attributzuordnung anpassen
|
||||||
*/}
|
</summary>
|
||||||
{quelle?.caZertifikatVorhanden ? (
|
<p className="mt-2 max-w-prose text-xs text-nebentext">
|
||||||
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
|
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
|
||||||
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
|
||||||
<span>
|
|
||||||
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
|
|
||||||
lassen behält es.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
<label className="mt-2 flex flex-col gap-1.5 text-sm">
|
{ZUORDNUNGS_FELDER.map((feld) => (
|
||||||
<span className="text-nebentext">PEM</span>
|
<label key={feld} className="flex flex-col gap-1.5 text-sm">
|
||||||
<textarea
|
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
|
||||||
name="caZertifikatPem"
|
<input
|
||||||
rows={4}
|
name={`attr.${feld}`}
|
||||||
defaultValue=""
|
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
|
||||||
placeholder="-----BEGIN CERTIFICATE-----"
|
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
|
||||||
className={`${FELD} mono py-2`}
|
className={`${FELD} mono`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-nebentext">
|
</label>
|
||||||
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
|
))}
|
||||||
{quelle?.caZertifikatVorhanden
|
</div>
|
||||||
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
|
<p className="mt-2 max-w-prose text-xs text-nebentext">
|
||||||
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
|
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
|
||||||
</span>
|
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
|
||||||
</label>
|
</p>
|
||||||
{quelle?.caZertifikatVorhanden ? (
|
</details>
|
||||||
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
|
)}
|
||||||
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
|
|
||||||
Hinterlegtes Zertifikat entfernen
|
{/*
|
||||||
|
* 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)}>
|
||||||
|
<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
|
||||||
|
</summary>
|
||||||
|
{/*
|
||||||
|
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
|
||||||
|
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
|
||||||
|
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
|
||||||
|
* darunter behauptete es auch (Reviewbefund zu #016).
|
||||||
|
*/}
|
||||||
|
{quelle?.caZertifikatVorhanden ? (
|
||||||
|
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
|
||||||
|
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
|
||||||
|
lassen behält es.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<label className="mt-2 flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">PEM</span>
|
||||||
|
<textarea
|
||||||
|
name="caZertifikatPem"
|
||||||
|
rows={4}
|
||||||
|
defaultValue=""
|
||||||
|
placeholder="-----BEGIN CERTIFICATE-----"
|
||||||
|
className={`${FELD} mono py-2`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-nebentext">
|
||||||
|
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
|
||||||
|
{quelle?.caZertifikatVorhanden
|
||||||
|
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
|
||||||
|
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
{quelle?.caZertifikatVorhanden ? (
|
||||||
</details>
|
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
|
||||||
|
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
|
||||||
|
Hinterlegtes Zertifikat entfernen
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"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,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016).
|
* Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016).
|
||||||
*
|
*
|
||||||
* Hier startet der Zeitplan des Verzeichnis-Abgleichs. Zwei Wachen davor:
|
* Hier starten die beiden Zeitplaene: der Verzeichnis-Abgleich (#016) und die
|
||||||
|
* Benachrichtigungen (#034). Zwei Wachen davor:
|
||||||
*
|
*
|
||||||
* - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit,
|
* - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit,
|
||||||
* und dort gibt es weder Datenbank noch `node-cron`.
|
* und dort gibt es weder Datenbank noch `node-cron`.
|
||||||
@@ -15,4 +16,11 @@ export async function register(): Promise<void> {
|
|||||||
|
|
||||||
const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan");
|
const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan");
|
||||||
zeitplanStarten();
|
zeitplanStarten();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein eigener Zeitplan und nicht derselbe: der Mailversand darf nicht warten,
|
||||||
|
* weil der Abgleich gerade ein grosses Verzeichnis liest.
|
||||||
|
*/
|
||||||
|
const { meldungsplanStarten } = await import("@/server/zeitplan/meldungs-plan");
|
||||||
|
meldungsplanStarten();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Grenzen und Formen der Anhaenge (#032).
|
||||||
|
*
|
||||||
|
* Framework-frei, weil beide Seiten davon lesen: der Dienst prueft damit, und
|
||||||
|
* die Oberflaeche sagt damit **vorher**, was erlaubt ist. Stuenden die Grenzen
|
||||||
|
* nur im Dienst, waere jede Abweisung eine Ueberraschung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was sich anhaengen laesst: Bilder und PDF.
|
||||||
|
*
|
||||||
|
* Bewusst eine Liste und kein Praefix-Vergleich wie `image/*`: ein Browser
|
||||||
|
* schickt auch `image/svg+xml`, und SVG ist ein Dokument mit Skripten, das ein
|
||||||
|
* Betrachter ausfuehrt. Ein Anhang, den jemand oeffnet, darf kein Angriffsweg
|
||||||
|
* sein.
|
||||||
|
*/
|
||||||
|
export const ERLAUBTE_MEDIENTYPEN = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
|
"application/pdf",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Medientyp = (typeof ERLAUBTE_MEDIENTYPEN)[number];
|
||||||
|
|
||||||
|
/** Die Endung, unter der eine Datei auf der Platte liegt. */
|
||||||
|
export const ENDUNG_JE_TYP: Readonly<Record<Medientyp, string>> = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/heic": "heic",
|
||||||
|
"image/heif": "heif",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function istErlaubterMedientyp(typ: string): typ is Medientyp {
|
||||||
|
return (ERLAUBTE_MEDIENTYPEN as readonly string[]).includes(typ);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bilder werden als Miniatur gezeigt, ein PDF nur als Zeile. */
|
||||||
|
export function istBild(medientyp: string): boolean {
|
||||||
|
return medientyp !== "application/pdf" && istErlaubterMedientyp(medientyp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANDARD_MAX_MB = 10;
|
||||||
|
const STANDARD_JE_LAUF = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoechstgroesse je Datei in Bytes.
|
||||||
|
*
|
||||||
|
* Ein Handyfoto hat heute 3–6 MB. Zehn als Vorgabe lassen Luft, ohne dass eine
|
||||||
|
* einzelne Datei eine Sicherung sprengt. Keine serverseitige Verkleinerung:
|
||||||
|
* sie verliert Metadaten und zieht eine Bildbibliothek ins Projekt — eine
|
||||||
|
* ehrliche Obergrenze ist weniger Magie.
|
||||||
|
*/
|
||||||
|
export function maxGroesseBytes(): number {
|
||||||
|
const roh = Number.parseInt(process.env["ANHANG_MAX_MB"]?.trim() ?? "", 10);
|
||||||
|
const mb = Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_MB;
|
||||||
|
return mb * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hoechstzahl Anhaenge je Lauf. */
|
||||||
|
export function maxJeLauf(): number {
|
||||||
|
const roh = Number.parseInt(process.env["ANHAENGE_JE_LAUF"]?.trim() ?? "", 10);
|
||||||
|
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_JE_LAUF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wo die Dateien liegen. Ohne Angabe ein Verzeichnis im Arbeitsbaum. */
|
||||||
|
export function anhaengePfad(): string {
|
||||||
|
return process.env["ANHAENGE_PFAD"]?.trim() || "./daten/anhaenge";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die eine gewaehlte Datei aus mehreren Formularfeldern desselben Namens.
|
||||||
|
*
|
||||||
|
* Der Schritt hat **zwei** Felder `datei` in einem Formular: „Foto“ mit
|
||||||
|
* `capture="environment"` und „Datei“ mit dem Dateiwaehler. Abgeschickt wird
|
||||||
|
* immer das ganze Formular, also kommen beide Felder mit — das leere als
|
||||||
|
* `File` mit `size === 0`. `FormData.get("datei")` liefert das **erste**, und
|
||||||
|
* das war in #032 das leere: wer „Datei“ nutzte, bekam „Keine Datei gewählt“,
|
||||||
|
* waehrend „Foto“ ging. Gefunden erst in der Klickprobe am gebauten Stand, weil
|
||||||
|
* kein Dienst-Test zwei Felder schickt.
|
||||||
|
*
|
||||||
|
* Darum hier die Regel, und zwar als reine Funktion: das **erste nicht leere**
|
||||||
|
* Feld gewinnt, unabhaengig von der Reihenfolge im Formular.
|
||||||
|
*/
|
||||||
|
export function gewaehlteDatei(eintraege: readonly unknown[]): File | undefined {
|
||||||
|
return eintraege.find((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `4,2 MB` – fuer Meldungen und die Anzeige. */
|
||||||
|
export function groesseText(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Dateiname, der sich anzeigen laesst.
|
||||||
|
*
|
||||||
|
* Der Name vom Browser wird **nie** zum Pfad — der entsteht aus der `id`.
|
||||||
|
* Trotzdem wird er beschnitten und von Steuerzeichen befreit: er landet in einer
|
||||||
|
* Antwortkopfzeile und in der Oberflaeche.
|
||||||
|
*/
|
||||||
|
export function dateinameSaeubern(roh: string): string {
|
||||||
|
const ohnePfad = roh.split(/[\\/]/).pop() ?? "";
|
||||||
|
const sauber = ohnePfad.replace(/[\u0000-\u001f\u007f"]/g, "").trim();
|
||||||
|
return (sauber || "anhang").slice(0, 200);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Die Texte der Benachrichtigungen (#034).
|
||||||
|
*
|
||||||
|
* Framework-frei und ohne Datenbank, weil hier die wichtigste Zusage dieses
|
||||||
|
* Meilensteins sitzt und einzeln pruefbar sein muss:
|
||||||
|
*
|
||||||
|
* **In keine Meldung kommt ein Schrittinhalt.** Kein Wert, keine Notiz, kein
|
||||||
|
* Befehl — und damit auch kein `{{generiertes_passwort}}`. Eine Mail mit dem
|
||||||
|
* Lauf-Inhalt waere genau die Luecke, die dieses Projekt an jeder anderen Stelle
|
||||||
|
* geschlossen hat: das Passwort entsteht bei jeder Anzeige neu und wird nirgends
|
||||||
|
* gespeichert, aber eine Mail liegt fuer immer in einem Postfach, oft auf einem
|
||||||
|
* Server, der nicht im Haus steht.
|
||||||
|
*
|
||||||
|
* Deshalb nimmt jede Funktion hier **nur** Kopfdaten: Titel, Status, Beteiligte,
|
||||||
|
* Frist, Link. Der Typ laesst gar nichts anderes zu — das ist die Absicht. Wer
|
||||||
|
* einen Schrittinhalt in eine Meldung bekommen wollte, muesste diese Datei
|
||||||
|
* aendern, und dann steht er vor diesem Kommentar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Meldeanlass } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die drei Anlaesse, die zu einem Lauf gehoeren.
|
||||||
|
*
|
||||||
|
* `ABGLEICH_FEHLGESCHLAGEN` ist ausgeschlossen, und zwar **im Typ**: er gehoert
|
||||||
|
* zu einer Verzeichnisquelle, nicht zu einem Lauf. Mit einem Wurf zur Laufzeit
|
||||||
|
* waere derselbe Fehler erst im Betrieb aufgefallen.
|
||||||
|
*/
|
||||||
|
export type LaufAnlass = Exclude<Meldeanlass, typeof Meldeanlass.ABGLEICH_FEHLGESCHLAGEN>;
|
||||||
|
|
||||||
|
/** Was eine Lauf-Meldung ueber den Lauf wissen darf. Mehr gibt es nicht. */
|
||||||
|
export type LaufKopf = {
|
||||||
|
readonly titel: string;
|
||||||
|
/** Hostname, Bezeichnung oder Tag-Code — was die Oberflaeche auch zeigt. */
|
||||||
|
readonly geraet?: string | null;
|
||||||
|
readonly person?: string | null;
|
||||||
|
readonly faelligAm?: Date | null;
|
||||||
|
readonly vorlage?: string | null;
|
||||||
|
/** Wer zugewiesen oder abgeschlossen hat. */
|
||||||
|
readonly handelnder?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Meldung = { readonly betreff: string; readonly rumpf: string };
|
||||||
|
|
||||||
|
/** `05.10.2026` – ohne Uhrzeit, weil eine Frist ein Tag ist. */
|
||||||
|
function datum(wert: Date): string {
|
||||||
|
return wert.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Zeilen, die jeden Lauf beschreiben – leere bleiben weg. */
|
||||||
|
function kopfzeilen(kopf: LaufKopf): string[] {
|
||||||
|
const zeilen: string[] = [];
|
||||||
|
if (kopf.vorlage) zeilen.push(`Vorlage: ${kopf.vorlage}`);
|
||||||
|
if (kopf.geraet) zeilen.push(`Gerät: ${kopf.geraet}`);
|
||||||
|
if (kopf.person) zeilen.push(`Person: ${kopf.person}`);
|
||||||
|
if (kopf.faelligAm) zeilen.push(`Fällig: ${datum(kopf.faelligAm)}`);
|
||||||
|
return zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zusammensetzen(absaetze: readonly (string | undefined)[]): string {
|
||||||
|
return absaetze.filter((teil): teil is string => Boolean(teil)).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Meldung zu einem Lauf.
|
||||||
|
*
|
||||||
|
* `link` ist die vollstaendige Adresse aus `APP_URL` — eine Mail mit einem
|
||||||
|
* relativen Pfad ist eine Mail ohne Link.
|
||||||
|
*/
|
||||||
|
export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): Meldung {
|
||||||
|
const zeilen = kopfzeilen(kopf);
|
||||||
|
const daten = zeilen.length > 0 ? zeilen.join("\n") : undefined;
|
||||||
|
|
||||||
|
switch (anlass) {
|
||||||
|
case Meldeanlass.LAUF_ZUGEWIESEN:
|
||||||
|
return {
|
||||||
|
betreff: `Laufzettel: „${kopf.titel}“ ist dir zugewiesen`,
|
||||||
|
rumpf: zusammensetzen([
|
||||||
|
kopf.handelnder
|
||||||
|
? `${kopf.handelnder} hat dir diesen Laufzettel zugewiesen.`
|
||||||
|
: "Dir wurde ein Laufzettel zugewiesen.",
|
||||||
|
daten,
|
||||||
|
link,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
case Meldeanlass.LAUF_UEBERFAELLIG:
|
||||||
|
return {
|
||||||
|
betreff: `Laufzettel überfällig: „${kopf.titel}“`,
|
||||||
|
rumpf: zusammensetzen([
|
||||||
|
kopf.faelligAm
|
||||||
|
? `Dieser Laufzettel war am ${datum(kopf.faelligAm)} fällig und ist noch offen.`
|
||||||
|
: "Dieser Laufzettel ist überfällig und noch offen.",
|
||||||
|
daten,
|
||||||
|
link,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
case Meldeanlass.LAUF_ABGESCHLOSSEN:
|
||||||
|
return {
|
||||||
|
betreff: `Laufzettel abgeschlossen: „${kopf.titel}“`,
|
||||||
|
rumpf: zusammensetzen([
|
||||||
|
kopf.handelnder
|
||||||
|
? `${kopf.handelnder} hat diesen Laufzettel abgeschlossen.`
|
||||||
|
: "Dieser Laufzettel wurde abgeschlossen.",
|
||||||
|
daten,
|
||||||
|
link,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
default: {
|
||||||
|
/*
|
||||||
|
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
|
||||||
|
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
|
||||||
|
* totem Code im Ergebnis.
|
||||||
|
*/
|
||||||
|
const unbekannt: never = anlass;
|
||||||
|
throw new Error(`laufMeldung kennt den Anlass ${String(unbekannt)} nicht.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Meldung zu einem fehlgeschlagenen Verzeichnis-Abgleich. */
|
||||||
|
export function abgleichMeldung(quelle: string, grund: string, link: string): Meldung {
|
||||||
|
return {
|
||||||
|
betreff: `Laufzettel: Verzeichnis-Abgleich „${quelle}“ fehlgeschlagen`,
|
||||||
|
rumpf: zusammensetzen([
|
||||||
|
`Der Abgleich der Verzeichnisquelle „${quelle}“ ist fehlgeschlagen.`,
|
||||||
|
`Grund: ${grund}`,
|
||||||
|
"Bis das behoben ist, bleiben neue und geänderte Konten aus dem Verzeichnis aus. " +
|
||||||
|
"Vorhandene Personen bleiben unverändert — der Abgleich löscht nichts.",
|
||||||
|
link,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf eines Webhooks.
|
||||||
|
*
|
||||||
|
* Dasselbe Prinzip: Kopfdaten und ein Link, keine Schrittinhalte. Als JSON, weil
|
||||||
|
* die Gegenseite ein Programm ist — und mit `anlass` als erstem Feld, damit sich
|
||||||
|
* ohne Auspacken filtern laesst.
|
||||||
|
*/
|
||||||
|
export function webhookRumpf(
|
||||||
|
anlass: Meldeanlass,
|
||||||
|
betreff: string,
|
||||||
|
link: string,
|
||||||
|
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
|
||||||
|
gesendetAm: Date,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
anlass,
|
||||||
|
betreff,
|
||||||
|
link,
|
||||||
|
laufId: bezug.laufId ?? null,
|
||||||
|
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
|
||||||
|
zeitpunkt: gesendetAm.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
+28
-4
@@ -30,15 +30,26 @@ 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", verfuegbar: true },
|
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", recht: "lesen", verfuegbar: true },
|
||||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
|
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", recht: "lesen", verfuegbar: true },
|
||||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
|
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", recht: "lesen", 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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,10 +57,23 @@ 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", verfuegbar: true },
|
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", recht: "lesen", 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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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)",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,4 +25,11 @@ 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
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Signatur eines Webhooks (#034).
|
||||||
|
*
|
||||||
|
* Framework-frei, weil die Gegenseite sie nachrechnen muss — und weil genau das
|
||||||
|
* geprueft werden soll, ohne einen Server zu starten. Das Format ist an das von
|
||||||
|
* Stripe angelehnt, aus einem praktischen Grund: wer schon einmal einen Webhook
|
||||||
|
* verdrahtet hat, kennt es, und `docs/betrieb/` kann ein Beispiel in drei Zeilen
|
||||||
|
* zeigen.
|
||||||
|
*
|
||||||
|
* X-Laufzettel-Signatur: t=1762334400,v1=9f86d0818...
|
||||||
|
*
|
||||||
|
* **Der Zeitstempel ist Teil der signierten Nachricht, nicht Beiwerk.** Stuende
|
||||||
|
* er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu brechen — und
|
||||||
|
* eine abgefangene Nachricht liesse sich in einem Jahr noch einspielen. Signiert
|
||||||
|
* wird deshalb `<zeitstempel>.<rumpf>`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie alt eine Nachricht hoechstens sein darf. Fuenf Minuten, wie bei Stripe. */
|
||||||
|
export const TOLERANZ_SEKUNDEN = 300;
|
||||||
|
|
||||||
|
export const SIGNATUR_KOPF = "X-Laufzettel-Signatur";
|
||||||
|
|
||||||
|
function berechnen(geheimnis: string, zeitstempel: number, rumpf: string): string {
|
||||||
|
return createHmac("sha256", geheimnis).update(`${zeitstempel}.${rumpf}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Den Wert der Kopfzeile bauen. `zeitstempel` in Sekunden. */
|
||||||
|
export function signaturBauen(geheimnis: string, rumpf: string, zeitstempel: number): string {
|
||||||
|
return `t=${zeitstempel},v1=${berechnen(geheimnis, zeitstempel, rumpf)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignaturPruefung =
|
||||||
|
{ readonly gueltig: true } | { readonly gueltig: false; readonly grund: "form" | "abgelaufen" | "falsch" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Gegenprobe – so, wie die Gegenseite sie machen soll.
|
||||||
|
*
|
||||||
|
* Exportiert und geprueft, damit `docs/betrieb/` nicht beschreiben muss, was
|
||||||
|
* niemand nachgerechnet hat.
|
||||||
|
*/
|
||||||
|
export function signaturPruefen(
|
||||||
|
geheimnis: string,
|
||||||
|
rumpf: string,
|
||||||
|
kopf: string,
|
||||||
|
jetztSekunden: number,
|
||||||
|
toleranzSekunden = TOLERANZ_SEKUNDEN,
|
||||||
|
): SignaturPruefung {
|
||||||
|
const teile = new Map<string, string>();
|
||||||
|
for (const stueck of kopf.split(",")) {
|
||||||
|
const trenner = stueck.indexOf("=");
|
||||||
|
if (trenner > 0) teile.set(stueck.slice(0, trenner).trim(), stueck.slice(trenner + 1).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const roherStempel = teile.get("t");
|
||||||
|
const mitgeschickt = teile.get("v1");
|
||||||
|
if (!roherStempel || !mitgeschickt) return { gueltig: false, grund: "form" };
|
||||||
|
|
||||||
|
const zeitstempel = Number.parseInt(roherStempel, 10);
|
||||||
|
if (!Number.isFinite(zeitstempel)) return { gueltig: false, grund: "form" };
|
||||||
|
|
||||||
|
/*
|
||||||
|
* In **beide** Richtungen pruefen. Nur „zu alt“ abzufangen liesse eine
|
||||||
|
* Nachricht mit Zeitstempel im Jahr 2040 fuer immer gueltig sein.
|
||||||
|
*/
|
||||||
|
if (Math.abs(jetztSekunden - zeitstempel) > toleranzSekunden) {
|
||||||
|
return { gueltig: false, grund: "abgelaufen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const erwartet = berechnen(geheimnis, zeitstempel, rumpf);
|
||||||
|
/*
|
||||||
|
* Zeitkonstant vergleichen. Ein `===` auf Zeichenketten bricht beim ersten
|
||||||
|
* Unterschied ab; aus der Dauer liesse sich die richtige Signatur Zeichen fuer
|
||||||
|
* Zeichen erraten. `timingSafeEqual` verlangt gleiche Laenge, deshalb die
|
||||||
|
* Pruefung davor — die Laenge ist kein Geheimnis, sie ist immer 64.
|
||||||
|
*/
|
||||||
|
const a = Buffer.from(erwartet, "utf8");
|
||||||
|
const b = Buffer.from(mitgeschickt, "utf8");
|
||||||
|
if (a.length !== b.length) return { gueltig: false, grund: "falsch" };
|
||||||
|
return timingSafeEqual(a, b) ? { gueltig: true } : { gueltig: false, grund: "falsch" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Server-Sent Events: Format und Grenzen (#033).
|
||||||
|
*
|
||||||
|
* Framework-frei, weil beides geprueft werden muss, ohne eine Verbindung
|
||||||
|
* aufzubauen. Das Format ist winzig und hat genau eine Falle, die einen Strom
|
||||||
|
* still zerstoert — siehe `sseEreignis`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Der Kanal, auf dem die Datenbank Aenderungen meldet. Muss zur Migration passen. */
|
||||||
|
export const KANAL = "lauf_geaendert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Ereignis im SSE-Format.
|
||||||
|
*
|
||||||
|
* **Die Falle:** ein Zeilenumbruch in den Daten beendet das Feld. Steht in der
|
||||||
|
* Nutzlast ein `\n`, zerfaellt die Nachricht, und der Client bekommt Schrott
|
||||||
|
* oder gar nichts. Deshalb wird **jede** Zeile mit eigenem `data:` gesendet —
|
||||||
|
* so schreibt es die Spezifikation vor, und der Client fuegt sie mit `\n`
|
||||||
|
* wieder zusammen.
|
||||||
|
*
|
||||||
|
* Unsere Nutzlast ist JSON aus `JSON.stringify` ohne Einrueckung und hat heute
|
||||||
|
* nie einen Umbruch. „Hat heute nie“ ist aber keine Zusage, sondern ein Zufall:
|
||||||
|
* ein Lauftitel mit Umbruch, ein Notiztext, und der Strom reisst.
|
||||||
|
*/
|
||||||
|
export function sseEreignis(name: string, daten: string): string {
|
||||||
|
const zeilen = daten.split("\n").map((zeile) => `data: ${zeile}`);
|
||||||
|
return `event: ${name}\n${zeilen.join("\n")}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Herzschlag: ein Kommentar, den der Client verwirft.
|
||||||
|
*
|
||||||
|
* Ohne ihn schliesst ein Reverse Proxy die stille Verbindung nach seiner
|
||||||
|
* Leerlaufzeit — nginx nach 60 s, und der Browser sieht nur einen Abbruch.
|
||||||
|
* Ein Kommentar (Zeile mit `:`) kostet zwei Bytes und haelt sie offen.
|
||||||
|
*/
|
||||||
|
export const HERZSCHLAG = ": hz\n\n";
|
||||||
|
|
||||||
|
const STANDARD_HERZSCHLAG_MS = 25_000;
|
||||||
|
const STANDARD_MAX_STROEME = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstand der Herzschlaege.
|
||||||
|
*
|
||||||
|
* 25 s, also unter der Leerlaufzeit von 60 s, die nginx und Caddy voreingestellt
|
||||||
|
* haben. Umstellbar, weil der Test nicht eine Minute warten soll, um zu sehen,
|
||||||
|
* dass ueberhaupt einer kommt.
|
||||||
|
*/
|
||||||
|
export function herzschlagMs(): number {
|
||||||
|
const roh = Number.parseInt(process.env["SSE_HERZSCHLAG_MS"]?.trim() ?? "", 10);
|
||||||
|
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_HERZSCHLAG_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoechstzahl gleichzeitig offener Stroeme je Instanz.
|
||||||
|
*
|
||||||
|
* Ein Strom belegt eine HTTP-Verbindung, solange die Lasche offen ist. Ohne
|
||||||
|
* Grenze belegt ein Haus mit vierzig offenen Laschen vierzig Verbindungen, und
|
||||||
|
* irgendwann nimmt der Server keine gewoehnliche Anfrage mehr an — die Seite
|
||||||
|
* waere dann nicht langsam, sondern tot. Ist die Grenze erreicht, antwortet der
|
||||||
|
* Handler gewoehnlich mit JSON, und der Client fuellt auf Polling zurueck.
|
||||||
|
*/
|
||||||
|
export function maxStroeme(): number {
|
||||||
|
const roh = Number.parseInt(process.env["SSE_MAX_STROEME"]?.trim() ?? "", 10);
|
||||||
|
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_STROEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will der Aufrufer einen Strom?
|
||||||
|
*
|
||||||
|
* `EventSource` schickt `Accept: text/event-stream` von sich aus — das ist der
|
||||||
|
* einzige Unterschied zwischen dem Strom und dem Polling, und deshalb liegen
|
||||||
|
* beide an **derselben** Adresse: eine Rechtepruefung, ein Lesemodell, eine
|
||||||
|
* Wahrheit. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand das
|
||||||
|
* Recht vergessen kann.
|
||||||
|
*/
|
||||||
|
export function willStrom(accept: string | null): boolean {
|
||||||
|
return (accept ?? "").split(",").some((teil) => teil.trim().startsWith("text/event-stream"));
|
||||||
|
}
|
||||||
+95
-3
@@ -60,6 +60,40 @@ 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)[];
|
||||||
|
|
||||||
@@ -84,6 +118,20 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -92,16 +140,60 @@ export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
|
|||||||
* 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): AttributZuordnung {
|
export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): 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()) gelesen[schluessel] = wert.trim();
|
if (typeof wert !== "string" || !wert.trim()) continue;
|
||||||
|
/*
|
||||||
|
* **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.
|
||||||
*
|
*
|
||||||
|
|||||||
+19
-2
@@ -18,13 +18,30 @@ import { SITZUNG_COOKIE } from "@/lib/sitzung";
|
|||||||
* den Proxy zu haengen.
|
* den Proxy zu haengen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Pfade, die ohne Anmeldung erreichbar sein muessen. */
|
/**
|
||||||
const OEFFENTLICH: readonly string[] = [
|
* Pfade, die ohne Anmeldung erreichbar sein muessen.
|
||||||
|
*
|
||||||
|
* **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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { gewaehlteDatei, groesseText, maxGroesseBytes } from "@/lib/anhaenge";
|
||||||
|
import { MandantFehler } from "@/server/mandant";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { LaufFehler } from "@/server/services/laeufe";
|
||||||
|
import { AnhangFehler, anhangEntfernen, anhangSpeichern } from "@/server/services/anhaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions der Anhaenge (#032).
|
||||||
|
*
|
||||||
|
* Recht ist `laeufeBearbeiten` — wer einen Schritt abhaken darf, darf auch ein
|
||||||
|
* Foto dazu legen. Der Archiv-Schreibschutz sitzt im Dienst, an derselben
|
||||||
|
* Stelle wie fuer alles andere am Lauf.
|
||||||
|
*
|
||||||
|
* Die Datei kommt als `File` im Formular. Server Actions nehmen das von sich
|
||||||
|
* aus; ein eigener Upload-Endpunkt waere eine zweite Tuer mit eigener
|
||||||
|
* Rechtepruefung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||||
|
|
||||||
|
const RECHTE_MELDUNG = "Dafür fehlt dir die Berechtigung.";
|
||||||
|
|
||||||
|
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||||
|
if (fehler instanceof AnhangFehler || fehler instanceof LaufFehler) {
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
}
|
||||||
|
if (fehler instanceof MandantFehler) return { fehler: fehler.message };
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
|
||||||
|
console.error(
|
||||||
|
`${neutral}:`,
|
||||||
|
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
|
||||||
|
);
|
||||||
|
return { fehler: neutral };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anhangHochladenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("laeufeBearbeiten");
|
||||||
|
|
||||||
|
const eingabe = z.object({ laufId: z.string().min(1), vorlagenSchrittId: z.string().min(1) }).safeParse({
|
||||||
|
laufId: formular.get("laufId"),
|
||||||
|
vorlagenSchrittId: formular.get("vorlagenSchrittId"),
|
||||||
|
});
|
||||||
|
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
// `getAll`, nicht `get`: das Formular hat zwei Felder `datei` – siehe
|
||||||
|
// `gewaehlteDatei`.
|
||||||
|
const datei = gewaehlteDatei(formular.getAll("datei"));
|
||||||
|
if (!datei) {
|
||||||
|
return { fehler: "Keine Datei gewählt." };
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Die Groesse **vor** dem Lesen pruefen. `arrayBuffer()` zieht die ganze
|
||||||
|
* Datei in den Speicher; bei einer 500-MB-Datei waere die Pruefung danach zu
|
||||||
|
* spaet. Der Dienst prueft es noch einmal — er ist auch ohne diese Action
|
||||||
|
* erreichbar.
|
||||||
|
*/
|
||||||
|
const grenze = maxGroesseBytes();
|
||||||
|
if (datei.size > grenze) {
|
||||||
|
return {
|
||||||
|
fehler:
|
||||||
|
`Die Datei ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
|
||||||
|
`${groesseText(grenze)} je Datei.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await anhangSpeichern(
|
||||||
|
{
|
||||||
|
laufId: eingabe.data.laufId,
|
||||||
|
vorlagenSchrittId: eingabe.data.vorlagenSchrittId,
|
||||||
|
dateiname: datei.name,
|
||||||
|
medientyp: datei.type,
|
||||||
|
inhalt: new Uint8Array(await datei.arrayBuffer()),
|
||||||
|
},
|
||||||
|
{ id: benutzer.id, rolle: benutzer.rolle },
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath(`/laeufe/${eingabe.data.laufId}`);
|
||||||
|
return { erfolg: "Angehängt." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Anhang konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anhangEntfernenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("laeufeBearbeiten");
|
||||||
|
const eingabe = z
|
||||||
|
.object({ anhangId: z.string().min(1), laufId: z.string().min(1) })
|
||||||
|
.safeParse({ anhangId: formular.get("anhangId"), laufId: formular.get("laufId") });
|
||||||
|
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||||
|
|
||||||
|
await anhangEntfernen(eingabe.data.anhangId, { id: benutzer.id, rolle: benutzer.rolle });
|
||||||
|
revalidatePath(`/laeufe/${eingabe.data.laufId}`);
|
||||||
|
return { erfolg: "Entfernt." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Anhang konnte nicht entfernt werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
MeldungsFehler,
|
||||||
|
meldungseinstellungenSpeichern,
|
||||||
|
testmailSenden,
|
||||||
|
} from "@/server/services/benachrichtigungen";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/** Server Actions der Benachrichtigungen (#034). Recht prüfen, Zod, Service rufen. */
|
||||||
|
|
||||||
|
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein leeres Feld heisst „loeschen“, ein fehlendes „unverändert lassen“.
|
||||||
|
*
|
||||||
|
* Beim Passwort ist das der Unterschied, auf den es ankommt: das Formular zeigt
|
||||||
|
* es nie an, also kommt es leer zurueck. Wuerde leer als „loeschen“ gelesen,
|
||||||
|
* loeschte jedes Speichern der uebrigen Felder das Passwort mit.
|
||||||
|
*/
|
||||||
|
const MeldungsSchema = z.object({
|
||||||
|
smtpHost: z.string().trim().optional(),
|
||||||
|
smtpPort: z.string().trim().optional(),
|
||||||
|
smtpTls: z.string().optional(),
|
||||||
|
smtpBenutzer: z.string().trim().optional(),
|
||||||
|
smtpPasswort: z.string().optional(),
|
||||||
|
smtpAbsender: z.string().trim().optional(),
|
||||||
|
webhookUrl: z.string().trim().optional(),
|
||||||
|
webhookGeheimnis: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function meldungenSpeichernAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
const { benutzer } = await rechtErforderlich("verwaltung");
|
||||||
|
|
||||||
|
const eingabe = MeldungsSchema.safeParse({
|
||||||
|
smtpHost: formular.get("smtpHost") ?? undefined,
|
||||||
|
smtpPort: formular.get("smtpPort") ?? undefined,
|
||||||
|
smtpTls: formular.get("smtpTls") ?? undefined,
|
||||||
|
smtpBenutzer: formular.get("smtpBenutzer") ?? undefined,
|
||||||
|
smtpPasswort: formular.get("smtpPasswort") ?? undefined,
|
||||||
|
smtpAbsender: formular.get("smtpAbsender") ?? undefined,
|
||||||
|
webhookUrl: formular.get("webhookUrl") ?? undefined,
|
||||||
|
webhookGeheimnis: formular.get("webhookGeheimnis") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!eingabe.success) {
|
||||||
|
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roherPort = eingabe.data.smtpPort ?? "";
|
||||||
|
let port: number | null = null;
|
||||||
|
if (roherPort !== "") {
|
||||||
|
const gelesen = Number.parseInt(roherPort, 10);
|
||||||
|
if (!Number.isFinite(gelesen)) return { fehler: "Der SMTP-Port muss eine Zahl sein." };
|
||||||
|
port = gelesen;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await meldungseinstellungenSpeichern(
|
||||||
|
{
|
||||||
|
smtpHost: eingabe.data.smtpHost ?? null,
|
||||||
|
smtpPort: port,
|
||||||
|
smtpTls: eingabe.data.smtpTls === "an",
|
||||||
|
smtpBenutzer: eingabe.data.smtpBenutzer ?? null,
|
||||||
|
// Leer = unverändert. Das Formular zeigt ein gesetztes Passwort nie an.
|
||||||
|
...(eingabe.data.smtpPasswort ? { smtpPasswort: eingabe.data.smtpPasswort } : {}),
|
||||||
|
smtpAbsender: eingabe.data.smtpAbsender ?? null,
|
||||||
|
webhookUrl: eingabe.data.webhookUrl ?? null,
|
||||||
|
...(eingabe.data.webhookGeheimnis ? { webhookGeheimnis: eingabe.data.webhookGeheimnis } : {}),
|
||||||
|
},
|
||||||
|
benutzer.id,
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
return { erfolg: "Gespeichert." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testmailAktion(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||||
|
const { benutzer } = await rechtErforderlich("verwaltung");
|
||||||
|
const an = z.string().trim().min(1).safeParse(formular.get("an"));
|
||||||
|
// Ohne Angabe an den Administrator selbst – das ist der häufigste Fall.
|
||||||
|
const ziel = an.success ? an.data : benutzer.email;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testmailSenden(ziel);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
return { erfolg: `Testmail an ${ziel} ist hinausgegangen.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungen je Person an- oder abschalten.
|
||||||
|
*
|
||||||
|
* Recht `verwaltung`, wie alles an einem Konto — es gibt kein eigenes Recht fuer
|
||||||
|
* Benutzer, die Rechtetabelle kennt `verwaltung` fuer ADMIN. **Fuer das eigene
|
||||||
|
* Konto fehlt damit noch die Selbstbedienung**: eine Profilseite gibt es bis
|
||||||
|
* Phase 2 nicht, sie kommt mit der Selbstauskunft (#035). Bis dahin stellt ein
|
||||||
|
* Administrator es um, und das steht so auch in der Anleitung.
|
||||||
|
*/
|
||||||
|
export async function meldungenSchalterAktion(formular: FormData): Promise<void> {
|
||||||
|
const { benutzer } = await rechtErforderlich("verwaltung");
|
||||||
|
/*
|
||||||
|
* Einstellig und ohne Rueckgabe, wie `aktivSetzenAktion` daneben: der Schalter
|
||||||
|
* sitzt in einem gewoehnlichen Formular in der Liste, nicht in einem
|
||||||
|
* `useActionState`. Eine zweistellige Action passt dort nicht hinein — und
|
||||||
|
* eine zweite Form derselben Sache waere Lärm.
|
||||||
|
*/
|
||||||
|
const eingabe = z
|
||||||
|
.object({ benutzerId: z.string().min(1), aktiv: z.enum(["an", "aus"]) })
|
||||||
|
.parse({ benutzerId: formular.get("benutzerId"), aktiv: formular.get("aktiv") });
|
||||||
|
|
||||||
|
const ziel = await prisma.benutzer.findUnique({
|
||||||
|
where: { id: eingabe.benutzerId },
|
||||||
|
select: { id: true, name: true, meldungenAktiv: true },
|
||||||
|
});
|
||||||
|
if (!ziel) return;
|
||||||
|
|
||||||
|
const neu = eingabe.aktiv === "an";
|
||||||
|
if (ziel.meldungenAktiv !== neu) {
|
||||||
|
await prisma.benutzer.update({ where: { id: ziel.id }, data: { meldungenAktiv: neu } });
|
||||||
|
await prisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Benutzer",
|
||||||
|
entitaetId: ziel.id,
|
||||||
|
aktion: neu ? "Benachrichtigungen eingeschaltet" : "Benachrichtigungen abgeschaltet",
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
vorher: { meldungenAktiv: ziel.meldungenAktiv },
|
||||||
|
nachher: { meldungenAktiv: neu },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/benutzer");
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ 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,
|
||||||
@@ -85,8 +86,30 @@ 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()
|
||||||
@@ -99,16 +122,46 @@ const QuelleSchema = 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,
|
||||||
@@ -116,12 +169,9 @@ 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 = QuelleSchema.safeParse({
|
const eingabe = GemeinsamSchema.safeParse({
|
||||||
name: text(formular, "name"),
|
name: text(formular, "name"),
|
||||||
url: text(formular, "url"),
|
art: text(formular, "art") || "LDAP",
|
||||||
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",
|
||||||
@@ -130,19 +180,45 @@ 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 bindPasswort = text(formular, "bindPasswort");
|
const felder = artFelder(eingabe.data.art, formular);
|
||||||
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
|
if ("fehler" in felder) return felder;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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, bindPasswort, attributZuordnung: zuordnung.zuordnung },
|
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung },
|
||||||
zugang.benutzer,
|
zugang.benutzer,
|
||||||
);
|
);
|
||||||
revalidatePath("/admin/verzeichnis");
|
revalidatePath("/admin/verzeichnis");
|
||||||
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
|
return {
|
||||||
|
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.");
|
||||||
}
|
}
|
||||||
@@ -158,12 +234,9 @@ 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 = QuelleSchema.safeParse({
|
const eingabe = GemeinsamSchema.safeParse({
|
||||||
name: text(formular, "name"),
|
name: text(formular, "name"),
|
||||||
url: text(formular, "url"),
|
art: text(formular, "art") || "LDAP",
|
||||||
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,
|
||||||
@@ -174,6 +247,9 @@ 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
|
||||||
@@ -200,11 +276,15 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -235,6 +315,58 @@ 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;
|
||||||
@@ -261,7 +393,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.treffer.length === 0 && ergebnis.gesamtGelesen === 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
|
||||||
? /*
|
? /*
|
||||||
|
|||||||
+134
-5
@@ -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,
|
||||||
rolleAusGruppen,
|
rolleAusGruppenGenau,
|
||||||
rollenZuordnungAktiv,
|
rollenZuordnungAktiv,
|
||||||
type OidcAnspruecke,
|
type OidcAnspruecke,
|
||||||
} from "@/server/auth/oidc";
|
} from "@/server/auth/oidc";
|
||||||
@@ -17,10 +17,23 @@ 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 {
|
||||||
constructor(nachricht: string) {
|
readonly grund: AnmeldeGrund | undefined;
|
||||||
|
|
||||||
|
constructor(nachricht: string, grund?: AnmeldeGrund) {
|
||||||
super(nachricht);
|
super(nachricht);
|
||||||
this.name = "AnmeldeFehler";
|
this.name = "AnmeldeFehler";
|
||||||
|
this.grund = grund;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +115,121 @@ 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.
|
||||||
@@ -133,9 +261,8 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
|||||||
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
|
const zuweisung = await rolleUndPersonBestimmen(anspruecke, vorhanden);
|
||||||
// waere jede Anmeldung eine stille Herabstufung auf LESER.
|
const { rolle, mitarbeiterId } = zuweisung;
|
||||||
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
|
|
||||||
|
|
||||||
const benutzer = vorhanden
|
const benutzer = vorhanden
|
||||||
? await prisma.benutzer.update({
|
? await prisma.benutzer.update({
|
||||||
@@ -145,6 +272,7 @@ 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(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -154,6 +282,7 @@ 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(),
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-3
@@ -1,5 +1,5 @@
|
|||||||
import * as oidc from "openid-client";
|
import * as oidc from "openid-client";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import { Rolle } from "@/generated/prisma/enums";
|
||||||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||||
import { staerksteRolle } from "@/server/auth/rollen";
|
import { staerksteRolle } from "@/server/auth/rollen";
|
||||||
|
|
||||||
@@ -139,6 +139,16 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -199,6 +209,7 @@ 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"] ?? "/",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -223,7 +234,15 @@ export function rollenZuordnungAktiv(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
/**
|
||||||
|
* Die abgebildete Rolle – oder `undefined`, wenn **keine** Gruppe getroffen hat.
|
||||||
|
*
|
||||||
|
* Die Unterscheidung ist der Angelpunkt der Selbstauskunft (#035): „keine
|
||||||
|
* Gruppe getroffen“ und „die Gruppe fuer LESER getroffen“ sahen bis dahin
|
||||||
|
* gleich aus, weil beides `LESER` ergab. Erst wer sie trennt, kann aus dem
|
||||||
|
* ersten Fall eine Selbstauskunft machen, ohne dem zweiten etwas zu nehmen.
|
||||||
|
*/
|
||||||
|
export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefined {
|
||||||
const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
|
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"],
|
||||||
@@ -234,7 +253,11 @@ export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
|||||||
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 staerksteRolle(getroffen);
|
return getroffen.length === 0 ? undefined : staerksteRolle(getroffen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||||
|
return rolleAusGruppenGenau(gruppen) ?? Rolle.LESER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,8 +20,24 @@ import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
|
|||||||
* des Cookies.
|
* 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. */
|
||||||
@@ -57,18 +73,59 @@ 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`. Wer
|
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`.
|
||||||
* sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen
|
*
|
||||||
* und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
|
* **Das gilt weiter fuer eine eingerichtete Zuordnung ohne Treffer in dieser
|
||||||
|
* Funktion.** Ob aus „keine Gruppe getroffen“ eine Selbstauskunft wird, entscheidet
|
||||||
|
* seit #035 der Login ueber `rolleAusGruppenGenau` — hier bleibt der Standard
|
||||||
|
* `LESER`, damit ein Aufrufer ohne diese Unterscheidung nie versehentlich mehr
|
||||||
|
* bekommt als vorher.
|
||||||
*/
|
*/
|
||||||
export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
|
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 (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
if (staerkste === undefined || ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
||||||
staerkste = rolle;
|
staerkste = rolle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return staerkste;
|
return staerkste ?? Rolle.LESER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wo diese Rolle nach der Anmeldung landet (#035).
|
||||||
|
*
|
||||||
|
* **Gefunden beim Durchklicken:** ein `MITARBEITER` meldete sich an und sah
|
||||||
|
* „Dafuer fehlt dir die Berechtigung“ — das Standardziel ist `/`, und die
|
||||||
|
* Uebersicht verlangt `lesen`. Genau die leere Seite, die wie ein Fehler
|
||||||
|
* aussieht und die #035 vermeiden will.
|
||||||
|
*
|
||||||
|
* Eine reine Funktion und nicht ein `if` in der Seite: dieselbe Antwort braucht
|
||||||
|
* die Uebersicht (zum Umleiten), `/kein-zugriff` (fuer seinen Knopf — der auf
|
||||||
|
* `/` zeigte und damit im Kreis) und spaeter jede weitere Rolle mit eigenem
|
||||||
|
* Einstieg.
|
||||||
|
*/
|
||||||
|
export function startseite(rolle: Rolle): string {
|
||||||
|
if (darf(rolle, "lesen")) return "/";
|
||||||
|
if (darf(rolle, "selbstauskunft")) return "/meine-geraete";
|
||||||
|
/*
|
||||||
|
* Kein Recht fuer irgendeinen Einstieg. Gibt es heute nicht — jede Rolle hat
|
||||||
|
* entweder `lesen` oder `selbstauskunft` —, und `/` ist hier die ehrlichere
|
||||||
|
* Antwort als ein Wurf: dort erklaert die Seite, was los ist.
|
||||||
|
*/
|
||||||
|
return "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
|
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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).
|
||||||
@@ -55,6 +56,39 @@ 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`.
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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 (#039): 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Client } from "pg";
|
||||||
|
import { KANAL } from "@/lib/sse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aenderungen an einem Lauf, wie die Datenbank sie meldet (#033).
|
||||||
|
*
|
||||||
|
* Die Trigger aus `20261005090000_lauf_ereignisse` schicken
|
||||||
|
* `NOTIFY lauf_geaendert, '<laufId>'`. Hier haengt **eine** Verbindung je
|
||||||
|
* Prozess mit `LISTEN` daran und verteilt an die offenen Stroeme.
|
||||||
|
*
|
||||||
|
* **Eine Verbindung, nicht eine je Strom.** `LISTEN` belegt eine Verbindung
|
||||||
|
* dauerhaft; eine je Lasche waere bei vierzig Laschen vierzig belegte
|
||||||
|
* Datenbankverbindungen, und PostgreSQL erlaubt voreingestellt hundert
|
||||||
|
* insgesamt. Die Nutzlast sagt, welcher Lauf gemeint ist — das Verteilen
|
||||||
|
* passiert hier im Prozess.
|
||||||
|
*
|
||||||
|
* **Nicht aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurueck und
|
||||||
|
* verteilt sie neu; ein `LISTEN` darauf gilt nach der Rueckgabe fuer eine
|
||||||
|
* fremde Anfrage oder fuer niemanden. Deshalb ein eigener `pg.Client`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Rueckruf = () => void;
|
||||||
|
|
||||||
|
type Register = {
|
||||||
|
readonly abonnenten: Map<string, Set<Rueckruf>>;
|
||||||
|
verbindung: Client | undefined;
|
||||||
|
aufbau: Promise<void> | undefined;
|
||||||
|
geschlossen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An `globalThis`, aus demselben Grund wie der Prisma-Client in `src/lib/db.ts`:
|
||||||
|
* Hot Reload laedt das Modul neu und baute sonst bei jedem Speichern eine
|
||||||
|
* weitere LISTEN-Verbindung auf, ohne die alte zu schliessen.
|
||||||
|
*/
|
||||||
|
const global = globalThis as typeof globalThis & { laufzettelEreignisse?: Register };
|
||||||
|
|
||||||
|
function register(): Register {
|
||||||
|
global.laufzettelEreignisse ??= {
|
||||||
|
abonnenten: new Map(),
|
||||||
|
verbindung: undefined,
|
||||||
|
aufbau: undefined,
|
||||||
|
geschlossen: false,
|
||||||
|
};
|
||||||
|
return global.laufzettelEreignisse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Abonnenten eines Laufs wecken. */
|
||||||
|
function wecken(laufId: string): void {
|
||||||
|
for (const rueckruf of register().abonnenten.get(laufId) ?? []) {
|
||||||
|
try {
|
||||||
|
rueckruf();
|
||||||
|
} catch (fehler) {
|
||||||
|
// Ein kaputter Abonnent darf die anderen nicht mitnehmen.
|
||||||
|
console.error("Abonnent eines Lauf-Ereignisses hat geworfen:", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Abonnenten wecken, egal welcher Lauf. Nach einem Verbindungsabbruch. */
|
||||||
|
function alleWecken(): void {
|
||||||
|
for (const laufId of [...register().abonnenten.keys()]) wecken(laufId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verbindungAufbauen(): Promise<void> {
|
||||||
|
const eigen = register();
|
||||||
|
const url = process.env["DATABASE_URL"];
|
||||||
|
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Erst der Fehlerbehandler, dann verbinden.** Ein `error` auf einem Client
|
||||||
|
* ohne Behandler beendet in Node den ganzen Prozess — und eine Datenbank, die
|
||||||
|
* neu startet, ist der Normalfall, nicht die Ausnahme.
|
||||||
|
*/
|
||||||
|
client.on("error", (fehler) => {
|
||||||
|
console.error("LISTEN-Verbindung verloren:", fehler.message);
|
||||||
|
eigen.verbindung = undefined;
|
||||||
|
void wiederverbinden();
|
||||||
|
});
|
||||||
|
client.on("end", () => {
|
||||||
|
if (eigen.verbindung === client) {
|
||||||
|
eigen.verbindung = undefined;
|
||||||
|
void wiederverbinden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client.on("notification", (nachricht) => {
|
||||||
|
if (nachricht.channel === KANAL && nachricht.payload) wecken(nachricht.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
await client.query(`LISTEN ${KANAL}`);
|
||||||
|
eigen.verbindung = client;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Nach einem Aufbau alle wecken.** Zwischen Abbruch und Wiederverbindung
|
||||||
|
* sind Meldungen verloren gegangen — NOTIFY hat keinen Zwischenspeicher. Wer
|
||||||
|
* jetzt nicht nachfragt, sieht den Haken des Kollegen nie. Lieber ein Abruf
|
||||||
|
* zu viel als ein stiller Stand, der falsch ist.
|
||||||
|
*/
|
||||||
|
alleWecken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach einem Abbruch neu verbinden, mit wachsendem Abstand.
|
||||||
|
*
|
||||||
|
* Ohne Abstand liefe bei einer laenger weg gebliebenen Datenbank eine
|
||||||
|
* Endlosschleife, die nichts tut als Verbindungen zu versuchen.
|
||||||
|
*/
|
||||||
|
async function wiederverbinden(versuch = 1): Promise<void> {
|
||||||
|
const eigen = register();
|
||||||
|
if (eigen.geschlossen || eigen.abonnenten.size === 0) return;
|
||||||
|
|
||||||
|
const wartezeit = Math.min(30_000, 500 * 2 ** (versuch - 1));
|
||||||
|
await new Promise((fertig) => setTimeout(fertig, wartezeit));
|
||||||
|
if (eigen.geschlossen || eigen.abonnenten.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verbindungAufbauen();
|
||||||
|
} catch {
|
||||||
|
await wiederverbinden(versuch + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicherstellen, dass die eine Verbindung steht. Mehrfachaufrufe teilen sich den Aufbau. */
|
||||||
|
async function verbindungSicherstellen(): Promise<void> {
|
||||||
|
const eigen = register();
|
||||||
|
eigen.geschlossen = false;
|
||||||
|
if (eigen.verbindung) return;
|
||||||
|
// Laufender Aufbau: anhaengen statt einen zweiten starten.
|
||||||
|
eigen.aufbau ??= verbindungAufbauen().finally(() => {
|
||||||
|
eigen.aufbau = undefined;
|
||||||
|
});
|
||||||
|
await eigen.aufbau;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auf Aenderungen eines Laufs hoeren.
|
||||||
|
*
|
||||||
|
* Gibt die Funktion zurueck, mit der das Abonnement endet. Sie **muss** gerufen
|
||||||
|
* werden, wenn der Strom schliesst — sonst waechst das Register mit jeder
|
||||||
|
* geschlossenen Lasche weiter, und der Zaehler fuer die Obergrenze erzaehlt
|
||||||
|
* Unsinn.
|
||||||
|
*/
|
||||||
|
export async function aufLaufHoeren(laufId: string, rueckruf: Rueckruf): Promise<() => void> {
|
||||||
|
const eigen = register();
|
||||||
|
const menge = eigen.abonnenten.get(laufId) ?? new Set<Rueckruf>();
|
||||||
|
menge.add(rueckruf);
|
||||||
|
eigen.abonnenten.set(laufId, menge);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verbindungSicherstellen();
|
||||||
|
} catch (fehler) {
|
||||||
|
// Aufraeumen, sonst zaehlt ein nie aufgebauter Strom weiter mit.
|
||||||
|
menge.delete(rueckruf);
|
||||||
|
if (menge.size === 0) eigen.abonnenten.delete(laufId);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
let beendet = false;
|
||||||
|
return () => {
|
||||||
|
if (beendet) return;
|
||||||
|
beendet = true;
|
||||||
|
menge.delete(rueckruf);
|
||||||
|
if (menge.size === 0) eigen.abonnenten.delete(laufId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahl der offenen Stroeme in diesem Prozess.
|
||||||
|
*
|
||||||
|
* Grundlage der Obergrenze. Bewusst die Summe der Abonnenten und nicht die Zahl
|
||||||
|
* der Laeufe: zwei Techniker an einem Lauf sind zwei Verbindungen.
|
||||||
|
*/
|
||||||
|
export function stroemeOffen(): number {
|
||||||
|
let summe = 0;
|
||||||
|
for (const menge of register().abonnenten.values()) summe += menge.size;
|
||||||
|
return summe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Verbindung schliessen und alle Abonnements verwerfen.
|
||||||
|
*
|
||||||
|
* Fuer den geordneten Abbau und fuer Tests, die nicht auf einer offenen
|
||||||
|
* Verbindung sitzen bleiben sollen.
|
||||||
|
*/
|
||||||
|
export async function ereignisseSchliessen(): Promise<void> {
|
||||||
|
const eigen = register();
|
||||||
|
eigen.geschlossen = true;
|
||||||
|
eigen.abonnenten.clear();
|
||||||
|
const client = eigen.verbindung;
|
||||||
|
eigen.verbindung = undefined;
|
||||||
|
if (client) await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -45,7 +45,15 @@ 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;
|
||||||
@@ -62,6 +70,14 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +165,9 @@ 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);
|
||||||
@@ -190,11 +209,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.
|
||||||
*/
|
*/
|
||||||
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
|
export function ersatzName(vorname: string | null, nachname: string | null, kennzeichen: 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 = dn.split(",")[0]?.split("=")[1]?.trim();
|
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim();
|
||||||
return ersterTeil ?? dn;
|
return ersterTeil ?? kennzeichen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,10 +244,16 @@ 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) {
|
||||||
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
if (eintrag.dn) nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||||
}
|
}
|
||||||
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
|
return eintraege.map(({ vorgesetzterDn, vorgesetzterDirekt, ...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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export type BenutzerZeile = {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly rolle: Rolle;
|
readonly rolle: Rolle;
|
||||||
readonly aktiv: boolean;
|
readonly aktiv: boolean;
|
||||||
|
/** Bekommt diese Person Benachrichtigungen (#034)? */
|
||||||
|
readonly meldungenAktiv: boolean;
|
||||||
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
|
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
|
||||||
readonly mitPasswort: boolean;
|
readonly mitPasswort: boolean;
|
||||||
readonly ueberOidc: boolean;
|
readonly ueberOidc: boolean;
|
||||||
@@ -24,6 +26,7 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
|
|||||||
name: true,
|
name: true,
|
||||||
rolle: true,
|
rolle: true,
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
|
meldungenAktiv: true,
|
||||||
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
|
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
|
||||||
// ob ueberhaupt einer da ist.
|
// ob ueberhaupt einer da ist.
|
||||||
passwortHash: true,
|
passwortHash: true,
|
||||||
@@ -38,6 +41,7 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
|
|||||||
name: zeile.name,
|
name: zeile.name,
|
||||||
rolle: zeile.rolle,
|
rolle: zeile.rolle,
|
||||||
aktiv: zeile.aktiv,
|
aktiv: zeile.aktiv,
|
||||||
|
meldungenAktiv: zeile.meldungenAktiv,
|
||||||
mitPasswort: zeile.passwortHash !== null,
|
mitPasswort: zeile.passwortHash !== null,
|
||||||
ueberOidc: zeile.oidcSub !== null,
|
ueberOidc: zeile.oidcSub !== null,
|
||||||
letzteAnmeldungAm: zeile.letzteAnmeldungAm,
|
letzteAnmeldungAm: zeile.letzteAnmeldungAm,
|
||||||
|
|||||||
@@ -1,6 +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 { 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";
|
||||||
@@ -87,6 +88,13 @@ export type DruckSchritt = {
|
|||||||
readonly bearbeitetVon: string | null;
|
readonly bearbeitetVon: string | null;
|
||||||
readonly bearbeitetAm: Date | null;
|
readonly bearbeitetAm: Date | null;
|
||||||
readonly kommentare: readonly KommentarZeile[];
|
readonly kommentare: readonly KommentarZeile[];
|
||||||
|
/** Anhaenge an diesem Schritt (#032). Bilder als Miniatur, PDF als Zeile. */
|
||||||
|
readonly anhaenge: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly dateiname: string;
|
||||||
|
readonly medientyp: string;
|
||||||
|
readonly groesse: number;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DruckAbschnitt = {
|
export type DruckAbschnitt = {
|
||||||
@@ -216,6 +224,9 @@ export async function laufZumDrucken(
|
|||||||
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
|
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
|
||||||
// kann.
|
// kann.
|
||||||
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
||||||
|
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
|
||||||
|
// nicht in sich (#032).
|
||||||
|
const alleAnhaenge = await anhaengeDesLaufs(laufId, benutzer);
|
||||||
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;
|
||||||
@@ -264,6 +275,14 @@ export async function laufZumDrucken(
|
|||||||
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
|
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
|
||||||
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
|
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
|
||||||
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
|
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
|
||||||
|
anhaenge: alleAnhaenge
|
||||||
|
.filter((anhang) => anhang.schrittergebnisId === ergebnis?.id)
|
||||||
|
.map((anhang) => ({
|
||||||
|
id: anhang.id,
|
||||||
|
dateiname: anhang.dateiname,
|
||||||
|
medientyp: anhang.medientyp,
|
||||||
|
groesse: anhang.groesse,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -456,6 +456,16 @@ export type LaufZustand = {
|
|||||||
* (Reviewbefund zu #013).
|
* (Reviewbefund zu #013).
|
||||||
*/
|
*/
|
||||||
readonly anzahlKommentare: number;
|
readonly anzahlKommentare: number;
|
||||||
|
/**
|
||||||
|
* Zahl der Anhaenge (#033).
|
||||||
|
*
|
||||||
|
* Aus demselben Grund wie die Kommentare: ein Foto, das der Kollege gerade an
|
||||||
|
* einen Schritt gehaengt hat, blieb sonst unsichtbar. Seit #032 gibt es
|
||||||
|
* Anhaenge, und seit #033 meldet die Datenbank ihre Aenderung — ein Ereignis
|
||||||
|
* ohne Entsprechung im Zustand loeste aber nichts aus, weil der Client
|
||||||
|
* vergleicht, bevor er neu laedt.
|
||||||
|
*/
|
||||||
|
readonly anzahlAnhaenge: number;
|
||||||
readonly geaendertAm: Date;
|
readonly geaendertAm: Date;
|
||||||
readonly schritte: readonly {
|
readonly schritte: readonly {
|
||||||
readonly schrittId: string;
|
readonly schrittId: string;
|
||||||
@@ -487,7 +497,7 @@ export async function laufZustand(
|
|||||||
select: {
|
select: {
|
||||||
status: true,
|
status: true,
|
||||||
geaendertAm: true,
|
geaendertAm: true,
|
||||||
_count: { select: { kommentare: true } },
|
_count: { select: { kommentare: true, anhaenge: true } },
|
||||||
ergebnisse: {
|
ergebnisse: {
|
||||||
select: {
|
select: {
|
||||||
vorlagenSchrittId: true,
|
vorlagenSchrittId: true,
|
||||||
@@ -504,6 +514,7 @@ export async function laufZustand(
|
|||||||
return {
|
return {
|
||||||
status: lauf.status,
|
status: lauf.status,
|
||||||
anzahlKommentare: lauf._count.kommentare,
|
anzahlKommentare: lauf._count.kommentare,
|
||||||
|
anzahlAnhaenge: lauf._count.anhaenge,
|
||||||
geaendertAm: lauf.geaendertAm,
|
geaendertAm: lauf.geaendertAm,
|
||||||
anzahlPflichtOffen: lauf.ergebnisse.filter(
|
anzahlPflichtOffen: lauf.ergebnisse.filter(
|
||||||
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),
|
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { LaufStatus, type GeraeteTyp } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was ein Mitarbeiter über sich selbst sehen darf (#035).
|
||||||
|
*
|
||||||
|
* **Die wichtigste Entscheidung dieses Meilensteins steht hier im Code und nicht
|
||||||
|
* in der Seite:** eine Selbstauskunft ist **keine Akteneinsicht**. Zurück kommen
|
||||||
|
* Titel, Status, Zeitraum und das Gerät eines Laufs — und sonst nichts.
|
||||||
|
*
|
||||||
|
* Nicht dabei, und zwar ausdrücklich:
|
||||||
|
*
|
||||||
|
* - **Schritte.** In ihnen stehen Befehle mit Platzhaltern für Zugangsdaten.
|
||||||
|
* - **Notizen.** „Gerät kam beschädigt zurück, Nutzer unkooperativ“ ist eine
|
||||||
|
* Arbeitsnotiz über einen Menschen, nicht eine Auskunft an ihn.
|
||||||
|
* - **Kommentare.** Dasselbe, nur länger.
|
||||||
|
* - **Anhänge.** Ein Foto vom Typenschild ist harmlos, eines vom Schreibtisch
|
||||||
|
* nicht — und welches es ist, weiß hier niemand.
|
||||||
|
*
|
||||||
|
* Der Typ `MeinLauf` trägt deshalb nichts davon, und das `select` unten holt es
|
||||||
|
* gar nicht erst. Wer es ändern wollte, müsste beides anfassen und stünde vor
|
||||||
|
* diesem Kommentar.
|
||||||
|
*
|
||||||
|
* **Jede Abfrage schränkt selbst ein** (CLAUDE.md): die `mitarbeiterId` kommt
|
||||||
|
* als Parameter, und ohne sie gibt es keine Abfrage. Eine Seite, die filtert,
|
||||||
|
* wäre eine Seite, die es vergessen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MeinLauf = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly status: LaufStatus;
|
||||||
|
readonly vorlagenName: string;
|
||||||
|
readonly begonnenAm: Date | null;
|
||||||
|
readonly abgeschlossenAm: Date | null;
|
||||||
|
readonly erstelltAm: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeinGeraet = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly bezeichnung: string;
|
||||||
|
readonly typ: GeraeteTyp;
|
||||||
|
readonly tagCode: string;
|
||||||
|
readonly seriennummer: string | null;
|
||||||
|
readonly archiviert: boolean;
|
||||||
|
readonly laeufe: readonly MeinLauf[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nur die Felder, die eine Selbstauskunft tragen darf. */
|
||||||
|
const LAUF_AUSWAHL = {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
status: true,
|
||||||
|
gestartetAm: true,
|
||||||
|
abgeschlossenAm: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type RoherLauf = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly status: LaufStatus;
|
||||||
|
readonly gestartetAm: Date | null;
|
||||||
|
readonly abgeschlossenAm: Date | null;
|
||||||
|
readonly erstelltAm: Date;
|
||||||
|
readonly vorlagenVersion: { readonly vorlage: { readonly name: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
function laufBauen(zeile: RoherLauf): MeinLauf {
|
||||||
|
return {
|
||||||
|
id: zeile.id,
|
||||||
|
titel: zeile.titel,
|
||||||
|
status: zeile.status,
|
||||||
|
vorlagenName: zeile.vorlagenVersion.vorlage.name,
|
||||||
|
begonnenAm: zeile.gestartetAm,
|
||||||
|
abgeschlossenAm: zeile.abgeschlossenAm,
|
||||||
|
erstelltAm: zeile.erstelltAm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Anzeigename eines Geraets, wie ihn auch die Geraeteliste bildet. */
|
||||||
|
function geraetName(geraet: {
|
||||||
|
readonly hostname: string | null;
|
||||||
|
readonly bezeichnung: string | null;
|
||||||
|
readonly tagCode: string;
|
||||||
|
}): string {
|
||||||
|
return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meine Geräte, je Gerät die Läufe chronologisch.
|
||||||
|
*
|
||||||
|
* Archivierte Geräte bleiben dabei: „was ist mit meinem alten Notebook
|
||||||
|
* passiert?“ ist genau die Frage, um die es geht, und das alte Notebook ist
|
||||||
|
* archiviert.
|
||||||
|
*/
|
||||||
|
export async function meineGeraete(mitarbeiterId: string): Promise<readonly MeinGeraet[]> {
|
||||||
|
const zeilen = await prisma.geraet.findMany({
|
||||||
|
where: { mitarbeiterId },
|
||||||
|
orderBy: [{ archiviert: "asc" }, { erstelltAm: "desc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
hostname: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
tagCode: true,
|
||||||
|
typ: true,
|
||||||
|
seriennummer: true,
|
||||||
|
archiviert: true,
|
||||||
|
laeufe: { select: LAUF_AUSWAHL, orderBy: { erstelltAm: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return zeilen.map((zeile) => ({
|
||||||
|
id: zeile.id,
|
||||||
|
bezeichnung: geraetName(zeile),
|
||||||
|
typ: zeile.typ,
|
||||||
|
tagCode: zeile.tagCode,
|
||||||
|
seriennummer: zeile.seriennummer,
|
||||||
|
archiviert: zeile.archiviert,
|
||||||
|
laeufe: zeile.laeufe.map(laufBauen),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Läufe, die an **mir** hängen und nicht an einem Gerät.
|
||||||
|
*
|
||||||
|
* Das Onboarding ist der Fall: es läuft auf die Person, nicht auf ein Notebook.
|
||||||
|
* Läufe mit Gerät stehen schon unter dem Gerät — hier noch einmal wären sie
|
||||||
|
* doppelt.
|
||||||
|
*/
|
||||||
|
export async function meineLaeufeOhneGeraet(mitarbeiterId: string): Promise<readonly MeinLauf[]> {
|
||||||
|
const zeilen = await prisma.lauf.findMany({
|
||||||
|
where: { mitarbeiterId, geraetId: null },
|
||||||
|
orderBy: { erstelltAm: "desc" },
|
||||||
|
select: LAUF_AUSWAHL,
|
||||||
|
});
|
||||||
|
return zeilen.map(laufBauen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeineDaten = {
|
||||||
|
readonly anzeigename: string;
|
||||||
|
readonly abteilung: string | null;
|
||||||
|
readonly titel: string | null;
|
||||||
|
readonly mail: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die eigenen Stammdaten, wie das Verzeichnis sie führt.
|
||||||
|
*
|
||||||
|
* Dabei, weil die häufigste Rückfrage nach einem Abgleich „steht meine Abteilung
|
||||||
|
* richtig drin?“ ist — und weil jemand sehen soll, auf welche Person dieses
|
||||||
|
* Konto zeigt. `undefined`, wenn die Zeile verschwunden ist; die Seite macht
|
||||||
|
* daraus einen Hinweis und keine leere Fläche.
|
||||||
|
*/
|
||||||
|
export async function meineDaten(mitarbeiterId: string): Promise<MeineDaten | undefined> {
|
||||||
|
const zeile = await prisma.mitarbeiter.findUnique({
|
||||||
|
where: { id: mitarbeiterId },
|
||||||
|
select: { anzeigename: true, abteilung: true, titel: true, mail: true },
|
||||||
|
});
|
||||||
|
return zeile ?? undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die letzten Zustellungen – fuer Admin → Zustellungen (#034).
|
||||||
|
*
|
||||||
|
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
|
||||||
|
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
|
||||||
|
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder einzelne Lauf
|
||||||
|
* funktioniert weiter, nur erfaehrt niemand davon.
|
||||||
|
*
|
||||||
|
* Keine Mandantensicht: das Recht `verwaltung` hat nur, wer alles sehen darf,
|
||||||
|
* und eine Zustellung gehoert zum Betrieb der Anwendung, nicht zu einem Kunden.
|
||||||
|
* Der **Rumpf** wird trotzdem nicht ausgeliefert — er traegt Lauftitel und
|
||||||
|
* Geraetenamen, und fuer „laeuft der Versand?“ braucht es ihn nicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ZustellZeile = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly anlass: Meldeanlass;
|
||||||
|
readonly weg: Zustellweg;
|
||||||
|
readonly empfaenger: string;
|
||||||
|
readonly betreff: string;
|
||||||
|
readonly zustand: Zustellzustand;
|
||||||
|
readonly versuche: number;
|
||||||
|
readonly letzterFehler: string | null;
|
||||||
|
readonly naechsterVersuchAm: Date;
|
||||||
|
readonly gesendetAm: Date | null;
|
||||||
|
readonly erstelltAm: Date;
|
||||||
|
readonly laufId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LISTE_HOECHSTENS = 200;
|
||||||
|
|
||||||
|
export type ZustellFilter = {
|
||||||
|
readonly zustand?: Zustellzustand;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function zustellungenHolen(filter: ZustellFilter = {}): Promise<readonly ZustellZeile[]> {
|
||||||
|
const zeilen = await prisma.zustellung.findMany({
|
||||||
|
where: filter.zustand ? { zustand: filter.zustand } : {},
|
||||||
|
orderBy: { erstelltAm: "desc" },
|
||||||
|
take: LISTE_HOECHSTENS,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
anlass: true,
|
||||||
|
weg: true,
|
||||||
|
empfaenger: true,
|
||||||
|
betreff: true,
|
||||||
|
zustand: true,
|
||||||
|
versuche: true,
|
||||||
|
letzterFehler: true,
|
||||||
|
naechsterVersuchAm: true,
|
||||||
|
gesendetAm: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
laufId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZustellZaehler = Readonly<Record<Zustellzustand, number>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele in welchem Zustand.
|
||||||
|
*
|
||||||
|
* Die Zahl der **aufgegebenen** ist die eine, die jemand sehen muss: alles
|
||||||
|
* andere regelt sich von selbst.
|
||||||
|
*/
|
||||||
|
export async function zustellungenZaehlen(): Promise<ZustellZaehler> {
|
||||||
|
const gruppen = await prisma.zustellung.groupBy({ by: ["zustand"], _count: { _all: true } });
|
||||||
|
const leer: Record<Zustellzustand, number> = {
|
||||||
|
[Zustellzustand.OFFEN]: 0,
|
||||||
|
[Zustellzustand.GESENDET]: 0,
|
||||||
|
[Zustellzustand.FEHLGESCHLAGEN]: 0,
|
||||||
|
[Zustellzustand.AUFGEGEBEN]: 0,
|
||||||
|
};
|
||||||
|
for (const gruppe of gruppen) leer[gruppe.zustand] = gruppe._count._all;
|
||||||
|
return leer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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 (#039).
|
||||||
|
*/
|
||||||
|
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") };
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
-179
@@ -1,15 +1,15 @@
|
|||||||
import { hostname } from "node:os";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
|
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
|
||||||
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
|
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
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 { eintraegeStroemen } from "@/server/ldap/leser";
|
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
|
||||||
import { fehlerMeldung } from "@/server/ldap/verbindung";
|
import { fehlerMeldung } from "@/server/ldap/verbindung";
|
||||||
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
|
import { dnSchluessel, type RoherEintrag } 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 { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Abgleich (#016).
|
* Der Abgleich (#016).
|
||||||
@@ -81,55 +81,6 @@ function sperrSchluessel(quelleId: string): string {
|
|||||||
return `abgleich:${quelleId}`;
|
return `abgleich:${quelleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
|
|
||||||
*
|
|
||||||
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Abgleiche im
|
|
||||||
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
|
|
||||||
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
|
|
||||||
*/
|
|
||||||
function haltername(): string {
|
|
||||||
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
|
|
||||||
*
|
|
||||||
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
|
|
||||||
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Abgleich, den
|
|
||||||
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
|
|
||||||
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
|
|
||||||
* dauerhaft blockiert.
|
|
||||||
*/
|
|
||||||
export async function mitAbgleichSperre<T>(
|
|
||||||
schluessel: string,
|
|
||||||
dauerMinuten: number,
|
|
||||||
was: () => Promise<T>,
|
|
||||||
): Promise<T | undefined> {
|
|
||||||
const halter = haltername();
|
|
||||||
const bekommen = await prisma.$executeRaw`
|
|
||||||
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
|
|
||||||
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
|
|
||||||
ON CONFLICT ("schluessel") DO UPDATE
|
|
||||||
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
|
|
||||||
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
|
|
||||||
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
|
|
||||||
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
|
|
||||||
`;
|
|
||||||
if (bekommen === 0) return undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await was();
|
|
||||||
} finally {
|
|
||||||
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
|
|
||||||
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
|
|
||||||
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
|
* Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
|
||||||
*
|
*
|
||||||
@@ -154,11 +105,19 @@ 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
|
||||||
@@ -196,7 +155,16 @@ 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;
|
||||||
readonly vorgesetzterDn: string;
|
/** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
@@ -218,9 +186,7 @@ export async function abgleichAusfuehren(
|
|||||||
}
|
}
|
||||||
const quelle = await quelleZeileHolen(quelleId);
|
const quelle = await quelleZeileHolen(quelleId);
|
||||||
|
|
||||||
const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () =>
|
const ergebnis = await mitSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () => lauf(quelle, benutzer));
|
||||||
lauf(quelle, benutzer),
|
|
||||||
);
|
|
||||||
if (ergebnis === undefined) {
|
if (ergebnis === undefined) {
|
||||||
throw new AbgleichLaeuftBereitsFehler(
|
throw new AbgleichLaeuftBereitsFehler(
|
||||||
`Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
|
`Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
|
||||||
@@ -258,144 +224,147 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
|
|||||||
let bindPasswort: string | undefined;
|
let bindPasswort: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zugang = zugangAus(quelle);
|
/*
|
||||||
bindPasswort = zugang.bindPasswort;
|
* Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem
|
||||||
|
* 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 eintraegeStroemen(
|
const bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => {
|
||||||
zugang,
|
verarbeitet += 1;
|
||||||
{
|
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der
|
||||||
basisDn: quelle.basisDn,
|
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
|
||||||
filter: quelle.filter,
|
if (eintrag.dn) kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
let vorhanden = await prisma.mitarbeiter.findFirst({
|
||||||
},
|
where: { quelleId, externeId: eintrag.externeId },
|
||||||
async (eintrag) => {
|
});
|
||||||
verarbeitet += 1;
|
/*
|
||||||
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
|
||||||
let vorhanden = await prisma.mitarbeiter.findFirst({
|
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
|
||||||
where: { quelleId, externeId: eintrag.externeId },
|
* AD-Konto entsteht danach, und der naechste Abgleich findet sie.
|
||||||
});
|
*
|
||||||
/*
|
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
|
||||||
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
|
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
|
||||||
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
|
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
|
||||||
* AD-Konto entsteht danach, und der naechste Abgleich findet sie.
|
*/
|
||||||
*
|
let uebernommenJetzt = false;
|
||||||
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
|
if (!vorhanden) {
|
||||||
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
|
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
|
||||||
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
|
if (vonHand) {
|
||||||
*/
|
vorhanden = vonHand;
|
||||||
let uebernommenJetzt = false;
|
uebernommenJetzt = true;
|
||||||
if (!vorhanden) {
|
uebernommen += 1;
|
||||||
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
|
|
||||||
if (vonHand) {
|
|
||||||
vorhanden = vonHand;
|
|
||||||
uebernommenJetzt = true;
|
|
||||||
uebernommen += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const felder = {
|
const felder = {
|
||||||
vorname: eintrag.vorname,
|
vorname: eintrag.vorname,
|
||||||
nachname: eintrag.nachname,
|
nachname: eintrag.nachname,
|
||||||
anzeigename: eintrag.anzeigename,
|
anzeigename: eintrag.anzeigename,
|
||||||
mail: eintrag.mail,
|
mail: eintrag.mail,
|
||||||
upn: eintrag.upn,
|
upn: eintrag.upn,
|
||||||
sam: eintrag.sam,
|
sam: eintrag.sam,
|
||||||
dn: eintrag.dn,
|
dn: eintrag.dn,
|
||||||
abteilung: eintrag.abteilung,
|
abteilung: eintrag.abteilung,
|
||||||
titel: eintrag.titel,
|
titel: eintrag.titel,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!vorhanden) {
|
if (!vorhanden) {
|
||||||
await prisma.mitarbeiter.create({
|
await prisma.mitarbeiter.create({
|
||||||
data: {
|
|
||||||
kundeId: quelle.kundeId,
|
|
||||||
quelleId,
|
|
||||||
externeId: eintrag.externeId,
|
|
||||||
...felder,
|
|
||||||
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
|
||||||
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
|
||||||
vorgesetzterExterneId: null,
|
|
||||||
vorgesetzterId: null,
|
|
||||||
aktiv: true,
|
|
||||||
herkunft: Herkunft.ABGLEICH,
|
|
||||||
zuletztGesehenAm: beginn,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
angelegt += 1;
|
|
||||||
if (eintrag.vorgesetzterDn) {
|
|
||||||
offene.push({
|
|
||||||
externeId: eintrag.externeId,
|
|
||||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
|
||||||
vorherigeKennung: null,
|
|
||||||
neuAngelegt: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
|
||||||
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
|
||||||
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
|
|
||||||
if (
|
|
||||||
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
|
|
||||||
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
|
|
||||||
!uebernommenJetzt &&
|
|
||||||
(vorhanden.vorname !== felder.vorname ||
|
|
||||||
vorhanden.nachname !== felder.nachname ||
|
|
||||||
vorhanden.anzeigename !== felder.anzeigename ||
|
|
||||||
vorhanden.mail !== felder.mail ||
|
|
||||||
vorhanden.upn !== felder.upn ||
|
|
||||||
vorhanden.sam !== felder.sam ||
|
|
||||||
vorhanden.dn !== felder.dn ||
|
|
||||||
vorhanden.abteilung !== felder.abteilung ||
|
|
||||||
vorhanden.titel !== felder.titel ||
|
|
||||||
!vorhanden.aktiv ||
|
|
||||||
vorhanden.herkunft !== Herkunft.ABGLEICH ||
|
|
||||||
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
|
|
||||||
) {
|
|
||||||
geaendert.add(eintrag.externeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.mitarbeiter.update({
|
|
||||||
where: { id: vorhanden.id },
|
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: quelle.kundeId,
|
||||||
|
quelleId,
|
||||||
|
externeId: eintrag.externeId,
|
||||||
...felder,
|
...felder,
|
||||||
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
|
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
||||||
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
||||||
// Kennung.
|
vorgesetzterExterneId: null,
|
||||||
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
vorgesetzterId: null,
|
||||||
/*
|
|
||||||
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
|
|
||||||
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
|
|
||||||
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
|
|
||||||
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
|
|
||||||
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
|
|
||||||
*/
|
|
||||||
...(vorgesetztenLeeren
|
|
||||||
? {
|
|
||||||
vorgesetzterExterneId: null,
|
|
||||||
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
|
||||||
// wiedereingestellte Person behaelt so ihre Laeufe.
|
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
herkunft: Herkunft.ABGLEICH,
|
herkunft: Herkunft.ABGLEICH,
|
||||||
zuletztGesehenAm: beginn,
|
zuletztGesehenAm: beginn,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!vorgesetztenLeeren) {
|
angelegt += 1;
|
||||||
|
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) {
|
||||||
offene.push({
|
offene.push({
|
||||||
externeId: eintrag.externeId,
|
externeId: eintrag.externeId,
|
||||||
vorgesetzterDn: eintrag.vorgesetzterDn as string,
|
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||||
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||||
neuAngelegt: false,
|
vorherigeKennung: null,
|
||||||
|
neuAngelegt: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
||||||
|
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
||||||
|
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen.
|
||||||
|
const vorgesetztenLeeren =
|
||||||
|
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === null;
|
||||||
|
if (
|
||||||
|
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
|
||||||
|
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
|
||||||
|
!uebernommenJetzt &&
|
||||||
|
(vorhanden.vorname !== felder.vorname ||
|
||||||
|
vorhanden.nachname !== felder.nachname ||
|
||||||
|
vorhanden.anzeigename !== felder.anzeigename ||
|
||||||
|
vorhanden.mail !== felder.mail ||
|
||||||
|
vorhanden.upn !== felder.upn ||
|
||||||
|
vorhanden.sam !== felder.sam ||
|
||||||
|
vorhanden.dn !== felder.dn ||
|
||||||
|
vorhanden.abteilung !== felder.abteilung ||
|
||||||
|
vorhanden.titel !== felder.titel ||
|
||||||
|
!vorhanden.aktiv ||
|
||||||
|
vorhanden.herkunft !== Herkunft.ABGLEICH ||
|
||||||
|
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
|
||||||
|
) {
|
||||||
|
geaendert.add(eintrag.externeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.mitarbeiter.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: {
|
||||||
|
...felder,
|
||||||
|
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
|
||||||
|
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
||||||
|
// Kennung.
|
||||||
|
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
||||||
|
/*
|
||||||
|
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
|
||||||
|
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
|
||||||
|
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
|
||||||
|
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
|
||||||
|
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
|
||||||
|
*/
|
||||||
|
...(vorgesetztenLeeren
|
||||||
|
? {
|
||||||
|
vorgesetzterExterneId: null,
|
||||||
|
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
||||||
|
// wiedereingestellte Person behaelt so ihre Laeufe.
|
||||||
|
aktiv: true,
|
||||||
|
herkunft: Herkunft.ABGLEICH,
|
||||||
|
zuletztGesehenAm: beginn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!vorgesetztenLeeren) {
|
||||||
|
offene.push({
|
||||||
|
externeId: eintrag.externeId,
|
||||||
|
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||||
|
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
|
||||||
|
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
||||||
|
neuAngelegt: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
gelesen = bilanz.gesamtGelesen;
|
gelesen = bilanz.gesamtGelesen;
|
||||||
|
|
||||||
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
||||||
@@ -405,7 +374,14 @@ 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
|
||||||
@@ -599,4 +575,22 @@ async function abschlussSchreiben(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein fehlgeschlagener Abgleich ist der eine Fall, von dem niemand erfaehrt,
|
||||||
|
* solange er nicht nachsieht (#034). Eingereiht wird **nach** der Transaktion
|
||||||
|
* und mit abgefangenem Fehler: der Abgleich selbst ist fertig, und eine
|
||||||
|
* Benachrichtigung, die nicht eingereiht werden kann, darf sein Ergebnis nicht
|
||||||
|
* verwerfen.
|
||||||
|
*/
|
||||||
|
if (ergebnis.status !== "ERFOLG") {
|
||||||
|
try {
|
||||||
|
await abgleichMeldungEinreihen(quelleId, ergebnis.fehler ?? "ohne Angabe");
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(
|
||||||
|
`Benachrichtigung zum Abgleich der Quelle ${quelleId} konnte nicht eingereiht werden:`,
|
||||||
|
fehler instanceof Error ? fehler.message : fehler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user