Florian WildeandClaude Opus 5 f3950847f3
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Uebergabeprotokoll als PDF (#041)
Neben "Drucken" steht am Lauf "Als PDF", im Archiv an jedem Treffer "Protokoll
als PDF" — dasselbe Blatt als fertige Datei, zum Verschicken oder Abheften.
Unten darauf jetzt zwei Unterschriftsfelder mit Zeile fuer Ort, Datum und Namen;
wo eine Vier-Augen-Freigabe erteilt wurde, steht sie mit im Kopf.

Erzeugt wird es, indem der Server seine eigene Druckansicht ueber 127.0.0.1
laedt und druckt — mit dem Sitzungscookie des Anfragenden. Damit gibt es genau
eine Vorlage fuer Papier und Datei, und fuer das PDF gilt dieselbe Rechte- und
Mandantenpruefung wie fuer die Seite. Ein zweites Layout waere billiger und
wuerde frueher oder spaeter abweichen, ohne dass es jemandem auffaellt.

Ein eigener Browser-Kontext je PDF, kein geteilter: er traegt die fremde
Sitzung, und ein geteilter gaebe sie an das naechste PDF weiter. Gespeichert
wird nichts — ein abgelegtes PDF waere eine zweite Wahrheit neben dem Lauf.
Ein erzeugtes Passwort steht auch hier nie darauf, geprueft am extrahierten
Text des fertigen PDF und nicht an den Bytes: der Inhalt ist komprimiert, dort
nichts zu finden bewiese nichts.

Chromium bleibt optional, und die gemessene Zahl ist der Grund: das
Laufzeitbild waechst von 370 MB auf 1,52 GB, Alpines chromium kostet allein
1,13 GB. Er kommt nur mit `--build-arg MIT_PDF=1` beziehungsweise
`deploy/install.sh --mit-pdf` mit. Fehlt er, erscheint der Knopf gar nicht
erst — ein Knopf, der zuverlaessig in eine Fehlermeldung fuehrt, ist schlimmer
als keiner. Ein direkter Aufruf bekommt 503 mit einer Meldung, die sagt, was
fehlt und wo es steht; ein leeres PDF gibt es in keinem Fall.

Meine Schaetzung lag um das Dreieinhalbfache daneben: "+328 MB" war das
.apk-Paket, nicht das Bild, und stand schon im Dockerfile-Kommentar, bevor ich
gebaut hatte. Jetzt steht die gemessene Zahl an drei Stellen.

1545 Vitest-Tests (vorher 1526): 15 in tests/pdf.test.ts, die den Handler echt
fahren, 3 in tests/pdf-grenze.test.ts fuer die Obergrenze mit gestelltem
Browser, einer in tests/archiv.test.ts. Dazu drei E2E-Ablaeufe; volle Suite
gruen mit 118 bestanden. Ein Blatt wurde wirklich erzeugt und angesehen.

Sechs Gegenproben, alle angeschlagen — darunter ein echtes Passwort auf dem
Blatt und die Freigabe des Zaehlers aus dem finally genommen, der Fall, in dem
ein Fehlschlag die PDF-Erzeugung bis zum Neustart leise stilllegt.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 11:14:25 +01:00

laufzettel

Checklisten für wiederkehrende IT-Arbeiten. Versionierte Vorlagen, daraus gestartete Läufe, im 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.

Stand: v0.1.0 ist veröffentlicht, Phase 2 liegt auf main und wird v0.2.0. Der Fortschritt steht in docs/issues/README.md, Entscheidungen im Entwicklungstagebuch.

Zwei Betriebsmodi

Modus Für wen Kunden-Ebene Verzeichnis
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

Der Modus liegt in der Datenbank und ist vom Admin ohne Neustart umschaltbar. Jede kundenbezogene Zeile trägt immer eine Kunden-Zuordnung, im Modus INTERN die eigene Organisation — deshalb ist der Wechsel eine Datenänderung und keine Migration.

Voraussetzungen

  • Node 24 (.nvmrc) und pnpm 12 über corepack, oder nur Docker mit Compose-Plugin
  • PostgreSQL 17 (im Compose-Stand enthalten)

Entwicklung

corepack enable pnpm
pnpm install
cp .env.example .env          # APP_SECRET und Passwörter ausfüllen
pnpm db:up                    # PostgreSQL auf 127.0.0.1:5434
pnpm db:migrate
pnpm db:seed
pnpm dev                      # http://localhost:3000

APP_SECRET erzeugen:

openssl rand -base64 48

Qualitätsschranken

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

Die beiden Container sind keine Zierde: ohne ldap überspringt die Suite den ganzen 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. Ende-zu-Ende-Tests: pnpm test:e2e (verlangt einen gebauten, laufenden Stand — siehe docs/anleitung/).

Betrieb mit Docker Compose

cp .env.example .env          # APP_URL, APP_SECRET, POSTGRES_PASSWORD sind Pflicht
docker compose up -d --build

Die Migrationen laufen beim Start automatisch in einem eigenen Einmal-Dienst. Der Seed läuft absichtlich nicht automatisch — ihn startest du einmal von Hand:

docker compose run --rm migrate pnpm db:seed

Was der Seed anlegt

Inhalt
Grundbestand Betriebsmodus, eigene Organisation, Administrator aus SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD
Vorlagen die sieben Abläufe des Hauses als veröffentlichte Version 1 — PC Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test
Beispielbestand 3 Abteilungen, 5 Personen, PC-001 und NB-001 mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp
Verzeichnis eine deaktivierte Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten

Mit SEED_MODE=DIENSTLEISTUNG (Alias SEED_MODE=SERVICE) kommen zwei Demo-Kunden dazu, „Musterfirma GmbH" (MUS) und „Bäckerei Reiter" (REI), mit je einer Abteilung, einer Person und einem Gerät nach der Hostname-Regel (MUS-PC-001).

Der Seed richtet ein, er räumt nicht auf. Was schon da ist, bleibt: eine bearbeitete Vorlage, eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen jeden weiteren Lauf. Zweimal hintereinander aufgerufen ändert der zweite Lauf nichts.

Die sieben Vorlagen liegen als JSON im Austauschformat unter prisma/seed-daten/vorlagen/. Wer eine anpassen will, ändert die Datei — oder besser: die Vorlage in der Oberfläche, denn eine veröffentlichte Version wird nie verändert, und der Seed fasst eine vorhandene Vorlage nicht mehr an.

Die Datenbank ist von außen nicht erreichbar

Das ist bewusst so gebaut und in tests/betriebsstand.test.ts festgehalten:

  • postgres hat kein ports:-Mapping, nur expose
  • postgres hängt ausschließlich im Netz intern, das internal: true ist — ein Netz ohne Gateway, also weder vom Host noch aus dem LAN erreichbar
  • nur web hängt zusätzlich im Netz extern (Reverse Proxy, OIDC, LDAP)
  • web bindet standardmäßig auf 127.0.0.1:3000; davor gehört ein Reverse Proxy

Prüfen:

nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht"
docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar"

Für die Entwicklung gibt es docker-compose.dev.yml, das PostgreSQL auf 5434 öffnet — umstellbar über DEV_DB_PORT. 5432 ist oft lokal belegt und 5433 von einem anderen Projekt, daher dieser Port. Diese Datei ist nicht für den Betrieb gedacht.

Reverse Proxy

Die App läuft hinter einem Reverse Proxy und hat keine fest verdrahteten Adressen. Maßgeblich ist APP_URL — sie steckt in den QR-Codes und in der OIDC-Rückruf-Adresse. Der Proxy muss X-Forwarded-Proto und X-Forwarded-Host setzen.

Sicherung

Der geschlossene Port ändert nichts an der Sicherung, sie läuft über den Dienstnamen:

docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz

Zurückspielen:

gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel

Gesichert werden müssen zwei Dinge: die Datenbank (das Volume heißt laufzettel_datenbank; die App selbst hält keinen veränderlichen Zustand auf der Platte) und die Geheimnisse aus der .env. Ohne das alte APP_SECRET sind nach dem Zurückspielen alle gespeicherten LDAP-Bind-Passwörter unlesbar — und das merkt man erst beim nächsten Abgleich. Einzelheiten in docs/betrieb/sicherung.md.

Anmeldung

Zwei Wege, beide gleichzeitig möglich:

  • OIDC (Ziel: authentik) für den normalen Betrieb
  • lokal mit E-Mail und Passwort, nur wenn AUTH_LOCAL_ENABLED=true — für die Entwicklung und als Notfallzugang, wenn der Identitätsanbieter ausfällt

Die Rolle kommt bei jedem OIDC-Login aus dem groups-Claim und wird über OIDC_GROUP_ADMIN, OIDC_GROUP_EDITOR, OIDC_GROUP_TECHNICIAN und OIDC_GROUP_VIEWER zugeordnet. Unbekannte Benutzer werden beim ersten Login angelegt.

Rolle Darf
ADMIN alles, inkl. Benutzer, Verzeichnis, Abteilungen, Archiv-Verwaltung
REDAKTEUR Vorlagen anlegen, bearbeiten, veröffentlichen; alles was TECHNIKER darf
TECHNIKER Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen
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.

Die Anbindung an authentik ist in docs/betrieb/authentik.md Schritt für Schritt beschrieben.

Verzeichnis (nur Modus INTERN)

Unter Admin → Verzeichnis wird eine Quelle angelegt. Es gibt drei Arten, und sie unterscheiden sich in der Richtung:

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, ohne etwas zu speichern. Erst daran sieht man, ob die Zuordnung stimmt.
  • Die Attributzuordnung ist einklappbar im Formular und trägt die AD-Vorgaben; ein OpenLDAP bekommt hier etwa entryUUID als Kennung.
  • Der Zeitplan läuft im selben Prozess wie die Anwendung und sieht jede Minute nach, welche Quelle fällig ist; das Intervall steht je Quelle. ZEITPLAN_AKTIV=false schaltet ihn ab — für Einmal-Container, die nur Migrationen fahren.
  • Gelöscht wird nie. Wer im Verzeichnis verschwindet oder deaktiviert wird, wird hier inaktiv. Lässt sich kein einziger Eintrag zuordnen, deaktiviert der Abgleich niemanden und meldet stattdessen einen Fehler — ein falsch gesetztes Kennungsattribut soll nicht die halbe Firma abschalten.
  • Das Bind-Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; ein leeres Feld beim Bearbeiten heißt „unverändert“.

Ohne Verzeichnis geht es auch: Personen lassen sich von Hand anlegen oder aus einer CSV-Datei einlesen.

Umgebungsvariablen

Jede Variable steht mit Erklärung in .env.example — hier die Übersicht. Pflicht sind APP_URL, APP_SECRET, POSTGRES_PASSWORD und DATABASE_URL; fehlt eine davon, bricht der Compose-Stand ab, statt mit Standardwerten zu starten.

Variable Bedeutung
APP_URL Die öffentliche Adresse, unter der die App erreichbar ist. Steht sie hinter einem Reverse Proxy, ist es dessen Adresse — nicht die interne. Aus ihr entstehen die QR-Adressen auf den Aufklebern und die OIDC-Rücksprungadresse.
APP_SECRET Schlüssel für Sitzungs-Cookies und die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit openssl rand -base64 48. Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.
DATABASE_URL Verbindung zur PostgreSQL-Datenbank.
POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB Zugangsdaten des Datenbank-Containers im Compose-Stand.
AUTH_LOCAL_ENABLED true schaltet die lokale Anmeldung mit E-Mail und Passwort frei — Notfallzugang und Entwicklung. Auf false verschwindet das Formular und der Endpunkt lehnt ab.
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET Der Identitätsanbieter. Discovery über <issuer>/.well-known/openid-configuration.
OIDC_NAME Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“.
OIDC_SCOPE Angefragte Scopes; muss einen enthalten, der groups liefert.
OIDC_GROUP_ADMIN, OIDC_GROUP_EDITOR, OIDC_GROUP_TECHNICIAN, OIDC_GROUP_VIEWER Welche Gruppe welche Rolle bekommt. Die Rolle wird bei jeder Anmeldung neu gesetzt.
OIDC_CUSTOMER_GROUP_PREFIX Nur im Modus DIENSTLEISTUNG: Gruppen mit diesem Präfix weisen einen Benutzer einem Kunden zu.
BIND_ADDR, APP_PORT Wohin der Compose-Stand die App bindet. Standard 127.0.0.1:3000 — davor gehört ein Reverse Proxy.
APP_VERSION, GIT_SHA Erscheinen unten links in der Oberfläche. Im Compose-Stand Build-Argumente.
SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD Der Administrator, den pnpm db:seed anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn.
SEED_MODE INTERN (Standard) oder DIENSTLEISTUNG — der Alias SERVICE tut dasselbe. Betrifft nur den ersten Seed-Lauf; danach steht der Modus in der Datenbank.
SEED_ORG_NAME, SEED_ORG_KUERZEL Name und Kürzel der eigenen Organisation. Das Kürzel steht auf Etiketten und in der Hostname-Regel.
ZEITPLAN_AKTIV false schaltet den Verzeichnis-Abgleich nach Zeitplan ab.
TEST_DATABASE_URL Datenbank für die Tests. Ohne sie überspringen sich alle Datenbanktests.

Dokumentation

Lizenz

Noch nicht festgelegt.

S
Description
No description provided
Readme
28 MiB
2026-10-02 22:17:05 +00:00
Languages
TypeScript 96.1%
Shell 2.4%
PLpgSQL 0.8%
JavaScript 0.4%
CSS 0.2%