Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und `konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die anderen neun bei jedem Onboarding weg. Entscheidungen, alle im Issue begruendet: - Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der Arbeit. Ein struktureller Test prueft, dass alle drei Schichten (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen. - Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und korrigierbar, ein fehlender faellt niemandem auf. - OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein bedingter Abschnitt von Anfang an sichtbar. - Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder. - Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben eine Struktur neu und geben neue Kennungen. Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein: - Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage verlor ihre Abteilungsabschnitte und galt dort fuer alle. - Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp, der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie und Original Feld fuer Feld. Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen. 1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen. Closes #39 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.0ist veröffentlicht, Phase 2 liegt aufmainund wirdv0.2.0. Der Fortschritt steht indocs/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:
postgreshat keinports:-Mapping, nurexposepostgreshängt ausschließlich im Netzintern, dasinternal: trueist — ein Netz ohne Gateway, also weder vom Host noch aus dem LAN erreichbar- nur
webhängt zusätzlich im Netzextern(Reverse Proxy, OIDC, LDAP) webbindet standardmäßig auf127.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
entryUUIDals 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=falseschaltet 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
- Anleitung für Technik und Verwaltung — acht Kapitel mit Bildern.
pnpm docs:pdfmacht daraus ein PDF; jedem Release liegt es bei - Architektur und Entscheidungen
- Austauschformat für Vorlagen — wie eine Vorlage als JSON aussieht
- Betrieb — Inbetriebnahme mit Compose, nativ im Proxmox-LXC, Reverse Proxy, Anbindung an authentik, Sichern und Zurückspielen
- Konventionen — Sprache, Schichtung, feste Regeln
- Änderungen · Entwicklungstagebuch
Lizenz
Noch nicht festgelegt.