commit 49ce06f20620617f4d3c663b9afea66e3642fac9 Author: Florian Wilde Date: Thu Sep 24 23:33:49 2026 +0200 feat: Projektgerüst, gehärteten Betriebsstand und Prozess aufsetzen Legt die Grundlage, auf der alle weiteren Meilensteine nur noch Fachlichkeit ergänzen: Next 16 mit App Router und TypeScript strict, Tailwind 4, Prisma 7, Vitest 5, Playwright, ESLint, Prettier und die Hooks für Lint und Commits. Zwei Dinge sind bewusst so gebaut und durch Tests festgehalten: Die Datenbank ist im Betriebsstand von außen nicht erreichbar. Sie hat kein ports:-Mapping und hängt allein im Netz "intern", das ohne Gateway läuft; nur "web" kommt zusätzlich nach draußen. tests/betriebsstand.test.ts schlägt an, falls ein Port-Mapping zurückkehrt. Geprüft, nicht behauptet: docker compose up startet den Stack, 5432 ist vom Host aus zu, aus "web" offen. pnpm pruefe:doku hält Dokumentation und Code zusammen — Version im Changelog, Issue-Dateien gegen ihre Übersicht, keine Verweise auf fehlende Dateien, keine fehlenden Bilder. Das Skript wurde gegen vier eingebaute Fehler gegengeprüft. Drei Versions-Obergrenzen sind nötig und in CLAUDE.md begründet: eslint bleibt auf 9.x, weil eslint-plugin-react keinen Nachfolger für eslint 10 hat und das Regelwerk sonst beim Laden abbricht; typescript bleibt auf 6.0.x, weil typescript-eslint TypeScript 7 noch nicht unterstützt; prisma ist exakt auf 7.10.0 gepinnt, weil sein latest-Tag auf einen 8.0-RC zeigt, der von @prisma/client aber nicht. Auth.js entfällt entgegen der ursprünglichen Vorgabe: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose, wie in ProxLedger. Dieser erste Commit geht direkt auf main, weil es noch keinen Branch gibt, von dem abzuzweigen wäre. Ab #006 läuft jedes Issue über einen eigenen Branch. Closes #5 Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b46a529 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.gitea +coverage +test-results +playwright-report +docs/screenshots +docs/anleitung/bilder +.env +.env.* +!.env.example diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..364170f --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# Laufzettel – Umgebungsvariablen. +# Kopieren nach .env und ausfuellen. Die .env gehoert nie ins Repo. + +# ---------- Pflicht ---------- + +# Oeffentliche Adresse der Anwendung. Steckt in den QR-Codes und in der +# OIDC-Rueckruf-Adresse, muss also stimmen, wenn ein Reverse Proxy davor haengt. +APP_URL=https://laufzettel.example.de + +# Geheimnis fuer Sitzungs-Cookies und die Verschluesselung des LDAP-Bind-Passworts. +# Mindestens 32 Zeichen. Erzeugen mit: openssl rand -base64 48 +APP_SECRET= + +# Zugang zur Datenbank. Im Compose-Stand wird der Dienstname postgres verwendet +# und die Datenbank ist von aussen nicht erreichbar. +POSTGRES_USER=laufzettel +POSTGRES_PASSWORD= +POSTGRES_DB=laufzettel +DATABASE_URL=postgresql://laufzettel:@postgres:5432/laufzettel?schema=public + +# ---------- Anmeldung ---------- + +# Lokaler Login mit E-Mail und Passwort. Fuer die Entwicklung und als Notfallzugang. +# Im Betrieb mit OIDC auf false setzen. +AUTH_LOCAL_ENABLED=false + +# Generischer OIDC-Anbieter, Ziel ist authentik. Leer lassen, um OIDC abzuschalten. +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_NAME=authentik + +# Zuordnung der Rollen ueber den groups-Claim. Wird bei jedem Login neu ausgewertet. +OIDC_GROUP_ADMIN=lz-admin +OIDC_GROUP_EDITOR=lz-redakteur +OIDC_GROUP_TECHNICIAN=lz-techniker +OIDC_GROUP_VIEWER=lz-leser + +# Optional, nur im Betriebsmodus Dienstleistung: Gruppen wie lz-kunde-MUS binden +# Benutzer an Kunden. TECHNIKER und LESER sehen dann nur ihre Kunden. +# Leer lassen heisst: keine Einschraenkung. +OIDC_CUSTOMER_GROUP_PREFIX= + +# ---------- Betrieb ---------- + +# Adresse, auf die der Webdienst gebunden wird. Standard ist nur localhost, +# davor gehoert ein Reverse Proxy. Fuer direkten Zugriff im LAN: 0.0.0.0 +BIND_ADDR=127.0.0.1 +APP_PORT=3000 + +# Wird beim Bauen in die Anzeige unten links uebernommen. +APP_VERSION=0.1.0 +GIT_SHA= + +# ---------- Seed ---------- + +SEED_ADMIN_EMAIL=admin@firma.example +SEED_ADMIN_PASSWORD= +# INTERN (Standard) oder DIENSTLEISTUNG. SERVICE wird als Alias akzeptiert. +SEED_MODE=INTERN + +# ---------- Tests ---------- + +# Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests. +# Passend zu docker-compose.dev.yml (Port 5433): +TEST_DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5433/laufzettel_test?schema=public diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0b2e511 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,124 @@ +name: Prüfung + +on: + push: + branches: [main, "feat/**", "fix/**"] + pull_request: + branches: [main] + +jobs: + pruefung: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: laufzettel + POSTGRES_PASSWORD: laufzettel + POSTGRES_DB: laufzettel_test + options: >- + --health-cmd "pg_isready -U laufzettel" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://laufzettel:laufzettel@localhost:5432/laufzettel_test?schema=public + TEST_DATABASE_URL: postgresql://laufzettel:laufzettel@localhost:5432/laufzettel_test?schema=public + APP_SECRET: ci-geheimnis-mit-mindestens-32-zeichen-laenge + APP_URL: http://localhost:3000 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: pnpm bereitstellen + run: corepack enable pnpm + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + + - name: Abhängigkeiten installieren + run: pnpm install --frozen-lockfile + + - name: Prisma-Client erzeugen + run: pnpm exec prisma generate + + - name: Code-Stil prüfen + run: pnpm lint + + - name: Typen prüfen + run: pnpm typecheck + + - name: Tests + run: pnpm test + + - name: Bauen + run: pnpm build + + - name: Dokumentation prüfen + run: pnpm pruefe:doku + + - name: Shell-Skripte prüfen + run: | + if compgen -G "deploy/*.sh" > /dev/null || compgen -G "deploy/proxmox/*.sh" > /dev/null; then + shellcheck --severity=warning deploy/*.sh deploy/proxmox/*.sh + else + echo "Noch keine Shell-Skripte vorhanden (kommen mit #019)." + fi + + e2e: + runs-on: ubuntu-latest + needs: pruefung + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: laufzettel + POSTGRES_PASSWORD: laufzettel + POSTGRES_DB: laufzettel_test + options: >- + --health-cmd "pg_isready -U laufzettel" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://laufzettel:laufzettel@localhost:5432/laufzettel_test?schema=public + APP_SECRET: ci-geheimnis-mit-mindestens-32-zeichen-laenge + APP_URL: http://127.0.0.1:3000 + AUTH_LOCAL_ENABLED: "true" + SEED_ADMIN_EMAIL: admin@firma.example + SEED_ADMIN_PASSWORD: ci-passwort-nur-fuer-tests + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: pnpm bereitstellen + run: corepack enable pnpm + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + + - name: Abhängigkeiten installieren + run: pnpm install --frozen-lockfile + + - name: Browser holen + run: pnpm exec playwright install --with-deps chromium + + - name: Schema und Seed + run: pnpm exec prisma migrate deploy && pnpm db:seed + + - name: Bauen + run: pnpm build + + - name: Ende-zu-Ende-Tests + run: pnpm exec playwright test + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-bericht + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..629671c --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Abhaengigkeiten +node_modules/ +.pnpm-store/ + +# Next +.next/ +out/ +next-env.d.ts + +# Erzeugter Prisma-Client +src/generated/ + +# Tests +coverage/ +test-results/ +playwright-report/ +blob-report/ +.playwright/ + +# Umgebung – Geheimnisse gehoeren nie ins Repo +.env +.env.* +!.env.example + +# Werkzeuge +*.tsbuildinfo +.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..2e6b87e --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm exec commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..cb2c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f745dc3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.next +node_modules +coverage +docs +*.md +pnpm-lock.yaml +src/generated +prisma/migrations diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5242053 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 110 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f70a62 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +Alle nennenswerten Änderungen an Laufzettel werden in dieser Datei dokumentiert. + +Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), +und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). + +## [Unveröffentlicht] + +### Hinzugefügt + +- Projektgerüst: Next 16 mit App Router und TypeScript strict, Tailwind 4, Vitest 5, + Playwright 1.63, ESLint flat config, Prettier, husky mit lint-staged und commitlint (#005) +- Gestaltung: Design-Token als CSS-Variablen, selbst gehostete Schriften, Wortmarke und + Bildmarke `[ ✓ ]`, Versionsanzeige unten links (#005) +- Betriebsstand mit Docker Compose: die Datenbank ist von außen nicht erreichbar — kein + veröffentlichter Port und ein Netz ohne Gateway; Migrationen laufen beim Start in einem + eigenen Einmal-Dienst, der Seed bleibt ein bewusster Handgriff (#005) +- Healthcheck `GET /api/gesundheit` und Sicherheitskopfzeilen samt CSP-Nonce je Anfrage (#005) +- shadcn/ui eingerichtet: sein Variablen-Vertrag wird aus den Laufzettel-Token gespeist, damit + kopierte Bauteile ohne Nacharbeit passen (#005) +- `pnpm pruefe:doku` hält Dokumentation und Code zusammen: Version im Changelog, Issue-Dateien + gegen ihre Übersicht, keine Verweise auf fehlende Dateien, keine fehlenden Bilder (#005) +- `pnpm docs:screenshots` erzeugt Screenshots reproduzierbar und prüft dabei gleich, dass am + Handy nicht horizontal gescrollt wird (#005) +- Issues, Labels und Meilensteine für alle vier Phasen im Gitea-Tracker (#001–#019) + +### Sicherheit + +- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen, + statt mit Standardwerten zu starten (#005) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..975887e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# Konventionen für Laufzettel + +Diese Datei ist verbindlich. Wer hier etwas ändert, ändert es auch in `docs/architektur.md`. + +## Sprache + +**Deutsch, überall wo ein Mensch liest oder benennt** — auch Bezeichner. Funktions- und +Variablennamen, Prisma-Modelle und -Felder, Enum-Werte, Fehlerklassen, Dateinamen, Kommentare, +Testbeschreibungen, Commit-Betreffs und Dokumentation sind deutsch. **Umlaute werden +ASCII-gefaltet**: `Geraet`, `geaendertAm`, `laeufe`, `veroeffentlichtAm`. + +Englisch bleiben nur: + +- vom Framework vorgegebene Namen: `GET`, `POST`, `layout.tsx`, `route.ts`, `page.tsx`, `proxy.ts`, + `dependencies`, `default` +- Lehnwörter aus dem Fachjargon: `tagCode`, `Token`, `OIDC`, `LDAP`, `Hostname`, `Shell`, + `Markdown`, `Nonce`, `Healthcheck` + +Kommentare erklären **warum**, nicht was, und tragen wo hilfreich die Issue-Nummer: `(#007)`. + +## Namensabbildung zur Auftragsvorgabe + +Der Auftrag nennt englische Modelle. Verbindlich gilt: + +| Vorgabe | Modell | Vorgabe | Modell | +|---|---|---|---| +| User | `Benutzer` | Template | `Vorlage` | +| Customer | `Kunde` | TemplateVersion | `VorlagenVersion` | +| Setting | `Einstellung` | TemplateVariable | `VorlagenVariable` | +| DirectorySource | `Verzeichnisquelle` | TemplateSection | `VorlagenAbschnitt` | +| Employee | `Mitarbeiter` | TemplateStep | `VorlagenSchritt` | +| Department | `Abteilung` | ChecklistRun | `Lauf` | +| Device | `Geraet` | StepResult | `Schrittergebnis` | +| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` | +| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` | + +Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` · +`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` · +`SchrittZustand {OFFEN ERLEDIGT NICHT_ZUTREFFEND FEHLGESCHLAGEN}` · +`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) · +`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · +`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`. + +`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias. + +## Schichtung + +``` +src/server/services/* Regeln und Schreiboperationen. Werfen eigene …Fehler-Klassen. +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/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar. +src/app/api/* Route Handler. Nur Polling, QR-SVG, CSV, Auth, Healthcheck. Minimal. +src/components/* Darstellung. Keine Geschäftslogik. +``` + +**Keine Geschäftslogik in Komponenten oder Route Handlern.** Eine Server Action ist ein +Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen. + +## Feste Regeln, die in Tests festgehalten sind + +- **Versionierung:** Eine veröffentlichte `VorlagenVersion` wird nie verändert. „Bearbeiten" + erzeugt eine tiefe Kopie als Entwurf. Höchstens ein Entwurf je Vorlage. Läufe verweisen nur auf + veröffentlichte Versionen. +- **Archiv:** `laufMussVeraenderbarSein(lauf)` sitzt an genau einer Stelle und wird vor jeder + Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`. +- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind. + `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz. +- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus + `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. +- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. + Rolle und Aktiv-Status werden pro Anfrage **aus der Datenbank** gelesen, nicht aus dem Token. +- **`tagCode`:** wird nie geändert und nie wiederverwendet, auch nicht nach Archivierung. +- **Löschen:** Geräte, Abteilungen und Vorlagen werden archiviert. Mitarbeiter werden nur + `aktiv = false`. Nichts wird gelöscht, woran ein Lauf hängt. +- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in + `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht. +- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt + wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json. + +## Werkzeuge und Versionen + +Node 24 (`.nvmrc`), pnpm 12 über corepack. Exakt gepinnt, weil dort etwas im Fluss ist: +`next@16.3.6`, `react@19.3.0`, `prisma`/`@prisma/client`/`@prisma/adapter-pg` auf **`7.10.0`** +(der `latest`-Tag von `prisma` zeigt auf einen 8.0-RC, `@prisma/client` nicht — das würde +auseinanderlaufen), `vitest@5.0.1`, `@playwright/test@1.63.0`. + +Zwei Obergrenzen, die nicht angehoben werden dürfen: + +- **`eslint` bleibt auf 9.x.** `eslint-config-next` bringt `eslint-plugin-react@7.37.5` mit, das + `eslint` nur bis `^9.7` unterstützt; mit eslint 10 bricht das Regelwerk beim Laden ab. Eine + neuere Fassung des Plugins gibt es nicht. +- **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8` erlaubt nur `>=4.8.4 <6.1.0`. + TypeScript 7 ist noch nicht unterstützt. + +Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben +(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr). + +## Next 16 + +`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`, +`headers()`, `params` und `searchParams` sind **async**. Route Handler: +`(anfrage, { params }: { params: Promise<{ id: string }> })`. Turbopack ist Standard. +`next lint` ist entfernt, `pnpm lint` ruft `eslint` direkt. + +## Prisma 7 + +Generator `prisma-client` mit Pflicht-`output` nach `src/generated/prisma`, `datasource` ohne +`url`, Zugriff über `@prisma/adapter-pg`. Ein Adapter ist **Pflicht** — `new PrismaClient()` ohne +`adapter` wirft. Seed steht in `prisma.config.ts` unter `migrations.seed`. Der Client wird über +den Lazy-Proxy in `src/lib/db.ts` geholt, damit `next build` ohne `DATABASE_URL` läuft. + +## Vor jedem Commit + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku +``` + +Der Pre-Commit-Hook erzwingt Lint und Format, der Commit-Msg-Hook Conventional Commits mit +höchstens 72 Zeichen im Betreff. Ein Commit pro abgeschlossener, lauffähiger Änderung. Kein +`wip` auf `main`. Fußzeile referenziert das Issue: `Closes #12`. + +## Gestaltung + +Dunkel zuerst, ein einziger Akzent (Signal-Lime `#C6F135`) nur für Aktionen, Fortschritt, Fokus +und aktive Zustände. Token stehen als CSS-Variablen in `src/app/globals.css` unter `@theme`; +Komponenten greifen nur darauf zu, nie auf rohe Hexwerte. Schriften selbst gehostet über +`@fontsource-variable`. Icons ausschließlich Lucide-Stroke-SVGs — **keine Emojis**. Keine +Verläufe, keine Schatten; Karten mit 1-px-Linie. Buttons als Pillen, 40 px hoch, am Handy 44–46 px. +Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`). Druckansichten und Etiketten immer hell. +**Am Handy nie horizontales Scrollen** — bei 390 px geprüft. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83ff504 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 + +# ---------- Basis ---------- +FROM node:24-alpine AS basis +WORKDIR /app +# tini beendet Prozesse sauber, openssl wird fuer die Geheimnis-Erzeugung gebraucht. +RUN apk add --no-cache tini openssl +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable pnpm + +# ---------- Abhaengigkeiten ---------- +FROM basis AS abhaengigkeiten +# pnpm-workspace.yaml muss mit, sonst blockiert pnpm 12 die erlaubten Build-Skripte. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN pnpm install --frozen-lockfile + +# ---------- Bauen ---------- +FROM abhaengigkeiten AS bauen +ARG APP_VERSION=0.0.0 +ARG GIT_SHA="" +ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION +ENV NEXT_PUBLIC_GIT_SHA=$GIT_SHA +COPY . . +RUN pnpm exec prisma generate && pnpm build + +# ---------- Laufzeit ---------- +FROM basis AS laufzeit +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +RUN addgroup -g 1001 -S laufzettel && adduser -S laufzettel -u 1001 -G laufzettel +COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/standalone ./ +COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/static ./.next/static +COPY --from=bauen --chown=laufzettel:laufzettel /app/public ./public +USER laufzettel +EXPOSE 3000 +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8118a31 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# 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:** in Entwicklung auf `v0.1.0` (MVP). Der Fortschritt steht in +> [`docs/issues/README.md`](docs/issues/README.md), Entscheidungen im +> [Entwicklungstagebuch](docs/devlog.md). + +## Zwei Betriebsmodi + +| Modus | Für wen | Kunden-Ebene | Verzeichnis | +|---|---|---|---| +| `INTERN` (Standard) | eine Firma, eigene Mitarbeiter | unsichtbar | eigenes AD per LDAP | +| `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 + +```bash +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:5433 +pnpm db:migrate +pnpm db:seed +pnpm dev # http://localhost:3000 +``` + +`APP_SECRET` erzeugen: + +```bash +openssl rand -base64 48 +``` + +### Qualitätsschranken + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku +``` + +Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits. +Ende-zu-Ende-Tests: `pnpm test:e2e`. + +## Betrieb mit Docker Compose + +```bash +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: + +```bash +docker compose run --rm migrate pnpm db:seed +``` + +### 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: + +```bash +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 `5433` öffnet. 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: + +```bash +docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz +``` + +Zurückspielen: + +```bash +gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel +``` + +Gesichert werden muss nur die Datenbank — die App hält keinen veränderlichen Zustand auf der +Platte. Das Volume heißt `laufzettel_datenbank`. + +## 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 | + +Die Anbindung an authentik ist in [`docs/betrieb/authentik.md`](docs/betrieb/authentik.md) +Schritt für Schritt beschrieben. + +## Umgebungsvariablen + +Alle Variablen mit Erklärung stehen in [`.env.example`](.env.example). Pflicht sind `APP_URL`, +`APP_SECRET`, `POSTGRES_PASSWORD` und `DATABASE_URL`; der Compose-Stand bricht ab, statt mit +Standardwerten zu starten. + +## Dokumentation + +- Anleitung für Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF +- [Architektur und Entscheidungen](docs/architektur.md) +- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019 +- [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln +- [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md) + +## Lizenz + +Noch nicht festgelegt. diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 0000000..604aa0d --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,11 @@ +// Conventional Commits. Betreff auf Deutsch, hoechstens 72 Zeichen (siehe CLAUDE.md). +const konfiguration = { + extends: ["@commitlint/config-conventional"], + rules: { + "header-max-length": [2, "always", 72], + "subject-case": [0], + "body-max-line-length": [0], + }, +}; + +export default konfiguration; diff --git a/components.json b/components.json new file mode 100644 index 0000000..5a56e7e --- /dev/null +++ b/components.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks", + "cn": "@/lib/utils" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..b0bb68d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,25 @@ +# Nur fuer die Entwicklung: Postgres auf Port 5433, damit ein lokales 5432 frei bleibt. +# Diese Datei niemals im Betrieb verwenden – dort ist die Datenbank absichtlich zu. + +name: laufzettel-dev + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: laufzettel + POSTGRES_PASSWORD: laufzettel + POSTGRES_DB: laufzettel + ports: + - "127.0.0.1:5433:5432" + volumes: + - datenbank-dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U laufzettel"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + datenbank-dev: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..315914d --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,20 @@ +# Dienste fuer die Testsuite. Nicht fuer den Betrieb. +# Die LDAP-Quelle fuer den Verzeichnis-Abgleich kommt in #016 dazu, dort wird sie +# auch tatsaechlich benutzt und geprueft. + +name: laufzettel-test + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: laufzettel + POSTGRES_PASSWORD: laufzettel + POSTGRES_DB: laufzettel_test + ports: + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U laufzettel"] + interval: 5s + timeout: 5s + retries: 10 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..925d1b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,97 @@ +# Laufzettel – Betriebsstand. +# +# Die Datenbank ist bewusst von aussen NICHT erreichbar: kein ports:-Mapping und +# ein Netz ohne Gateway (internal: true). Nur web und migrate kommen ueber den +# Dienstnamen daran. Fuer die Entwicklung gibt es docker-compose.dev.yml. +# +# Sicherung trotz geschlossenem Port: +# docker compose exec -T postgres pg_dump -U laufzettel laufzettel > sicherung.sql + +name: laufzettel + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-laufzettel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env} + POSTGRES_DB: ${POSTGRES_DB:-laufzettel} + # Absichtlich expose statt ports: im LAN und auf dem Host nicht erreichbar. + expose: + - "5432" + networks: + - intern + volumes: + - datenbank:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-laufzettel}"] + interval: 10s + timeout: 5s + retries: 10 + + # Laeuft einmal beim Start und spielt nur die Migrationen ein. + # Der Seed wird bewusst nicht automatisch ausgefuehrt, siehe README. + migrate: + build: + context: . + target: bauen + restart: "no" + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public} + depends_on: + postgres: + condition: service_healthy + networks: + - intern + command: ["pnpm", "exec", "prisma", "migrate", "deploy"] + + web: + build: + context: . + target: laufzeit + args: + APP_VERSION: ${APP_VERSION:-0.1.0} + GIT_SHA: ${GIT_SHA:-} + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public} + APP_SECRET: ${APP_SECRET:?APP_SECRET fehlt in der .env} + APP_URL: ${APP_URL:?APP_URL fehlt in der .env} + AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-false} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_NAME: ${OIDC_NAME:-authentik} + OIDC_GROUP_ADMIN: ${OIDC_GROUP_ADMIN:-} + OIDC_GROUP_EDITOR: ${OIDC_GROUP_EDITOR:-} + OIDC_GROUP_TECHNICIAN: ${OIDC_GROUP_TECHNICIAN:-} + OIDC_GROUP_VIEWER: ${OIDC_GROUP_VIEWER:-} + OIDC_CUSTOMER_GROUP_PREFIX: ${OIDC_CUSTOMER_GROUP_PREFIX:-} + # Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy. + ports: + - "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + networks: + - intern + - extern + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + +networks: + # Kein Gateway: die Datenbank kann weder heraus noch ist sie von aussen erreichbar. + intern: + internal: true + # Nur fuer web: Reverse Proxy, OIDC zu authentik, LDAP zum AD. + extern: + +volumes: + datenbank: diff --git a/docs/abnahme-mvp.md b/docs/abnahme-mvp.md new file mode 100644 index 0000000..8e8f290 --- /dev/null +++ b/docs/abnahme-mvp.md @@ -0,0 +1,95 @@ +# Abnahmekriterien für den MVP (v0.1.0) + +Diese Liste wird von [#019](issues/019-betrieb-release.md) abschließend durchgegangen. Jeder +Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschweigend offen. + +## Betrieb + +- [ ] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch +- [ ] Seed läuft per dokumentiertem Befehl, nicht automatisch +- [ ] Die Datenbank ist vom Host aus **nicht** erreichbar (`nc -z localhost 5432` schlägt fehl), aus `web` schon +- [ ] Der Proxmox-One-Liner rollt einen LXC aus, in dem die App läuft und PostgreSQL nur am Unix-Socket hört +- [ ] Erneuter Aufruf des Skripts aktualisiert, ohne Daten oder Geheimnisse zu verlieren +- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund +- [ ] Läuft hinter einem Reverse Proxy ohne fest verdrahtete Adressen + +## Anmeldung und Rechte + +- [ ] Login per lokalem Konto funktioniert +- [ ] OIDC ist konfigurierbar und für authentik dokumentiert, inkl. Gruppen für Rollen +- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist der lokale Login weg und der Endpunkt lehnt ab +- [ ] Rollenrechte: jede Rolle gegen jede Aktion getestet, Prüfung ausschließlich serverseitig +- [ ] Eine Rollenänderung greift ohne neue Anmeldung; ein deaktivierter Benutzer wird abgewiesen +- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie in einer API-Antwort + +## Betriebsmodus + +- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige +- [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl +- [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden +- [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt +- [ ] Im Modus `INTERN` ignoriert die Service-Schicht eine mitgeschickte `kundeId` +- [ ] Der Modus ist ohne Neustart umschaltbar, jeder Wechsel steht im `Protokoll` + +## Vorlagen + +- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten" erzeugt eine tiefe Kopie als Entwurf +- [ ] Höchstens ein Entwurf je Vorlage +- [ ] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht +- [ ] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage +- [ ] Unbekannte Platzhalter werden im Editor als Warnung angezeigt + +## Läufe und Archiv + +- [ ] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren +- [ ] Abschließen scheitert, solange ein Pflichtschritt offen ist +- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz +- [ ] „Abschnitt als nicht zutreffend markieren" setzt alle Schritte mit einer Notiz +- [ ] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare +- [ ] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag +- [ ] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden +- [ ] Druckansicht eines Laufs ist sauber und hell + +## Mitarbeiter, Geräte, QR + +- [ ] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte +- [ ] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt +- [ ] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite +- [ ] „Vorlage zuweisen" erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen" erscheint; „Starten" setzt `IN_ARBEIT` +- [ ] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis +- [ ] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet +- [ ] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar + +## Platzhalter und Befehle + +- [ ] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen +- [ ] Abteilung „Buchhaltung" wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle` +- [ ] Kopieren liefert exakt diesen Text, zeichengenau +- [ ] „Jörg Müller-Lüdenscheid" ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`) +- [ ] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll` +- [ ] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert +- [ ] „Alle Befehle als Skript kopieren" liefert die Schritte in richtiger Reihenfolge +- [ ] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped + +## Verzeichnis (LDAP) + +- [ ] Erster Abgleich legt Mitarbeiter an, zweiter aktualisiert sie ohne Duplikate +- [ ] Entfernte oder deaktivierte Konten werden `aktiv = false`, nie gelöscht +- [ ] `{{ou}}` wird korrekt aus dem `dn` abgeleitet +- [ ] Das Bind-Passwort erscheint in keiner API-Antwort und keinem Log +- [ ] Zwei gleichzeitige Abgleiche derselben Quelle: der zweite wird von der Sperre abgewiesen + +## Oberfläche + +- [ ] Am Handy kein horizontales Scrollen — bei 390 px auf allen Hauptseiten geprüft +- [ ] Gestaltung nach Vorgabe: dunkel, ein Akzent, keine Emojis, keine Schatten, keine Verläufe +- [ ] Version mit Git-SHA unten links + +## Projektorganisation + +- [ ] Issues für alle Phasen und MVP-Meilensteine existieren +- [ ] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt +- [ ] `v0.1.0` ist getaggt, Gitea-Release mit Changelog-Auszug +- [ ] `docs/anleitung/` vollständig mit Screenshots und als PDF +- [ ] `docs/screenshots/` enthält die Meilenstein-Bilder +- [ ] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün diff --git a/docs/architektur.md b/docs/architektur.md new file mode 100644 index 0000000..b3f89d2 --- /dev/null +++ b/docs/architektur.md @@ -0,0 +1,108 @@ +# Architektur und Entscheidungen + +Ergänzt [`CLAUDE.md`](../CLAUDE.md), das die verbindlichen Konventionen enthält. Hier steht das +Warum. + +## 1. Zwei Betriebsmodi, ein Codepfad + +`Einstellung.modus` ist `INTERN` oder `DIENSTLEISTUNG` und liegt in der Datenbank, nicht in der +Umgebung — der Admin schaltet ohne Neustart um. + +Der Kern der Entscheidung: **jede kundenbezogene Zeile trägt immer eine `kundeId`**, auch im +Modus `INTERN`. Dort ist es die eine Organisation mit `isteigeneOrganisation = true`, die in +keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett erscheint. Dadurch ist der +Wechsel nach `DIENSTLEISTUNG` eine Datenänderung (die eigene Organisation wird zum ersten Kunden) +und keine Migration. + +Es gibt daher **nicht zwei Codepfade** für Geräte oder Läufe, sondern ein- und ausgeblendete +Felder und Filter. Zwei Funktionen tragen das: + +- `betriebsmodus()` — gecachter Zugriff, den alle Listen und Formulare fragen +- `kundeErmitteln(eingabe)` — liefert im Modus `INTERN` immer die eigene Organisation und + **verwirft** eine mitgeschickte `kundeId`. Das ist eine Sicherheitsgrenze, kein Komfort. + +## 2. Schichtung + +``` +Server Component ──► queries/* ──► prisma +Server Action ──► services/* ──► prisma + └─ rechtErforderlich(), Zod, Regeln, Protokoll +``` + +`services/*` hält die Regeln und wirft eigene `…Fehler`-Klassen. `queries/*` hält Lesemodelle und +wendet Rollen- und Mandantensicht selbst an, damit kein Aufrufer sie vergessen kann. +`actions/*` sind Dreizeiler: Recht prüfen, Schema anwenden, Service rufen. + +Der Grund für die Trennung von Action und Service: die Regeln sollen **ohne Next.js testbar** +sein. Vitest ruft den Service direkt. + +## 3. Anmeldung: kein Auth.js + +Der Auftrag nannte Auth.js. Beim Prüfen der Lage am 2026-09-24: + +- `next-auth` v5 steht auf `5.0.0-beta.32`, seit Jahren ohne GA +- authjs.dev führt das Projekt im Wartungsmodus („The Auth.js project is now part of Better + Auth"), die Maintainer schreiben „no immediate plans for v5" +- `@auth/prisma-adapter` deklariert `@prisma/client` nur bis `>=6` — wir fahren Prisma 7 + +Entschieden wurde deshalb der Eigenbau mit stabilen Bausteinen, wie in ProxLedger: + +- **`openid-client` 6.x** für generisches OIDC mit Discovery (Ziel: authentik) +- **`jose`** für ein signiertes Sitzungs-Cookie (HS256 aus `APP_SECRET`) +- **`@node-rs/argon2`** für den lokalen Notfallzugang + +Rolle und Aktiv-Status werden **pro Anfrage aus der Datenbank** gelesen, nicht aus dem Token. +Eine Rollenänderung greift damit sofort, und ein deaktivierter Benutzer wird auch mit gültigem +Cookie abgewiesen. + +## 4. Versionierung und Archiv + +Zwei Regeln tragen die Nachvollziehbarkeit: + +1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten" erzeugt eine tiefe + Kopie als Entwurf. Ein Lauf auf Version 1 zeigt für immer die Schritte aus Version 1, auch + wenn längst Version 5 veröffentlicht ist. +2. **Ein archivierter Lauf ist schreibgeschützt.** Die Sperre sitzt in genau einer Funktion, + `laufMussVeraenderbarSein(lauf)`, die vor jeder Änderung an Kopfdaten, `Schrittergebnis` und + `Kommentar` gerufen wird. Nur `ADMIN` holt einen Lauf zurück, mit `Protokoll`-Eintrag. + +Beides ist durch Tests abgedeckt, nicht durch Disziplin. + +## 5. Der Weg zu Phase 4 (Aktionen) + +Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer +Schritte**. Damit der Schritt dorthin keine Migration braucht: + +- `VorlagenSchritt.typ` ist ein Enum, dem `AKTION` hinzugefügt wird; `konfig` (Json) trägt dann + Connector und Parameter +- `Schrittergebnis.wert` ist ein Json, das ein Aktionsergebnis mit Log, IDs und Zeitstempeln + aufnimmt +- kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird +- das Krypto-Modul für das LDAP-Bind-Passwort ist schon der Platz für Connector-Geheimnisse + +## 6. Betrieb: zwei Wege, eine Regel + +Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: + +| Weg | Wie | +|---|---| +| Docker Compose | `postgres` ohne `ports:`, nur im Netz `intern` mit `internal: true` (kein Gateway) | +| Nativ im LXC | PostgreSQL mit `listen_addresses = ''` — nur Unix-Socket | + +Festgehalten in `tests/betriebsstand.test.ts`, damit ein `ports:`-Mapping nicht versehentlich +zurückkommt. + +## 7. Werkzeug-Obergrenzen + +Zwei Abhängigkeiten dürfen **nicht** angehoben werden, beides empirisch festgestellt: + +- **`eslint` bleibt auf 9.x.** `eslint-config-next` bringt `eslint-plugin-react@7.37.5` mit, das + `eslint` nur bis `^9.7` unterstützt. Mit eslint 10 bricht das Regelwerk beim Laden ab + (`contextOrFilename.getFilename is not a function`). Eine neuere Fassung des Plugins gibt es + nicht — die letzte stammt von April 2025. +- **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8` erlaubt nur `>=4.8.4 <6.1.0`. + TypeScript 7.0.2 ist zwar `latest`, aber noch nicht unterstützt. + +Dazu ein Pinning: **`prisma` und `@prisma/client` exakt auf `7.10.0`**. Der `latest`-Tag von +`prisma` zeigt auf einen 8.0-RC, der von `@prisma/client` nicht — ein unbedachtes +`pnpm add prisma` würde beide auseinanderlaufen lassen. diff --git a/docs/betrieb/authentik.md b/docs/betrieb/authentik.md new file mode 100644 index 0000000..7f3006f --- /dev/null +++ b/docs/betrieb/authentik.md @@ -0,0 +1,122 @@ +# Anmeldung über authentik (OIDC) + +Laufzettel spricht generisches OpenID Connect mit Discovery. Der Anbieter muss also nur einen +`/.well-known/openid-configuration`-Endpunkt anbieten — authentik tut das. Ein anderer Anbieter +(Keycloak, Entra ID, Zitadel) funktioniert genauso, nur heißen die Menüs anders. + +## 1. Provider in authentik anlegen + +**Anwendungen → Provider → Erstellen → OAuth2/OpenID Provider** + +| Feld | Wert | +|---|---| +| Name | `Laufzettel` | +| Authorization flow | `default-provider-authorization-explicit-consent` (oder implicit) | +| Client type | `Confidential` | +| Redirect URIs | `https://laufzettel.example.de/api/auth/oidc/rueckruf` | +| Signing Key | ein vorhandener Schlüssel | +| Scopes | `openid`, `profile`, `email` und ein Scope, der `groups` liefert | + +Die Redirect-URI ist `APP_URL` plus `/api/auth/oidc/rueckruf` — exakt so, mit demselben Schema +und ohne abschließenden Schrägstrich. Läuft Laufzettel hinter einem Reverse Proxy, ist `APP_URL` +die **öffentliche** Adresse, nicht die interne. + +`Client ID` und `Client Secret` notieren. + +## 2. Gruppen in den Token bringen + +Laufzettel liest die Rolle aus dem Claim `groups`. In authentik gibt es dafür den mitgelieferten +Scope-Mapping `authentik default OAuth Mapping: OpenID 'profile'` nicht — du brauchst ein eigenes +Mapping: + +**Anpassung → Property Mappings → Erstellen → Scope Mapping** + +- Name: `Laufzettel Gruppen` +- Scope name: `groups` +- Expression: + +```python +return { + "groups": [gruppe.name for gruppe in request.user.ak_groups.all()], +} +``` + +Dieses Mapping beim Provider unter **Advanced protocol settings → Scopes** auswählen. + +## 3. Anwendung anlegen + +**Anwendungen → Anwendungen → Erstellen**, den Provider zuordnen, Slug z. B. `laufzettel`. +Über die Anwendung steuerst du per Policy, wer sich überhaupt anmelden darf. + +## 4. Gruppen für die Rollen + +Lege in authentik vier Gruppen an und trage die Mitglieder ein: + +| Gruppe | Rolle in Laufzettel | Darf | +|---|---|---| +| `lz-admin` | `ADMIN` | alles, inkl. Benutzer, Verzeichnis, Abteilungen, Archiv-Verwaltung | +| `lz-redakteur` | `REDAKTEUR` | Vorlagen anlegen, bearbeiten, veröffentlichen | +| `lz-techniker` | `TECHNIKER` | Läufe starten, abarbeiten, abschließen, archivieren; Geräte pflegen | +| `lz-leser` | `LESER` | nur lesen | + +Ist jemand in mehreren Gruppen, gewinnt die stärkere Rolle. + +## 5. Laufzettel konfigurieren + +In der `.env`: + +```dotenv +APP_URL=https://laufzettel.example.de + +OIDC_ISSUER=https://authentik.example.de/application/o/laufzettel/ +OIDC_CLIENT_ID=... +OIDC_CLIENT_SECRET=... +OIDC_NAME=authentik + +OIDC_GROUP_ADMIN=lz-admin +OIDC_GROUP_EDITOR=lz-redakteur +OIDC_GROUP_TECHNICIAN=lz-techniker +OIDC_GROUP_VIEWER=lz-leser +``` + +Den `OIDC_ISSUER` zeigt authentik beim Provider unter **OpenID Configuration Issuer** an. Der +abschließende Schrägstrich gehört dazu. + +Danach neu starten: + +```bash +docker compose up -d +``` + +## 6. Was beim Login passiert + +- Ein unbekannter Benutzer wird beim ersten Login **angelegt** (JIT-Provisioning) +- Die Rolle wird bei **jedem** Login aus `groups` neu gesetzt — eine Änderung in authentik greift + ab der nächsten Anmeldung +- Ist der Benutzer in keiner der vier Gruppen, bekommt er `LESER` +- Wer vor dem Login eine bestimmte Seite aufgerufen hat (z. B. `/t/LZ-7K3F9Q` nach dem Scannen + eines QR-Codes), landet danach genau dort + +## 7. Notfallzugang + +Fällt authentik aus, kommt niemand mehr in Laufzettel. Für diesen Fall gibt es den lokalen Login: + +```dotenv +AUTH_LOCAL_ENABLED=true +``` + +Er ist standardmäßig **aus**. Ein lokales Admin-Konto legst du über den Seed an +(`SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`) oder als Admin unter **Admin → Benutzer**. Die +Passwörter liegen als argon2-Hash in der Datenbank. + +Empfehlung: ein einziges lokales Admin-Konto, Passwort im Passwortmanager, `AUTH_LOCAL_ENABLED` +im Normalbetrieb auf `false` und nur im Notfall umgestellt. + +## 8. Fehlersuche + +| Symptom | Ursache | +|---|---| +| `redirect_uri_mismatch` | Die URI in authentik stimmt nicht zeichengenau mit `APP_URL` + `/api/auth/oidc/rueckruf` überein — oft `http` gegen `https` oder ein abschließender Schrägstrich | +| Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. Entweder HTTPS nutzen oder zum Testen über `localhost` gehen | +| Jeder bekommt `LESER` | Der Claim `groups` fehlt im Token. Scope-Mapping aus Schritt 2 angelegt und beim Provider ausgewählt? | +| Discovery schlägt fehl | `OIDC_ISSUER` falsch oder von der App aus nicht erreichbar. Im Compose-Stand hängt `web` im Netz `extern` — prüfen, ob der Name auflösbar ist | diff --git a/docs/devlog.md b/docs/devlog.md new file mode 100644 index 0000000..1fa016d --- /dev/null +++ b/docs/devlog.md @@ -0,0 +1,106 @@ +# DEVLOG – Laufzettel + +Entwicklungstagebuch mit Zeiterfassung. Eine Sitzung ist ein Abschnitt; Sub-Agenten-Läufe stehen +als Unterpunkte mit Aufgabe und Dauer. + +## Zeitübersicht + +| Issue | Titel | Tatsächlich | +|---|---|---| +| #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min | +| | **Summe** | **1 h 25 min** | + +| Meilenstein | Tatsächlich | Status | +|---|---|---| +| v0.1.0 | 1 h 25 min | in Arbeit | + +--- + +## 2026-09-24 · Sitzung 1 · 22:05–23:30 + +**Issue:** #005 (M0) +**Version:** 0.1.0 + +### Erledigt + +- Umgebung geprüft: Node v24.21.0 nur über nvm (nicht im `PATH` nicht-interaktiver Shells), + Docker 29.1.3, Chromium-Systembibliotheken vorhanden, kein `pandoc` +- Gitea aufgesetzt: 11 Labels, 5 Meilensteine (`v0.1.0`–`v0.5.0`), 19 Issues (#1–#19), Nummern + passen zu den Dateinamen unter `docs/issues/` +- Projektgerüst angelegt und alle vier Schranken grün: `lint`, `typecheck`, `test`, `build` +- Betriebsstand gehärtet und der Nachweis in `tests/betriebsstand.test.ts` festgeschrieben +- Abnahme von #005 wirklich gefahren, nicht behauptet: `docker compose up -d --build` startet den + Stack, `migrate` läuft und beendet sich, `web` wartet korrekt darauf, `/api/gesundheit` meldet + `ok` — und `5432` ist vom Host nicht erreichbar, aus `web` schon +- `scripts/pruefe-doku.mjs` gegen vier eingebaute Fehler geprüft: es findet abweichende Zeiten, + fehlende Einträge, Verweise auf fehlende Dateien und fehlende Bilder + +### Entscheidungen + +- **Deutsch im Code, auch für Bezeichner.** Der Auftragstext verlangte Englisch; Florian hat sich + für Einheitlichkeit mit ProxLedger entschieden. Verbindliche Namensabbildung in `CLAUDE.md`. +- **Kein Auth.js.** v5 steht seit Jahren auf Beta (`5.0.0-beta.32`), authjs.dev führt das Projekt + im Wartungsmodus („now part of Better Auth"), die Maintainer nennen „no immediate plans for v5", + und `@auth/prisma-adapter` deklariert Prisma nur bis 6 — wir fahren Prisma 7. Stattdessen + `openid-client` + `jose` wie in ProxLedger. Dieselbe Überlegung wie dort, nur ein Jahr später + noch deutlicher. +- **`prisma` exakt auf 7.10.0 gepinnt.** Der `latest`-Tag von `prisma` zeigt auf `8.0.0-rc.17`, + der von `@prisma/client` auf `7.10.0`. Ein unbedachtes `pnpm add prisma` hätte einen + Versions-Konflikt erzeugt. +- **`eslint` bleibt auf 9.x.** Mit eslint 10 bricht das Regelwerk beim Laden ab: + `eslint-config-next` bringt `eslint-plugin-react@7.37.5` mit, das `eslint` nur bis `^9.7` + unterstützt, und eine neuere Fassung des Plugins gibt es nicht (letzte Veröffentlichung + April 2025). Empirisch festgestellt, nicht geraten. +- **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8.70.1` erlaubt nur + `>=4.8.4 <6.1.0`; TypeScript 7.0.2 ist zwar `latest`, aber noch nicht unterstützt. +- **`@node-rs/argon2` statt `argon2`.** Liefert vorgebaute musl- und gnu-Binaries, braucht auf + `node:24-alpine` also keine C++-Toolchain in der Bau-Stufe. +- **`barcode-detector` statt `jsQR`.** `jsqr` ist seit April 2021 unveröffentlicht. Der Polyfill + bietet dieselbe API wie das native `BarcodeDetector`, also ein Codepfad statt zwei. +- **Seed läuft nicht automatisch.** Die Migrationen schon, der Seed bleibt ein dokumentierter + Handgriff — so steht es in den Abnahmekriterien. +- **shadcn/ui eingerichtet, aber der Button folgt der Vorgabe, nicht dem Preset.** Der Vertrag + von shadcn (`--background`, `--primary`, `--border`, …) wird in `globals.css` aus den + Laufzettel-Token gespeist; jedes später kopierte Bauteil sieht damit sofort richtig aus und + folgt auch dem hellen Modus. Der Scannen-Knopf nutzt den shadcn-Button, überschreibt aber Form + und Höhe (Pille, 44 px am Handy statt 32 px). +- **PDF der Anleitung über Playwright.** `pandoc` und `wkhtmltopdf` fehlen und sind ohne root + nicht installierbar; Chromium ist für die E2E-Tests ohnehin da. + +### Probleme + +- **Projektverzeichnis gehörte `root`.** `~/Projekte/Laufzettel` war root-eigen, die Sitzung läuft + als `florianwilde`, und `sudo` verlangt hier ein Passwort. Bis Florian den `chown` gemacht hat, + wurde das Gerüst im Scratchpad vorgebaut und danach hineinverschoben — die Gitea-Arbeit brauchte + das Verzeichnis nicht und lief parallel. +- **pnpm 12 hat die Einstellung für Build-Skripte umbenannt.** `onlyBuiltDependencies` wird noch + von `pnpm config list` angezeigt, aber nicht mehr angewandt; wirksam ist `allowBuilds` als + Zuordnung in `pnpm-workspace.yaml`. Das `pnpm`-Feld in `package.json` liest pnpm 12 gar nicht + mehr. Gefunden über `pnpm approve-builds --all`, das die richtige Form selbst schreibt. +- **Context7-MCP ist nicht autorisiert**, der OAuth-Ablauf geht in dieser Sitzung nicht. Alle + Versionen wurden stattdessen direkt gegen das npm-Registry und die offiziellen Docs geprüft. +- **pnpm 12 prüft die Lieferkette und lehnt taufrische Pakete ab.** `vite@8.3.1` (8,7 h alt) und + `lucide-react@1.48.0` (15,3 h alt) fielen durch. Beide auf die Vorgängerfassung gesetzt, die + Richtlinie bleibt an. Bei der Gelegenheit alle direkten Abhängigkeiten auf ihr Alter geprüft. +- **`shadcn init` lässt sich nicht skripten.** Es fragt auch mit `--yes` nach einem Preset und + bleibt hinter einer Pipe stumm hängen. `shadcn add --yes --overwrite < /dev/null` + geht dagegen. Zusätzlich nötig: Pfad `"cn": ["./src/lib/utils"]` in `tsconfig.json` und das + Paket `radix-ui`, beides schreibt `init` nicht. +- **Port 3000 ist von ProxLedger belegt.** Die Abnahme lief auf 3100 und 3200. +- **Kein Gitea-Actions-Runner registriert** (0 Runner). `.gitea/workflows/ci.yml` wird trotzdem + angelegt und ist nutzbar, sobald ein Runner da ist; verbindlich sind bis dahin der + Pre-Commit-Hook und die Durchläufe vor jedem Commit. + +### Offen + +- Der `pct create`-Teil des Proxmox-Skripts (#019) lässt sich hier nicht ausführen, weil kein + PVE-Host erreichbar ist. Geplant: `shellcheck` plus `--dry-run`, und `deploy/install.sh` + vollständig in einem `debian:13`-Container geprüft. Der Lauf auf echtem Proxmox braucht eine + Gegenprüfung durch Florian, dafür kommt eine Prüfliste. +- Der OpenLDAP-Testcontainer wandert von #005 nach #016, wo er tatsächlich benutzt wird — ein + Dienst, den noch kein Test anfasst, wäre eine Attrappe. + +### Nächster Schritt + +- #006 Datenmodell: das vollständige Schema in deutscher Benennung, `tagCode`-Generator, + Krypto-Helfer, erste Migration, idempotenter Seed-Rumpf. diff --git a/docs/issues/001-epic-phase-1-mvp.md b/docs/issues/001-epic-phase-1-mvp.md new file mode 100644 index 0000000..d758079 --- /dev/null +++ b/docs/issues/001-epic-phase-1-mvp.md @@ -0,0 +1,48 @@ +# #001 Phase 1: MVP + +**Status:** in Arbeit +**Meilenstein:** v0.1.0 +**Labels:** phase-1 +**Geschätzt:** – **Tatsächlich:** – + +## Ziel + +Eine selbst gehostete Checklisten-App für wiederkehrende IT-Arbeiten: versionierte Vorlagen, daraus gestartete Läufe, im Team abgearbeitet, abgeschlossen und unveränderlich archiviert. Ein Lauf hängt optional an einem Mitarbeiter und optional an einem Gerät, woraus im Archiv eine Historie je Gerät und je Mitarbeiter entsteht. Einstieg über QR-Aufkleber mit unveränderlichem `tagCode`. Zwei Betriebsmodi (`INTERN`, `DIENSTLEISTUNG`) in einem Codepfad, zwei Betriebswege (gehärteter Compose-Stack, nativer LXC per One-Liner). + +## Untergeordnete Issues + +- [ ] #005 M0 – Gerüst, Prozess, Design-Token +- [ ] #006 M1 – Datenmodell +- [ ] #007 M2 – Auth, Rollen, Benutzer +- [ ] #008 M3 – Betriebsmodus und Kunden +- [ ] #009 M4 – Vorlagen-Versionierung im Service +- [ ] #010 M5 – Vorlagen-Editor +- [ ] #011 M6 – Mitarbeiter, Abteilungen, Geräte +- [ ] #012 M7 – Platzhalter, Namensregeln, Escaping +- [ ] #013 M8 – Läufe: Service und Abarbeitung +- [ ] #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner +- [ ] #015 M10 – Dashboard und Archiv +- [ ] #016 M11 – Verzeichnis und LDAP-Abgleich +- [ ] #017 M12 – Seed der sieben Vorlagen +- [ ] #018 M13 – E2E-Tests, Anleitung, PDF +- [ ] #019 M14 – Betriebswege und Release v0.1.0 + +## Akzeptanzkriterien + +Die vollständige Liste steht in `docs/abnahme-mvp.md` und wird von #019 abschließend geprüft. Kern: + +- [ ] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch, Datenbank von außen nicht erreichbar +- [ ] Proxmox-One-Liner rollt einen lauffähigen LXC aus, erneuter Aufruf aktualisiert +- [ ] Lokaler Login funktioniert, OIDC für authentik dokumentiert +- [ ] Lauf ohne Mitarbeiter und ohne Gerät: starten, abschließen, archivieren +- [ ] Hauptablauf in beiden Betriebsmodi, Moduswechsel in beiden Richtungen nach Regel +- [ ] LDAP-Abgleich gegen Testcontainer: anlegen, aktualisieren, deaktivieren +- [ ] Veröffentlichte Vorlagenversion unveränderlich, Archiv schreibgeschützt +- [ ] `{{generiertes_passwort}}` nirgends persistiert +- [ ] Am Handy kein horizontales Scrollen +- [ ] `v0.1.0` getaggt, Anleitung mit Screenshots und als PDF + +## Entscheidungen + +- **Deutsch im Code.** Auch Prisma-Modelle, Felder und Enum-Werte sind deutsch, Umlaute ASCII-gefaltet. Abweichung vom Auftragstext, bewusst zugunsten der Einheitlichkeit mit ProxLedger. Verbindliche Namensabbildung in `CLAUDE.md`. +- **Kein Auth.js.** v5 ist dauerhaft Beta und im Wartungsmodus, der Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen `openid-client` + `jose` wie in ProxLedger. diff --git a/docs/issues/002-epic-phase-2.md b/docs/issues/002-epic-phase-2.md new file mode 100644 index 0000000..9a9b7a2 --- /dev/null +++ b/docs/issues/002-epic-phase-2.md @@ -0,0 +1,27 @@ +# #002 Phase 2: Verzeichnis-Alternativen, Bausteine, Anhänge + +**Status:** offen +**Meilenstein:** v0.2.0 +**Labels:** phase-2 +**Geschätzt:** – **Tatsächlich:** – + +## Ziel + +Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut. + +## Aufgaben + +- [ ] SCIM-Endpunkt als Alternative zum Pull-Abgleich +- [ ] Microsoft-Graph-Quelle für reine Entra-ID-Umgebungen ohne LDAP +- [ ] Wiederverwendbare Bausteine: Abschnitte, die mehrere Vorlagen teilen +- [ ] Abteilungsspezifische Zusatzabschnitte (Fachsoftware je Abteilung) +- [ ] Anhänge und Fotos an Schritten +- [ ] Live-Aktualisierung per SSE statt Polling +- [ ] Benachrichtigungen per Mail und Webhook +- [ ] Mitarbeiter-Selbstauskunft: ein Mitarbeiter sieht die Läufe zu seinen Geräten, nur lesend + +## Vorleistung im MVP + +- `Verzeichnisquelle` ist bereits mehrfach anlegbar und trägt einen Typ-fähigen Aufbau, damit Graph und SCIM daneben passen +- `VorlagenAbschnitt` hängt an der Version, nicht an der Vorlage — ein geteilter Baustein kann später als eigene Entität referenziert werden +- Polling ist in einem Route Handler gekapselt, der sich gegen SSE tauschen lässt diff --git a/docs/issues/003-epic-phase-3.md b/docs/issues/003-epic-phase-3.md new file mode 100644 index 0000000..d177867 --- /dev/null +++ b/docs/issues/003-epic-phase-3.md @@ -0,0 +1,23 @@ +# #003 Phase 3: Protokoll, bedingte Schritte, Freigabe + +**Status:** offen +**Meilenstein:** v0.3.0 +**Labels:** phase-3 +**Geschätzt:** – **Tatsächlich:** – + +## Ziel + +Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut. + +## Aufgaben + +- [ ] PDF-Übergabeprotokoll mit Unterschrift +- [ ] Bedingte Schritte und Abschnitte +- [ ] Vier-Augen-Freigabe +- [ ] Weitere Schritt-Typen + +## Vorleistung im MVP + +- Weil bedingte Abschnitte fehlen, enthält die Onboarding-Vorlage beide Konto-Abschnitte (AD und Entra ID); der nicht zutreffende wird per Abschnitts-Aktion als Ganzes auf `NICHT_ZUTREFFEND` gesetzt. Diese Aktion ist Teil des MVP (#013) +- `SchrittTyp` ist ein Enum, `VorlagenSchritt.konfig` ein Json — neue Typen brauchen keine Migration der bestehenden +- Die Druckansicht (#015) ist bereits die Vorstufe des PDF-Protokolls diff --git a/docs/issues/004-epic-phase-4.md b/docs/issues/004-epic-phase-4.md new file mode 100644 index 0000000..b746b7b --- /dev/null +++ b/docs/issues/004-epic-phase-4.md @@ -0,0 +1,31 @@ +# #004 Phase 4: Aktionen, Connectors, REST-API + +**Status:** offen +**Meilenstein:** v0.4.0 +**Labels:** phase-4 +**Geschätzt:** – **Tatsächlich:** – + +## Ziel + +Nicht im MVP umsetzen. Der Weg dorthin wird im MVP aber bewusst freigehalten: Die in den Vorlagen vorbereiteten PowerShell-Befehle sind die Vorstufe von Schritten, die die App selbst ausführt. + +## Aufgaben + +- [ ] Schritt-Typ `AKTION` mit Connector und Parametern aus Variablen und Person +- [ ] Connectors: LDAP/AD schreibend über eigenes Dienstkonto, Microsoft Graph (Entra ID, Exchange Online, Intune), authentik-API, später Ticketsystem und RMM +- [ ] Onboarding als Aktion: Konto anlegen, Initialpasswort erzeugen und sicher übergeben, Gruppen laut Abteilung, Postfach und Lizenz +- [ ] Offboarding als Aktion: Konto sperren, Sitzungen widerrufen, Postfach in Shared Mailbox wandeln +- [ ] Vorschau „das wird passieren", ausdrückliche Bestätigung, Ergebnis-Log am Schritt, Rollback-Hinweis +- [ ] Fehler setzen den Schritt auf `FEHLGESCHLAGEN` mit Meldung +- [ ] Berechtigung je Connector und Rolle, Geheimnisse verschlüsselt, alle Aufrufe im `Protokoll` +- [ ] Wiederkehrende Läufe (v0.5.0) +- [ ] REST-API und Webhooks, Start aus Ticketsystem und Wartungsplaner (v0.5.0) +- [ ] Zeiterfassung je Lauf, automatische Prüfschritte per RMM-API, Auswertungen, Aufbewahrungsfristen, NFC-Tags (v0.5.0) + +## Vorleistung im MVP + +- `SchrittTyp` ist ein Enum, dem `AKTION` ohne Migration bestehender Daten hinzugefügt werden kann; `konfig` (Json) trägt dann Connector und Parameter +- `Schrittergebnis.wert` ist ein Json, das ein Aktionsergebnis mit Log, IDs und Zeitstempeln aufnimmt +- Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird — `aktualisiertVonId` ist optional und ein Systemlauf ist darstellbar +- Das Krypto-Modul für das LDAP-Bind-Passwort (#006) ist schon der Platz für Connector-Geheimnisse +- NFC nutzt dieselbe URL `{APP_URL}/t/{tagCode}` wie der QR-Code diff --git a/docs/issues/005-geruest-prozess-design.md b/docs/issues/005-geruest-prozess-design.md new file mode 100644 index 0000000..586ff11 --- /dev/null +++ b/docs/issues/005-geruest-prozess-design.md @@ -0,0 +1,68 @@ +# #005 M0 – Gerüst, Prozess, Design-Token + +**Status:** erledigt +**Meilenstein:** v0.1.0 +**Labels:** phase-1, infra, docs +**Geschätzt:** 6 h **Tatsächlich:** 1 h 25 min + +## Ziel + +Ein lauffähiges, leeres Projekt mit allen Qualitätsschranken, dem gehärteten Betriebsstand und dem Design-Fundament. Danach ist jeder weitere Meilenstein reine Fachlichkeit. + +## Aufgaben + +- [x] Lokalen Branch `master` auf `main` umbenennen (Gitea-Default ist `main`), Remote unverändert lassen +- [x] `.nvmrc` = `24`, `packageManager: "pnpm@10.34.5"` über corepack, `engines.node >= 24` +- [x] Next 16.3.6 (App Router, TypeScript strict) mit `output: "standalone"`, `src/`-Layout +- [x] `tsconfig.json`: `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`, `forceConsistentCasingInFileNames`, Alias `@/*` → `./src/*` +- [x] Tailwind 4 (`@import "tailwindcss"` + `@theme`, `@tailwindcss/postcss`), shadcn/ui initialisieren, `lucide-react` +- [x] ESLint flat config nach ProxLedger-Muster (`eslint-config-next/core-web-vitals` + `/typescript` + `eslint-config-prettier/flat`, `globalIgnores` inkl. `src/generated/**`), Prettier (`printWidth: 110`, `singleQuote: false`, `trailingComma: "all"`) +- [x] husky + lint-staged + commitlint (`@commitlint/config-conventional`) als Pre-Commit- und Commit-Msg-Hook +- [x] Vitest 5 (`vitest.config.mts`, `environment: "node"`, `fileParallelism: false`) und `vite@^7` als Peer; Playwright 1.63 (`playwright.config.ts`) +- [x] `Dockerfile` multi-stage (Basis → Abhängigkeiten → Bauen → Laufzeit), `node:24-alpine`, `tini`, Nutzer 1001, non-root +- [x] `docker-compose.yml` gehärtet: `postgres` **ohne** `ports:`, Netze `intern` (`internal: true`) und `extern`, `web` auf `${BIND_ADDR:-127.0.0.1}:3000`, `migrate` als One-Shot aus der Bau-Stufe, Healthchecks, `${VAR:?… fehlt in der .env}` +- [x] `docker-compose.dev.yml` (nur Postgres, Port 5433) und `docker-compose.test.yml` (Postgres; der OpenLDAP-Dienst kommt mit #016, wo er tatsächlich benutzt wird) +- [x] `.env.example` mit allen Variablen und Kommentar je Variable +- [x] `GET /api/gesundheit` → `SELECT 1` → 200/503 +- [x] Sicherheitskopfzeilen in `next.config.ts`, CSP-Nonce in `proxy.ts` (Next 16: nicht `middleware.ts`) +- [x] `scripts/pruefe-doku.mjs` und `pnpm pruefe:doku`: Version hat Changelog-Abschnitt, Issue-Dateien und Übersicht stimmen überein, verlinkte Bilder existieren +- [x] `scripts/gitea-issues.ts` und `pnpm issues:sync`: spiegelt `docs/issues/*.md` in den Gitea-Tracker +- [x] `.gitea/workflows/ci.yml` nach ProxLedger-Muster, erweitert um Playwright und `shellcheck` +- [x] Design-Token als CSS-Variablen (Grund `#0B0D10`, Flächen `#14171C`/`#1B1F26`, Linien `#262B33`, Text `#F3F5F7`/`#98A2B3`, Akzent `#C6F135`, Status `#3DDC97`/`#F5B84A`/`#F87171`), heller Modus optional +- [x] Schriften selbst gehostet: `@fontsource-variable/space-grotesk`, `-manrope`, `-jetbrains-mono` +- [x] Wortmarke `laufzettel` (Space Grotesk 700, Laufweite −0.04em, Akzentpunkt) und Bildmarke `[ ✓ ]` als SVG, Favicon, PWA-Icon, `manifest.ts` +- [x] App-Shell: dunkle Sidebar mit „Scannen"-Knopf direkt unter dem Logo, Pillen-Buttons (40 px, am Handy 44–46 px), Karten mit Radius 16–18 px und 1-px-Linie ohne Schatten +- [x] Versionsanzeige unten links aus `NEXT_PUBLIC_APP_VERSION` und `NEXT_PUBLIC_GIT_SHA` +- [x] `README.md`, `CHANGELOG.md` (Keep a Changelog, deutsch), `docs/devlog.md`, `CLAUDE.md` mit Konventionen und Namensabbildung, `docs/architektur.md`, `docs/abnahme-mvp.md` +- [x] Gitea: Labels, Milestones `v0.1.0`…`v0.5.0`, Epic-Issues #001–#004 und Meilenstein-Issues #005–#019 + +## Akzeptanzkriterien + +- [x] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch +- [x] `nc -z localhost 5432` schlägt fehl, `docker compose exec web nc -z postgres 5432` gelingt +- [x] Ein Vitest-Test liest `docker-compose.yml` und schlägt an, wenn unter `postgres` ein `ports:` auftaucht +- [x] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün +- [x] Ein Commit ohne Conventional-Commits-Betreff wird vom Hook abgelehnt +- [x] Die App zeigt Wortmarke, Bildmarke und Version unten links im vorgegebenen Farbschema + +## Beim Bauen aufgefallen + +- **pnpm 12 hat die Freigabe von Build-Skripten umbenannt.** `onlyBuiltDependencies` wird von + `pnpm config list` noch angezeigt, aber nicht mehr angewandt; wirksam ist `allowBuilds` als + Zuordnung in `pnpm-workspace.yaml`. Das `pnpm`-Feld in `package.json` liest pnpm 12 gar nicht + mehr und warnt darüber. +- **pnpm 12 prüft die Lieferkette.** Pakete, die jünger als 24 Stunden sind, werden abgelehnt. + `vite@8.3.1` und `lucide-react@1.48.0` erschienen am selben Tag und mussten auf `8.3.0` bzw. + `1.47.0` zurück. Die Richtlinie bleibt an — sie ist sinnvoll. +- **eslint 10 bricht mit `eslint-config-next`.** `eslint-plugin-react@7.37.5` unterstützt nur bis + `eslint ^9.7` und hat keinen Nachfolger. Festgestellt, nicht geraten: + `TypeError: contextOrFilename.getFilename is not a function`. +- **TypeScript 7 geht noch nicht.** `typescript-eslint@8` erlaubt `>=4.8.4 <6.1.0`. +- **`shadcn init` ist nicht skriptfähig** — es fragt nach einem Preset, auch mit `--yes`, und + bleibt hinter einer Pipe hängen. `shadcn add --yes --overwrite < /dev/null` + funktioniert dagegen. Die Registry importiert den Helfer als blanken Modulnamen `cn`, dafür + braucht `tsconfig.json` den Pfad `"cn": ["./src/lib/utils"]`, und die Bauteile brauchen das + Paket `radix-ui`. +- **Port 3000 war von ProxLedger belegt**, die Abnahme lief deshalb auf 3100 bzw. 3200. +- Die Migrationen laufen im Compose-Stand nachweislich automatisch; eingespielt wird ab #006 + etwas, vorher meldet der Dienst korrekt „No pending migrations to apply". diff --git a/docs/issues/006-datenmodell.md b/docs/issues/006-datenmodell.md new file mode 100644 index 0000000..9f88c90 --- /dev/null +++ b/docs/issues/006-datenmodell.md @@ -0,0 +1,33 @@ +# #006 M1 – Datenmodell + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, backend +**Geschätzt:** 5 h **Tatsächlich:** – + +## Ziel + +Das vollständige Schema in deutscher Benennung, sodass alle folgenden Meilensteine nur noch Verhalten ergänzen und keine Strukturänderung mehr brauchen. + +## Aufgaben + +- [ ] Prisma 7: Generator `prisma-client` mit `output = "../src/generated/prisma"`, `datasource` ohne `url`, `@prisma/adapter-pg` +- [ ] `prisma.config.ts` mit `migrations.seed = "tsx prisma/seed.ts"` +- [ ] `src/lib/db.ts`: Lazy-Singleton-Proxy, damit `next build` ohne `DATABASE_URL` läuft, Dev-Instanz an `globalThis` +- [ ] Modelle: `Benutzer`, `Kunde`, `Einstellung`, `Verzeichnisquelle`, `Mitarbeiter`, `Abteilung`, `Geraet`, `GeraetetypVorlage`, `Vorlage`, `VorlagenVersion`, `VorlagenVariable`, `VorlagenAbschnitt`, `VorlagenSchritt`, `Lauf`, `Schrittergebnis`, `Kommentar`, `Protokoll`, `AbgleichSperre` +- [ ] Enums: `Rolle`, `Betriebsmodus`, `LaufStatus`, `SchrittZustand`, `SchrittTyp`, `GeraeteTyp`, `VersionStatus`, `Herkunft` +- [ ] Unique-Constraints: `Geraet.tagCode`, `(laufId, vorlagenSchrittId)` auf `Schrittergebnis`, `(quelleId, externeId)` auf `Mitarbeiter`, `(kundeId, name)` auf `Abteilung`, `(kundeId)` partiell für `isteigeneOrganisation` +- [ ] `pg_trgm`-Erweiterung und GIN-Indizes für die Archivsuche über `Lauf.titel`, `Schrittergebnis.notiz`, `Kommentar.rumpf` +- [ ] Jede kundenbezogene Zeile trägt `kundeId` als Pflichtfeld: `Geraet`, `Mitarbeiter`, `Abteilung`, `Verzeichnisquelle`, `Lauf` +- [ ] `src/lib/tagcode.ts`: Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, Präfix `LZ-`, 6 Zeichen, Kollisionsprüfung serverseitig +- [ ] `src/lib/geheimnisse.ts`: AES-256-GCM aus `APP_SECRET` (scrypt-abgeleitet), für `bindPasswortVerschluesselt` +- [ ] Erste Migration, `prisma/seed.ts` als idempotenter Rumpf: Admin aus `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` mit `@node-rs/argon2`, eigene Organisation, `Einstellung` mit `Betriebsmodus.INTERN` +- [ ] `tests/setup-env.ts`, `tests/global-setup.ts`, `tests/helpers/db.ts` nach ProxLedger-Muster + +## Akzeptanzkriterien + +- [ ] `prisma migrate deploy` läuft auf leerer Datenbank durch +- [ ] `pnpm db:seed` zweimal hintereinander ohne Fehler und ohne Datenänderung +- [ ] Tests: `tagCode` benutzt nur das erlaubte Alphabet, enthält nie `0`, `O`, `1`, `I` oder `L`, ist eindeutig über 10 000 Ziehungen +- [ ] Tests: verschlüsseln und entschlüsseln ist verlustfrei, zwei Verschlüsselungen desselben Klartexts unterscheiden sich (eigener IV), ein manipulierter Geheimtext wirft +- [ ] `next build` gelingt ohne gesetzte `DATABASE_URL` diff --git a/docs/issues/007-auth-rollen-benutzer.md b/docs/issues/007-auth-rollen-benutzer.md new file mode 100644 index 0000000..77d4dbe --- /dev/null +++ b/docs/issues/007-auth-rollen-benutzer.md @@ -0,0 +1,33 @@ +# #007 M2 – Auth, Rollen, Benutzer + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, backend, frontend, security +**Geschätzt:** 8 h **Tatsächlich:** – + +## Ziel + +Anmeldung per generischem OIDC (Ziel authentik) und als Notfallzugang lokal per E-Mail und Passwort, dazu die serverseitige Rechteprüfung, auf der alle weiteren Services aufsetzen. + +## Aufgaben + +- [ ] `src/server/auth/oidc.ts`: `openid-client` 6.x mit Discovery, Umgebungsvariablen `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_NAME`; Rücksprung-URL aus `APP_URL` abgeleitet, damit es hinter dem Reverse Proxy stimmt +- [ ] `src/lib/sitzung.ts`: `jose`-signiertes Sitzungscookie (HS256, `APP_SECRET` mit mindestens 32 Zeichen erzwungen, Hinweis auf `openssl rand -base64 48`), Nutzlast `{benutzerId, email, name, herkunft}`, Laufzeit 12 h +- [ ] Lokaler Login mit `@node-rs/argon2`, nur aktiv bei `AUTH_LOCAL_ENABLED=true` +- [ ] Rolle bei jedem OIDC-Login aus dem `groups`-Claim über `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` aktualisieren; JIT-Provisioning unbekannter Benutzer beim ersten Login +- [ ] `src/server/auth/rollen.ts`: `RECHTE` als eine Tabelle (`satisfies Record`) plus `darf()` und `ROLLEN_TEXT` +- [ ] `src/server/auth/sitzungspflicht.ts`: `angemeldet()` (React-`cache`, ohne Weiterleitung) und `rechtErforderlich(recht)`; Rolle und Aktiv-Status pro Request **aus der Datenbank**, nicht aus dem Token; `sitzungenUngueltigAb` für Widerruf; reine, testbare `sitzungBewerten()` +- [ ] `src/proxy.ts`: Allowlist öffentlicher Pfade, Weiterleitung auf `/anmelden?ziel=…` +- [ ] Login-Seite im Design, OIDC-Knopf und lokales Formular, Rücksprung auf die ursprünglich angeforderte URL (nötig für `/t/[tagCode]`) +- [ ] Admin → Benutzer: Liste mit Rolle und Aktiv-Status, lokale Benutzer anlegen wenn lokaler Login aktiv +- [ ] Screenshots Login und Benutzerliste in `docs/screenshots/m2/` + +## Akzeptanzkriterien + +- [ ] Anmeldung mit dem Seed-Admin gelingt, Abmelden beendet die Sitzung +- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist das lokale Formular nicht vorhanden und der Endpunkt lehnt ab +- [ ] Tests fahren jede Rolle gegen jedes Recht: `LESER` darf nichts schreiben, `TECHNIKER` darf keine Verwaltung, `REDAKTEUR` darf Vorlagen, `ADMIN` alles +- [ ] Eine Rollenänderung in der Datenbank greift ohne neue Anmeldung +- [ ] Ein deaktivierter Benutzer wird mit bestehendem Cookie abgewiesen +- [ ] Aufruf von `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login genau dorthin zurück +- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie im Klartext und nie in einer API-Antwort diff --git a/docs/issues/008-betriebsmodus-kunden.md b/docs/issues/008-betriebsmodus-kunden.md new file mode 100644 index 0000000..168c5b5 --- /dev/null +++ b/docs/issues/008-betriebsmodus-kunden.md @@ -0,0 +1,33 @@ +# #008 M3 – Betriebsmodus und Kunden + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, backend, frontend +**Geschätzt:** 6 h **Tatsächlich:** – + +## Ziel + +Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vollständig unsichtbar, ohne dass irgendwo Daten fehlen — dadurch ist der Wechsel später eine Datenänderung und keine Migration. + +## Aufgaben + +- [ ] `betriebsmodus()` als zentraler, gecachter Zugriff auf `Einstellung.modus`; alle Listen und Formulare fragen ihn ab +- [ ] `kundeErmitteln(eingabe)`: im Modus `INTERN` immer die eigene Organisation, eine mitgeschickte `kundeId` wird **verworfen** +- [ ] Moduswechsel-Service: `INTERN → DIENSTLEISTUNG` jederzeit, die eigene Organisation wird zum ersten Kunden (Name und Kürzel werden abgefragt), alle Daten bleiben daran hängen +- [ ] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis +- [ ] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN` +- [ ] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen +- [ ] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen" +- [ ] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung +- [ ] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes +- [ ] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/` + +## Akzeptanzkriterien + +- [ ] Im Modus `INTERN` erscheint die eigene Organisation in keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett +- [ ] Test: eine Server Action mit mitgeschickter fremder `kundeId` schreibt im Modus `INTERN` trotzdem auf die eigene Organisation +- [ ] Test: Wechsel `INTERN → DIENSTLEISTUNG` erhält alle Geräte, Mitarbeiter, Abteilungen und Läufe am ersten Kunden +- [ ] Test: Wechsel `DIENSTLEISTUNG → INTERN` wird bei zwei nicht archivierten Kunden mit Hinweis abgelehnt, bei einem gelingt er +- [ ] Test: ein Wechsel schreibt einen `Protokoll`-Eintrag mit Vorher- und Nachher-Zustand +- [ ] Test: `LESER` und `TECHNIKER` dürfen den Modus nicht wechseln +- [ ] Der Modus liegt in der Datenbank und ist ohne Neustart umschaltbar diff --git a/docs/issues/009-vorlagen-versionierung.md b/docs/issues/009-vorlagen-versionierung.md new file mode 100644 index 0000000..c2955b3 --- /dev/null +++ b/docs/issues/009-vorlagen-versionierung.md @@ -0,0 +1,32 @@ +# #009 M4 – Vorlagen-Versionierung im Service + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, backend +**Geschätzt:** 6 h **Tatsächlich:** – + +## Ziel + +Die harte Regel der Anwendung: eine veröffentlichte Version ist für immer unveränderlich, damit ein Archiv-Lauf für immer nachvollziehbar bleibt. Diese Regel sitzt in der Service-Schicht, nicht in der Oberfläche. + +## Aufgaben + +- [ ] Services für `Vorlage`, `VorlagenVersion`, `VorlagenAbschnitt`, `VorlagenSchritt`, `VorlagenVariable` +- [ ] `vorlageVeroeffentlichenService`: setzt `status = VEROEFFENTLICHT`, `veroeffentlichtAm`, `veroeffentlichtVonId`, Änderungsnotiz, `Protokoll`-Eintrag +- [ ] `vorlageBearbeitenService`: tiefe Kopie der letzten veröffentlichten Version als neuer Entwurf mit fortlaufender `version`; kopiert Variablen, Abschnitte und Schritte samt `konfig` +- [ ] Höchstens ein Entwurf je Vorlage — zweiter Versuch wirft `VorlagenFehler` mit Verweis auf den bestehenden Entwurf +- [ ] Jede Schreiboperation auf einer veröffentlichten Version wirft; zentral in `versionMussEntwurfSein()` +- [ ] Läufe verweisen ausschließlich auf veröffentlichte Versionen — beim Anlegen geprüft +- [ ] `vorlageDuplizierenService`: neue Vorlage mit Entwurf als Kopie +- [ ] JSON-Export und -Import mit Zod-Schema, Format in `docs/vorlagen-format.md` dokumentiert (Version des Formats, Abschnitte, Schritte, Variablen, `konfig`) +- [ ] Rechte: `REDAKTEUR` und `ADMIN` dürfen anlegen, bearbeiten, veröffentlichen; `TECHNIKER` und `LESER` nicht + +## Akzeptanzkriterien + +- [ ] Test: nach dem Veröffentlichen schlägt jede Änderung an Version, Abschnitten, Schritten und Variablen fehl +- [ ] Test: „Bearbeiten" erzeugt eine tiefe Kopie — Änderungen am Entwurf lassen die veröffentlichte Version unberührt, auch in verschachtelten `konfig`-Feldern +- [ ] Test: ein zweiter Entwurf wird abgelehnt +- [ ] Test: ein Lauf auf Version 1 bleibt nach Veröffentlichen von Version 2 unverändert und zeigt weiter die Schritte aus Version 1 +- [ ] Test: Export und anschließender Import ergeben eine inhaltsgleiche Vorlage +- [ ] Test: `TECHNIKER` darf nicht veröffentlichen +- [ ] Test: Veröffentlichen schreibt einen `Protokoll`-Eintrag diff --git a/docs/issues/010-vorlagen-editor.md b/docs/issues/010-vorlagen-editor.md new file mode 100644 index 0000000..6f3a48c --- /dev/null +++ b/docs/issues/010-vorlagen-editor.md @@ -0,0 +1,31 @@ +# #010 M5 – Vorlagen-Editor + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, frontend +**Geschätzt:** 10 h **Tatsächlich:** – + +## Ziel + +Der Editor, in dem ein Redakteur eine Vorlage baut, ohne den Kopf über Versionen zu verlieren. + +## Aufgaben + +- [ ] Vorlagenliste mit Suche, Filter nach Kategorie und Tag, Kennzeichnung „Entwurf vorhanden" +- [ ] Editor: Abschnitte und Schritte anlegen, bearbeiten, löschen; Abschnitte mit zweistelliger Nummer (`01 · Vorbereitung`) +- [ ] Sortieren per Drag & Drop mit `@dnd-kit`, auch am Handy mit dem Finger bedienbar +- [ ] Je Schritt: Titel, Typ (`HAKEN`, `TEXT`, `ZAHL`, `AUSWAHL`, `DATUM`, `BEFEHL`), Pflicht (roter Stern), Hilfetext als Markdown, Doku-Link +- [ ] Typabhängige `konfig`-Formulare: Auswahloptionen, Min/Max/Einheit, bei `BEFEHL` die Shell (`powershell`/`bash`/`cmd`), `multiline` und der Befehlstext +- [ ] Variablen definieren: `key`, Label, Typ, Optionen, Pflicht, Position +- [ ] Vorschau der Vorlage wie im Lauf, mit Beispielwerten +- [ ] Veröffentlichen mit Änderungsnotiz, Versionshistorie ansehen, Vorlage duplizieren, JSON-Export und -Import +- [ ] Warnung im Editor bei unbekannten Platzhaltern in Titel, Hilfetext und Befehlstext +- [ ] Screenshots Liste, Editor, Historie in `docs/screenshots/m5/` + +## Akzeptanzkriterien + +- [ ] Eine Vorlage lässt sich vollständig ohne Umweg über die Datenbank anlegen, sortieren, veröffentlichen +- [ ] Eine veröffentlichte Version ist im Editor nur lesend; „Bearbeiten" erzeugt sichtbar einen Entwurf +- [ ] Unbekannte Platzhalter werden als Warnung angezeigt, blockieren aber das Speichern nicht +- [ ] Sortieren funktioniert am Handy bei 390 px Breite ohne horizontales Scrollen +- [ ] Der Markdown-Hilfetext rendert ohne rohes HTML diff --git a/docs/issues/011-mitarbeiter-abteilungen-geraete.md b/docs/issues/011-mitarbeiter-abteilungen-geraete.md new file mode 100644 index 0000000..278e441 --- /dev/null +++ b/docs/issues/011-mitarbeiter-abteilungen-geraete.md @@ -0,0 +1,33 @@ +# #011 M6 – Mitarbeiter, Abteilungen, Geräte + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, backend, frontend +**Geschätzt:** 9 h **Tatsächlich:** – + +## Ziel + +Die Stammdaten, an denen die Historie hängt. Mitarbeiter und Geräte werden nie gelöscht, damit archivierte Läufe für immer auf sie zeigen können. + +## Aufgaben + +- [ ] Mitarbeiterliste: Suche über Anzeigename, Mail, UPN und `sam`, Filter nach Abteilung und Aktiv-Status +- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte" fürs Offboarding +- [ ] Mitarbeiterseite: Stammdaten, Abteilung, Vorgesetzter, seine Geräte, alle Läufe chronologisch (Onboarding, Übergaben, Offboarding) +- [ ] Mitarbeiter manuell anlegen und bearbeiten (`herkunft = MANUELL`); synchronisierte Felder sind bei `herkunft = ABGLEICH` gesperrt +- [ ] Deaktivieren statt löschen (`aktiv = false`) +- [ ] Abteilungen: Name (eindeutig je Kunde), Ziel-OU, Standardgruppen (`String[]`), Mail-Domain, Notizen +- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein", Besitzer optional +- [ ] Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und `tagCode`, Filter nach Typ +- [ ] CSV-Import für Geräte (`hostname`, `label`, `serialNumber`, `inventoryNumber`, `type`, `model`), `tagCode` wird beim Import erzeugt; eigener `src/lib/csv.ts` +- [ ] Archivieren statt Löschen, sobald Läufe an einem Gerät oder einer Abteilung hängen +- [ ] Screenshots Mitarbeiterliste, Mitarbeiterseite, Geräteliste, Abteilungen in `docs/screenshots/m6/` + +## Akzeptanzkriterien + +- [ ] Test: Mitarbeitersuche liefert standardmäßig nur `aktiv = true`; mit Umschalter auch deaktivierte +- [ ] Test: ein Gerät ohne `hostname` und ohne `bezeichnung` wird abgelehnt +- [ ] Test: ein Gerät mit Läufen lässt sich nicht löschen, nur archivieren +- [ ] Test: `tagCode` eines bestehenden Geräts lässt sich nicht ändern +- [ ] Test: CSV-Import erzeugt je Zeile einen `tagCode`, meldet fehlerhafte Zeilen einzeln und bricht nicht ab +- [ ] Test: bei `herkunft = ABGLEICH` lehnt der Service Änderungen an synchronisierten Feldern ab diff --git a/docs/issues/012-platzhalter-namensregeln.md b/docs/issues/012-platzhalter-namensregeln.md new file mode 100644 index 0000000..5989b51 --- /dev/null +++ b/docs/issues/012-platzhalter-namensregeln.md @@ -0,0 +1,36 @@ +# #012 M7 – Platzhalter, Namensregeln, Escaping + +**Status:** offen +**Meilenstein:** v0.1.0 +**Labels:** phase-1, backend, security +**Geschätzt:** 8 h **Tatsächlich:** – + +## Ziel + +Reine, framework-freie Service-Schicht mit dichter Testabdeckung. Hier entstehen die fertigen PowerShell-Befehle, die der Techniker kopiert — und hier darf nichts schiefgehen, weder beim Escaping noch beim generierten Passwort. + +## Aufgaben + +- [ ] Platzhalter-Auflösung `{{key}}` in Schritt-Titel, Hilfetext und Befehlstext +- [ ] Eingebaut aus Gerät und Lauf: `{{hostname}}`, `{{seriennummer}}`, `{{inventarnummer}}`, `{{ticket}}`, `{{datum}}`, `{{techniker}}`; im Modus `DIENSTLEISTUNG` zusätzlich `{{kunde}}` und `{{kundenkuerzel}}`, im Modus `INTERN` leer +- [ ] Eingebaut aus dem Mitarbeiter: `{{mitarbeiter}}` (Alias `{{benutzer}}`), `{{vorname}}`, `{{nachname}}`, `{{upn}}`, `{{sam}}`, `{{mail}}`, `{{abteilung}}`, `{{titel}}`, `{{vorgesetzter}}`, `{{vorgesetzter_sam}}`, `{{dn}}`, `{{ou}}` +- [ ] Aus der gewählten Abteilung: `{{ou}}` falls nicht vom Mitarbeiter, `{{gruppen}}` kommagetrennt +- [ ] `{{ou}}` eines synchronisierten Mitarbeiters ist der Eltern-Container seines `dn` (alles nach dem ersten Komma) +- [ ] Dazu die in der Vorlage definierten Variablen; unbekannte Platzhalter bleiben sichtbar stehen +- [ ] `{{generiertes_passwort}}`: 20 Zeichen aus Groß, Klein, Ziffern und Sonderzeichen ohne verwechselbare Zeichen, je Anzeige neu erzeugt, **nie** gespeichert; Hinweis „Passwort sofort in den Passwortmanager übernehmen"; in der Druckansicht `[Passwort – nicht gespeichert]` +- [ ] Namensregeln (`Einstellung.namensregeln` global, im Modus `DIENSTLEISTUNG` zusätzlich `Kunde.namensregeln` mit Fallback): `upn`, `sam` (höchstens 20 Zeichen), `mail`, `hostname` +- [ ] Transliteration (ä→ae, ö→oe, ü→ue, ß→ss, Akzente entfernt), Kleinschreibung, Leerzeichen entfernt, `{nnn}`-Zähler, `{v}` für den ersten Buchstaben des Vornamens, `{kundenkuerzel}` +- [ ] Shell-Escaping je Shell: PowerShell (doppelte Anführungszeichen verdoppeln, Backtick maskieren), Bash, Cmd +- [ ] HTML-Escaping ersetzter Werte; Markdown im Hilfetext sicher gerendert, kein rohes HTML +- [ ] „Alle Befehle als Skript kopieren" je Abschnitt: alle `BEFEHL`-Schritte in Reihenfolge, getrennt durch Leerzeilen, je mit `# Schritt: ` davor, Zeilenumbruch passend zur Shell + +## Akzeptanzkriterien + +- [ ] Test: „Jörg Müller-Lüdenscheid" ergibt `joerg.mueller-luedenscheid@firma.de` und `jmuellerluedensch` (auf 20 Zeichen gekürzt) +- [ ] Test: `{{generiertes_passwort}}` erscheint im angezeigten Befehl, steht aber nicht in `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht; zwei Aufrufe liefern verschiedene Passwörter +- [ ] Test: ein Wert mit `"` und `` ` `` wird für PowerShell korrekt maskiert und bricht den Befehl nicht +- [ ] Test: unbekannte Platzhalter bleiben als `{{key}}` stehen +- [ ] Test: `{{ou}}` wird aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` zu `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` +- [ ] Test: Skript-Kopie liefert die Befehle in Abschnittsreihenfolge mit Kommentarzeilen +- [ ] Test: im Modus `INTERN` sind `{{kunde}}` und `{{kundenkuerzel}}` leer +- [ ] Test: ein Wert mit `