diff --git a/.gitignore b/.gitignore index 7f2b7ea..502034c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,12 @@ blob-report/ .env.* !.env.example +# Die Anleitung als PDF entsteht aus den Markdown-Kapiteln und den Bildern, die +# beide im Repo liegen (`pnpm docs:pdf`). Ein Erzeugnis von 8,7 MB, das sich bei +# jedem Lauf vollstaendig aendert, gehoert nicht in die Versionsgeschichte — dem +# Release liegt es bei, so steht es im Auftrag. +/docs/anleitung/*.pdf + # Werkzeuge *.tsbuildinfo .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec4eec..2b87be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,9 +171,22 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Namensregeln und die Sperr-OU kommen mit dem Seed: „Namen aus den Regeln vorschlagen" schlägt für Maria Huber genau `m.huber@firma.example` und `mhuber` vor — dieselben Werte, die im Beispielbestand stehen (#017) +- Anleitung für Technik und Verwaltung unter `docs/anleitung/`: neun Kapitel von den ersten Schritten + bis zur Fehlerbehebung, mit Bildern, die `pnpm docs:screenshots` reproduzierbar neu erzeugt (#018) +- `pnpm docs:pdf` macht daraus ein A4-PDF mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen — hell + gedruckt, auch wenn die App dunkel ist (#018) +- Screenshots lassen sich markieren: Rahmen in der Akzentfarbe mit Nummer, für die Stellen, die ein + Kapitel gerade beschreibt (#018) - `{{ou_gesperrt}}` wird aus Admin → Einstellungen vorbelegt. Das Feld war gespeichert und wurde von keinem Platzhalter gelesen; `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer, obwohl die Einstellungsseite es versprach (#017) +- Die Ende-zu-Ende-Abläufe prüfen ihre Voraussetzungen, bevor der erste startet: läuft ein gebauter + Stand, steht der Seed-Bestand, und liest der Server **dieselbe** Datenbank wie der Testprozess. + Vorher fiel eine fehlende Voraussetzung erst mitten im Lauf auf — als Zeitablauf oder als „0 + Treffer" (#018) +- Jeder Ablauf legt an, was er braucht, und räumt es weg. Keiner setzt mehr voraus, dass die + Datenbank schon Läufe, Entwürfe oder bestimmte Personen enthält, und keiner sucht seine Vorlage + über die Position in der Liste (#018) ### Sicherheit @@ -301,3 +314,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). Passwortmanager legte, sperrte den neuen Mitarbeiter aus (#017) - Der Seed vergibt keinen verbrannten Tag-Code neu: er fragt das Vergabebuch und nicht nur die Geräteliste, wie es der Gerätedienst tut (#017) +- Der Verzeichnis-Ablauf legt seine eigene Quelle an, statt die erste vorhandene zu nehmen: er hätte + in einer echten Installation die Attributzuordnung der produktiven Quelle überschrieben (#018) +- Ein Nachlauf setzte die Namensregeln auf leer, statt den vorherigen Wert wiederherzustellen — er + löschte damit, was der Seed eingerichtet hatte (#018) diff --git a/README.md b/README.md index 631062c..e520463 100644 --- a/README.md +++ b/README.md @@ -177,13 +177,34 @@ einlesen. ## 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. +Jede Variable steht mit Erklärung in [`.env.example`](.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 `/.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 Techniker und Admins — entsteht mit #018, inkl. Bildern und PDF +- [Anleitung für Technik und Verwaltung](docs/anleitung/) — acht Kapitel mit Bildern. `pnpm docs:pdf` + macht daraus ein PDF; jedem Release liegt es bei - [Architektur und Entscheidungen](docs/architektur.md) - [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht - [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019 diff --git a/docs/anleitung/01-erste-schritte.md b/docs/anleitung/01-erste-schritte.md new file mode 100644 index 0000000..fb39a48 --- /dev/null +++ b/docs/anleitung/01-erste-schritte.md @@ -0,0 +1,88 @@ +# Erste Schritte + +## Anmelden + +Ruf die Adresse auf, die deine IT dir gegeben hat. Ohne Anmeldung landest du immer auf der +Anmeldeseite. + +![Die Anmeldeseite mit Feldern für E-Mail und Passwort](bilder/anmelden-desktop.png) + +Es gibt bis zu zwei Wege, und welche du siehst, hängt von der Einrichtung ab: + +- **Mit … anmelden** — der Knopf oben trägt den Namen deines Anmeldeanbieters, etwa „Mit authentik + anmelden“. Das ist der normale Weg: du gibst dein Passwort dort ein, nicht hier. +- **E-Mail und Passwort** — ein lokales Konto, gedacht als Notfallzugang, wenn der Anbieter + ausfällt. Felder: **E-Mail**, **Passwort**, dann **Anmelden**. + +Steht dort „Es ist kein Anmeldeweg eingerichtet“, ist die Anwendung noch nicht fertig +konfiguriert — das löst ein Administrator. + +Warst du auf einer bestimmten Seite und deine Anmeldung ist abgelaufen, kommst du nach dem +Anmelden genau dorthin zurück. Das gilt auch für einen gescannten Aufkleber: du landest hinterher +auf der Geräteseite und nicht auf der Übersicht. + +**Abmelden** geht über das Symbol neben deinem Namen, unten in der Seitenleiste; am Handy steht es +im Menü. + +## Der Aufbau der Oberfläche + +Am Schreibtisch liegt links eine dunkle Seitenleiste, rechts der Inhalt. Ganz oben in der Leiste +sitzt der Knopf **Scannen** — dort fängt der Techniker an. Darunter die Navigation: + +| Eintrag | Was dort steht | +|---|---| +| **Übersicht** | was dringend ist, was bei dir liegt, woran zuletzt gearbeitet wurde | +| **Läufe** | alle offenen, laufenden und abgeschlossenen Läufe | +| **Vorlagen** | die Checklisten und ihre Versionen | +| **Mitarbeiter & Geräte** | Personen und Geräte, über zwei Reiter umschaltbar | +| **Kunden** | nur im Modus Dienstleistung | +| **Archiv** | Volltextsuche über abgeschlossene und archivierte Läufe | +| **Admin** | Einstellungen, Benutzer, Abteilungen, Verzeichnis — nur für Administratoren | + +Was deine Rolle nicht darf, steht gar nicht in der Leiste. Ein ausgegrauter Eintrag heißt dagegen: +diese Seite gibt es noch nicht. + +Unten in der Leiste stehen dein Name, deine Rolle, der Knopf zum Abmelden und die Versionsnummer. + +Am Handy wird aus der Leiste eine Kopfzeile: links das Zeichen, rechts der Knopf **Scannen** und +daneben das Menü mit denselben Einträgen. + +![Die Übersicht am Handy mit Kopfzeile, Scannen-Knopf und Menü](bilder/uebersicht-handy.png) + +## Die Übersicht + +Die Übersicht ist der Einstieg am Schreibtisch — das Gegenstück zum Aufkleber am Gerät. Sie ist +nach Dringlichkeit sortiert und zeigt vier Blöcke, jeder mit höchstens fünf Läufen: + +- **Überfällig** — Läufe, deren Fälligkeitsdatum verstrichen ist und die noch offen oder in Arbeit + sind. Das ist das Einzige auf der Seite in Warnfarbe, und das ist Absicht: riefe jeder Block, + riefe keiner. +- **Mir zugewiesen** — was auf dich wartet. Zugewiesen wird beim Anlegen eines Laufs oder auf der + Geräteseite. +- **Zuletzt bearbeitet** — woran zuletzt gearbeitet wurde, auch von Kolleginnen und Kollegen. +- **Schnellstart** — die Vorlagen, aus denen hier am häufigsten Läufe entstehen. Ein Klick führt + in den Start-Dialog. Der Block erscheint nur, wenn du Läufe bearbeiten darfst. + +Jede Zeile trägt den Status, bei Bedarf die Frist und einen Fortschrittsbalken („7 von 21“). Ein +Klick öffnet den Lauf. Leere Blöcke verschwinden nicht, sondern sagen, warum sie leer sind — eine +Seite, deren Abschnitte kommen und gehen, lässt sich nicht lesen. + +## Die vier Rollen + +Rechte hängen allein an der Rolle, und geprüft wird jede Anfrage neu gegen die Datenbank. Eine +Rollenänderung wirkt also sofort, ohne neues Anmelden. + +| Rolle | Darf | +|---|---| +| **Leser** | alles ansehen: Läufe, Vorlagen, Geräte, Personen, Archiv | +| **Techniker** | dazu Läufe starten, abarbeiten, abschließen und archivieren; Geräte und Personen pflegen; Tags registrieren | +| **Redakteur** | dazu Vorlagen anlegen, bearbeiten und veröffentlichen | +| **Administrator** | alles: Benutzer, Verzeichnis, Abteilungen, Einstellungen, Kunden, Tag-Stapel, Geräte löschen — und als Einziger einen Lauf aus dem Archiv zurückholen | + +Zwei Dinge liegen bewusst nur beim Administrator: das **Zurückholen aus dem Archiv**, weil das +Archiv die Zusage ist, dass sich ein abgeschlossener Lauf nicht mehr ändert, und das **endgültige +Löschen eines Geräts**, weil dabei ein Tag-Code für immer verbrennt. + +Wichtig zu unterscheiden: **Benutzer** sind die Menschen, die Laufzettel bedienen und sich +anmelden. **Mitarbeiter** (im Modus Dienstleistung: **Personen**) sind die Leute, für die Läufe +geführt werden — die kommen meist aus dem Verzeichnis und melden sich hier nie an. diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md new file mode 100644 index 0000000..269f3dd --- /dev/null +++ b/docs/anleitung/02-laeufe-abarbeiten.md @@ -0,0 +1,269 @@ +# Läufe abarbeiten + +Ein Lauf ist eine Checkliste für einen konkreten Fall. Er wird vorbereitet, abgearbeitet, +abgeschlossen und irgendwann archiviert. Dieses Kapitel geht diesen Weg von vorn nach hinten +durch — am Schreibtisch und am Handy. + +Ein Lauf hat vier Zustände: + +| Zustand | Bedeutung | +|---|---| +| **offen** | vorbereitet und meist jemandem zugewiesen. Niemand arbeitet daran. | +| **in Arbeit** | jemand hat ihn begonnen. | +| **abgeschlossen** | fertig für jetzt. Notizen und Kommentare gehen weiter. | +| **archiviert** | schreibgeschützt, für immer. | + +„Abgeschlossen“ und „archiviert“ sind bewusst zwei Dinge. Nach dem Abschluss lässt sich noch eine +Seriennummer berichtigen oder ein Kommentar nachtragen — das passiert oft. Erst das **Archivieren** +macht einen Lauf unveränderlich. + +## Die Laufliste + +![Die Laufliste mit Filtern und Fortschrittsbalken je Lauf](bilder/laeufe-liste-desktop.png) + +Unter **Läufe** stehen alle offenen, laufenden und abgeschlossenen Läufe. Archivierte stehen hier +nicht — die findet das [Archiv](#im-archiv-suchen). + +Gefiltert wird über die Zeile darüber: **Suche** (Titel, Ticket, Hostname oder Person), **Status** +und das Häkchen **nur meine**. Danach **Filtern**; **zurücksetzen** räumt alles weg. Der Filter +steht in der Adresse, ein Lesezeichen darauf funktioniert also. + +Jede Zeile zeigt Titel, Status, gegebenenfalls „seit … fällig“, die Vorlage mit ihrer Version, das +Gerät, die Person und den Fortschritt. Es werden höchstens 200 Läufe gezeigt; fehlt deiner, such +nach Titel, Ticket, Hostname oder Person. + +## Einen Lauf anlegen + +Es gibt drei Wege, und der schnellste ist meist nicht dieser: + +- **Am Gerät**: Aufkleber scannen, auf der Geräteseite eine Schnellstart-Kachel antippen. Der Lauf + entsteht und beginnt sofort, mit Gerät und Besitzer, ohne eine einzige Eingabe. Siehe + [Kapitel 03](03-qr-tags.md). +- **Aus der Übersicht**: der Block **Schnellstart** führt mit vorgewählter Vorlage in den Dialog. +- **Über den Knopf Lauf starten** in der Laufliste — der vollständige Weg, hier beschrieben. + +![Der Start-Dialog mit Vorlage, Gerät, Person und den Angaben zum Lauf](bilder/lauf-starten-desktop.png) + +Der Dialog fragt in der Reihenfolge, in der man denkt: + +1. **Vorlage** — es stehen nur veröffentlichte Vorlagen zur Wahl, jeweils die zuletzt + veröffentlichte Fassung. Steht dort nichts, gibt es noch keine veröffentlichte Vorlage; ein + Entwurf ist noch niemandes Arbeitsanweisung. +2. **Gerät (optional)** — oder „kein Gerät“. Wählst du ein Gerät, wird sein Besitzer als Person + vorgeschlagen. +3. **Person (optional)** — jemand aus dem Verzeichnis, oder „keine Person aus dem Verzeichnis“. +4. **… oder Name als Freitext** — für ein Onboarding, bevor die Person im Verzeichnis steht. Eines + von beidem, nicht beides. Der Freitextname lässt sich später mit dem AD-Konto verknüpfen. +5. **Angaben zu diesem Lauf** — die Variablen der Vorlage. Ein Sternchen heißt Pflicht. Fragt die + Vorlage nach `upn`, `sam`, `mail` oder `hostname`, gibt es den Knopf **Namen aus den Regeln + vorschlagen**: er füllt leere Felder nach den Namensregeln des Hauses, ausgefüllte bleiben + stehen. +6. Aufgeklappt hinter **Titel, Ticket, Abteilung, Zuweisung und Fälligkeit** stehen die Beigaben. + Bleibt der **Titel** leer, bildet Laufzettel ihn aus Vorlage, Gerät oder Person und Datum. Die + **Abteilung** ist wichtiger, als sie aussieht: sie speist `{{ou}}` und `{{gruppen}}` in den + Befehlen dieses Laufs. +7. Im Modus Dienstleistung außerdem der **Kunde**. + +**Lauf anlegen** legt ihn an und springt hinein. Er beginnt als **offen** — das ist die Zuweisung. +Gestartet wird er erst, wenn ihn jemand zur Bearbeitung öffnet. + +Ein Lauf hängt für immer an der Version, mit der er gestartet wurde. Eine spätere Veröffentlichung +ändert ihn nicht, und den Wechsel auf eine neuere Version weist Laufzettel ausdrücklich ab. Für +eine neuere Fassung startest du einen neuen Lauf. + +## Die Abarbeitungsansicht + +![Ein Lauf in Arbeit mit Abschnitten, Schritten und Fortschrittsbalken](bilder/lauf-abarbeiten-desktop.png) + +Oben stehen Titel, Vorlage mit Version, Gerät, Person und Ticket, darunter der Fortschrittsbalken +und die Knöpfe. Welcher Knopf da ist, hängt vom Zustand ab: + +| Zustand | Knopf | +|---|---| +| offen | **Lauf beginnen** | +| in Arbeit | **Lauf abschließen** | +| abgeschlossen | **archivieren** | +| archiviert | **aus dem Archiv zurückholen** (nur Administrator) | + +Daneben immer: der Umschalter **Kompakt** / **Ausführlich** und **Drucken**. + +**Lauf beginnen** setzt den Lauf auf „in Arbeit“ und hält den Startzeitpunkt fest. Ein Lauf, den +jemand nur aufschlägt, hat noch nicht begonnen — sonst sähe die Liste nach Arbeit aus, die niemand +tut. + +**Ausführlich** zeigt die Hilfetexte der Schritte, **Kompakt** blendet sie alle aus. Beim ersten +Mal sind sie der eigentliche Wert, beim dritten Mal stehen sie im Weg. + +### Abschnitte + +Die Schritte stecken in Abschnitten mit zweistelliger Nummer: `01 · Vorbereitung`. Die Nummer kommt +aus der Position und steht nirgends gespeichert. Ein Klick auf die Überschrift klappt den Abschnitt +zu oder auf; daneben steht „3 von 7“. Abschnitte, in denen nichts mehr zu tun ist, sind beim +Aufbau der Seite schon zu. + +Enthält ein Abschnitt Befehlsschritte, gibt es **Alle Befehle als Skript kopieren** — einmal +kopieren statt achtmal. + +### Einen Schritt erledigen + +Unter jedem Schritt stehen drei Pillen: + +- **erledigt** — getan. +- **nicht zutreffend** — trifft in diesem Fall nicht zu. Verlangt eine Begründung. +- **fehlgeschlagen** — hat nicht funktioniert. Verlangt eine Begründung. + +„erledigt“ speichert mit einem Klick. Bei den anderen zwei erscheint erst das Feld **Begründung**, +dann der Knopf **Mit Begründung speichern**. Ohne Begründung steht im Archiv ein übersprungener +Schritt ohne Grund — und genau das macht ein Archiv wertlos. + +Ein Klick auf die bereits gesetzte Pille nimmt die Angabe zurück; das Zurücknehmen braucht keine +Begründung. + +**Jeder Schritt speichert einzeln.** Es gibt kein Gesamtformular und keinen Speichern-Knopf am +Ende: ein verlorener Klick soll nicht zwei Stunden Haken kosten. + +Ein rotes Sternchen am Titel heißt **Pflichtschritt**. Ein Pflichtschritt muss erledigt oder +ausdrücklich als nicht zutreffend eingetragen sein, bevor sich der Lauf abschließen lässt. + +### Werte erfassen + +Je nach Schritt-Typ steht über den Pillen ein Eingabefeld — Text, Zahl, Auswahl oder Datum. Getippt +wird, dann **Wert speichern**. Das ist ein eigener Knopf und kein automatisches Speichern: ein halb +getippter Wert soll nicht in die Datenbank. + +Eine Zahl außerhalb der erwarteten Grenzen wird **markiert, nicht abgewiesen** („Erwartet werden 8 +bis 64 GB“). Ein Rechner mit 4 GB statt der erwarteten 8 ist ein Befund, den der Lauf festhalten +soll, kein Eingabefehler. + +### Befehle kopieren + +![Ein Befehlsschritt mit fertigem PowerShell-Befehl und dem Knopf zum Kopieren](bilder/lauf-befehlsschritt-desktop.png) + +Ein Schritt vom Typ **Befehl** zeigt den fertigen Befehl in Monospace, mit allen Platzhaltern +eingesetzt. **Befehl kopieren** legt ihn in die Zwischenablage, der Knopf bestätigt kurz mit +„Kopiert.“. Der Text steht vollständig da, du kannst ihn also auch markieren. + +Drei Hinweise können daneben stehen: + +- **Ein Platzhalter ließ sich nicht einsetzen** — er steht als `{{name}}` im Befehl und muss vor + dem Ausführen von Hand ersetzt werden. Das ist eine Warnung und keine Sperre; vielleicht setzt du + den Wert selbst ein. +- **Ein Passwort** — enthält der Befehl `{{generiertes_passwort}}`, entsteht das Passwort bei jeder + Anzeige neu und wird **nirgends gespeichert**: nicht im Lauf, nicht im Kommentar, nicht im + Protokoll, nicht im Ausdruck. Wer es nicht sofort weiterreicht, hat es verloren. Sobald sich die + Seite neu aufbaut — auch nach dem Speichern eines Schritts — steht dort ein anderes. +- **Der Befehl ließ sich nicht sicher füllen** — dann gibt es keinen Kopier-Knopf, sondern eine + Fehlermeldung. Ein halb maskierter Befehl zum Kopieren wäre die gefährlichste aller Antworten. + +### Einen ganzen Abschnitt überspringen + +Am Ende jedes Abschnitts sitzt **Abschnitt trifft nicht zu**. Der Fall aus dem Alltag: im +Onboarding gibt es zwei Konto-Abschnitte, und je nach Kunde trifft einer gar nicht zu. Du gibst +eine Begründung ein und drückst **Abschnitt vermerken** — die Begründung steht danach an jedem +Schritt des Abschnitts. Achtzehn Haken einzeln mit derselben Begründung zu versehen macht niemand, +und wer es nicht macht, lässt sie weg. + +### Kommentare + +Zwei Orte, zwei Bedeutungen: + +- **Kommentar zu diesem Schritt** — aufklappbar unter dem Schritt, für das, was genau dort + aufgefallen ist. +- **Kommentare zum Lauf** — der Kasten unten, für alles andere. + +Beide fragen dasselbe: „Was sollte man später wissen?“ Dann **Kommentar hinzufügen**. Kommentare +werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen. + +### Zwei Techniker am selben Lauf + +Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas +geändert hat. Bei verstecktem Tab pausiert die Abfrage. + +Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen +erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen +entsteht ein neues Passwort.“ Du entscheidest mit **Neu laden**, wann es passt. + +### Einen Freitextnamen später verknüpfen + +Läuft ein Onboarding auf einen Freitextnamen, erscheint oben **„…“ mit einem AD-Konto verknüpfen**. +Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst im Verzeichnis +entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne +das findet die Person in ihrer Historie später nichts wieder. + +## Abschließen + +**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht +zutreffend“ zählen beide, **fehlgeschlagen nicht** — ein fehlgeschlagener Schritt ist entweder zu +beheben oder mit Begründung als nicht zutreffend einzutragen. Solange etwas offen ist, ist der +Knopf gesperrt und darunter steht, was fehlt. + +Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der +Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie. + +## Archivieren — das ist endgültig + +**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein +Kommentar, keine Notiz, kein Schritt.“ Erst **Ja, archivieren** vollzieht es. + +Danach ist der Lauf schreibgeschützt. Er zeigt für immer, was damals abgearbeitet wurde. Nur ein +**Administrator** holt ihn mit **aus dem Archiv zurückholen** auf „abgeschlossen“ zurück, und das +wird protokolliert. + +Archiviere also erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile: ein +abgeschlossener Lauf steht auch im Archiv und ist durchsuchbar. + +## Drucken + +**Drucken** öffnet einen hellen Beleg auf A4, ohne Navigation, zum Abheften. Im Browser dann +**Jetzt drucken**; wer ein PDF braucht, druckt in eine Datei. + +![Die Druckansicht eines Laufs auf A4, hell und ohne Navigation](bilder/druckansicht-desktop.png) + +Auf dem Blatt stehen Kopfdaten, alle Abschnitte mit Zustand, Wert und Notiz je Schritt und die +Kommentare. **Ein erzeugtes Passwort steht dort nie** — an seiner Stelle steht +`[Passwort – nicht gespeichert]`, und erzeugt wird auch keines. Auf einem Blatt, das jemand +abheftet, darf keines stehen. + +Von der Person stehen nur Name, UPN und Abteilung auf dem Beleg. Ein Ausdruck liegt herum, wird +kopiert und weitergegeben; was darauf steht, ist aus der Hand gegeben. + +## Im Archiv suchen + +![Das Archiv mit Suchfeld, Filtern und Fundstellen im Treffer](bilder/archiv-desktop.png) + +Das **Archiv** durchsucht abgeschlossene und archivierte Läufe — ihren Titel, die Notizen und Werte +der Schritte und die Kommentare. Es zeigt zu jedem Treffer die Fundstelle mit dem Text drumherum: +Wer im Archiv sucht, will nicht wissen, *dass* etwas passt, sondern *wo*. + +Der **Suchbegriff** braucht mindestens zwei Zeichen; ohne Begriff wird nur gefiltert. Hinter +**Weitere Filter** stehen Person, Abteilung, Gerät, Bearbeiter, Kunde und der Zeitraum +(**abgelegt ab** / **abgelegt bis**). Als Ablage gilt der Zeitpunkt der Archivierung, sonst der des +Abschlusses, sonst der des Anlegens — danach ist auch sortiert. Mit dem Häkchen **auch offene und +laufende Läufe** kommen die noch nicht fertigen dazu. + +Gezeigt werden höchstens 100 Treffer; steht das dabei, holt ein engerer Zeitraum oder ein genauerer +Begriff die übrigen hervor. + +## Am Handy + +Die Abarbeitungsansicht ist für das Handy gemacht, weil dort gearbeitet wird: neben dem +aufgeschraubten Rechner, im Serverraum, vor dem Drucker. + +![Ein Lauf am Handy, ein Abschnitt aufgeklappt](bilder/lauf-abarbeiten-handy.png) + +Was du wissen solltest: + +- **Es gibt nie horizontales Scrollen.** Alles steht untereinander, auch bei 390 Pixeln Breite. + Knöpfe und Pillen sind 44 Pixel hoch und mit dem Daumen zu treffen. +- **Schalte auf Kompakt.** Ohne die Hilfetexte passen dreimal so viele Schritte auf den Schirm. +- **Klapp erledigte Abschnitte zu.** Fertige Abschnitte sind beim Öffnen des Laufs schon zu; beim + Nachtragen einer Notiz bleibt ein selbst aufgeklappter Abschnitt offen, auch wenn dazwischen + gespeichert wird. +- **Jeder Klick speichert sofort.** Bei wackligem WLAN ist das der Unterschied zwischen „ein Haken + fehlt“ und „zwei Stunden fehlen“. Bleibt eine Pille nach dem Tippen ungesetzt, war die + Verbindung weg — tippe sie noch einmal. +- **Befehle kopieren** funktioniert auch am Handy, ist dort aber selten nützlich: den + PowerShell-Befehl führst du ohnehin an einem Rechner aus. Nimm dafür den Weg über die + Übersicht am Schreibtisch oder schicke dir den Lauf per Link. +- **Der Einstieg ist der Aufkleber.** Scannen, Geräteseite, „Starten“ — dazu + [Kapitel 03](03-qr-tags.md). diff --git a/docs/anleitung/03-qr-tags.md b/docs/anleitung/03-qr-tags.md new file mode 100644 index 0000000..ab00619 --- /dev/null +++ b/docs/anleitung/03-qr-tags.md @@ -0,0 +1,164 @@ +# QR-Aufkleber und Tags + +Auf jedem Objekt klebt ein Aufkleber mit einem QR-Code und dem Code darunter zum Ablesen, etwa +`LZ-7K3F9Q`. Objekt heißt: nicht nur PCs. Ein Serverschrank, ein Raum, eine Firewall oder ein +Drucker bekommen genauso einen. + +Im QR-Code steht nichts als die Adresse dieser Anwendung plus der Tag-Code. Kein Name, keine +Seriennummer, kein Besitzer — ein Aufkleber hängt sichtbar am Gerät und wird von jedem Telefon +gelesen, auch von einem fremden. Hinter der Adresse verlangt Laufzettel eine Anmeldung. + +**Ein Tag-Code wird nie geändert und nie neu vergeben.** Auch nicht, nachdem das Gerät archiviert +oder gelöscht wurde. Das ist der Grund, warum der Aufkleber die einzige Angabe am Gerät ist, die +Jahre hält: Hostnamen ändern sich, Besitzer wechseln, Seriennummern werden abgeschrieben. + +Im Code gibt es die Zeichen `0`, `O`, `1` und `I` nicht — sie sind zu leicht zu verwechseln. +Groß- und Kleinschreibung ist beim Eintippen unerheblich. + +## Zwei Wege, wie ein Tag entsteht + +**Weg A — Gerät zuerst.** Du legst das Gerät in der Geräteliste an; der Tag-Code entsteht dabei und +steht danach in der Erfolgsmeldung. Dann druckst du sein Etikett und klebst es auf. Siehe +[Kapitel 05](05-mitarbeiter-geraete.md). + +**Weg B — Aufkleber zuerst.** Du legst auf Vorrat einen Stapel Tags an, druckst den Bogen und +klebst die Aufkleber auf, was gerade da ist. Wer später davorsteht, scannt und trägt ein, was es +ist. Das ist der Weg für eine Lieferung von zwanzig Notebooks. + +## Einen Stapel Tags anlegen + +Das verlangt die Rolle **Administrator**: ein Stapel verbraucht unwiderruflich Tag-Codes. Der Weg +dorthin steht in der Geräteliste unten („Aufkleber auf Vorrat drucken? **Tag-Stapel anlegen**“). + +![Die Seite Tags mit dem Formular für einen Stapel und der Liste der noch nicht zugeordneten Tags](bilder/tags-desktop.png) + +Unter **Stapel anlegen** steht die **Anzahl**, voreingestellt 50 — das sind gut zwei A4-Bogen und +der übliche Vorrat. Höchstens 500 auf einmal. Im Modus Dienstleistung wählst du außerdem den +**Kunden**. Dann **Stapel anlegen**. + +Danach erscheint direkt daneben **Etiketten für diesen Stapel drucken** — genau diese Codes, nicht +alles, was noch offen ist. + +> **Jeder Tag verbraucht einen Tag-Code für immer**, auch einer, dessen Aufkleber nie geklebt wird. +> Ein Stapel, den niemand klebt, ist verbraucht. Lege also so viele an, wie du wirklich aufklebst. + +Darunter listet **Noch nicht zugeordnet** alle Tags, die auf ein Gerät warten. Jede Zeile führt über +den Code zum Registrierformular und hat rechts **Einzeletikett** zum Nachdrucken. Über der Liste +steht **Etiketten drucken** für den ganzen Vorrat. + +Ein Tag ohne Gerät steht auch in der Geräteliste, dort mit dem Kennzeichen „Tag ohne Zuordnung“. + +## Etiketten drucken + +Es gibt zwei Formate, und die Druckansicht schaltet zwischen ihnen um: + +| Format | Maße | Wofür | +|---|---|---| +| **Einzeletikett** | 62 × 29 mm, ein Stück je Seite | Etikettendrucker | +| **A4-Bogen** | 3 × 8 Stück à 70 × 37 mm | Stanzbogen im Laserdrucker | + +![Die Druckansicht im Format 62 × 29 mm, ein Etikett je Seite](bilder/etikett-62x29-desktop.png) + +![Die Druckansicht als A4-Bogen mit dem Raster aus 3 × 8 Etiketten](bilder/etiketten-a4-desktop.png) + +Auf dem Etikett stehen der QR-Code, der Tag-Code zum Ablesen, Hostname oder Bezeichnung des Geräts, +der Besitzer, das Wort „laufzettel“ und im Modus Dienstleistung das Kundenkürzel. Etiketten sind +immer hell, auch wenn die Anwendung dunkel ist. + +Vor dem Drucken: + +- **Skalierung im Druckdialog auf 100 % stellen und Kopf- und Fußzeilen abschalten** — sonst sitzen + die Etiketten neben ihrer Stanzung. +- Steht oben eine rote Warnung **„APP_URL ist nicht gesetzt“**, brich ab und hol einen + Administrator. Die QR-Codes zeigen dann ins Leere, und auf dem Bildschirm sieht das Etikett + trotzdem richtig aus. Ein geklebter Aufkleber lässt sich nicht berichtigen. + +Ein Etikett nachdrucken geht jederzeit: auf der Geräteseite über **Etikett drucken**, in der +Tag-Liste über **Einzeletikett**. Auch das Etikett eines archivierten Geräts lässt sich weiter +drucken — der Aufkleber am Karton im Lager soll lesbar bleiben. Zum Nachdrucken genügt die Rolle +**Leser**; niemand soll für einen abgerissenen Aufkleber den Administrator fragen müssen. + +## Einen Aufkleber scannen + +Der Knopf **Scannen** steht an beiden Stellen ganz oben — in der Seitenleiste und am Handy in der +Kopfzeile. Halte den Aufkleber ins Bild; Laufzettel springt von selbst weiter, sobald der Code +erkannt ist. + +![Der Scanner am Handy mit Kamerabild und dem Feld zum Eintippen](bilder/scannen-handy.png) + +Drei Dinge, die passieren können: + +- **Der Code gehört zu einem Gerät** — du landest auf der Geräteseite. +- **Der Code gehört zu einem vorgedruckten Tag** — du landest im Registrierformular. +- **Es ist kein Laufzettel-Code** — es erscheint der Hinweis „Das ist kein Laufzettel-Tag. Halte den + Aufkleber mit dem Code LZ-… ins Bild.“ + +Unter dem Kamerabild steht immer das Feld **Code eintippen**. Es ist der Weg, wenn die Kamera nicht +will: kein HTTPS, verweigerte Berechtigung, Rechner ohne Kamera, ausgefallener QR-Leser. Dann tippst +du den Code ab, der lesbar unter dem QR steht, und drückst **Öffnen**. Ein Scanner, der nicht +startet, darf keine Sackgasse sein. + +Am Schreibtisch brauchst du keine Kamera: in der Tag-Liste und in der Geräteliste führt der Code +als Link dorthin, wohin auch der Scan führt. + +## Einen unregistrierten Tag registrieren + +Steht hinter dem Code noch kein Gerät, erscheint **Gerät registrieren**. Dafür braucht es die Rolle +**Techniker**, **Redakteur** oder **Administrator**; einem Leser sagt die Seite, dass ihm die +Berechtigung fehlt. + +![Das Registrierformular hinter einem vorgedruckten Aufkleber](bilder/tag-registrieren-handy.png) + +Fünf Angaben, mehr nicht — der Rest lässt sich später in der Geräteliste nachtragen: + +- **Typ** — PC, Notebook, Server, Firewall, Switch, Drucker, Raum oder Sonstiges. +- **Hostname** und **Bezeichnung** — eines von beiden genügt. Ein Raum oder ein Serverschrank hat + keinen Hostname, ein Notebook keine Bezeichnung. +- **Seriennummer** und **Inventarnummer**. +- **Besitzer (optional)** — oder „niemand — Server, Raum oder Poolgerät“. Zur Wahl stehen nur + Personen desselben Kunden. + +Der **Tag-Code steht oben, aber nicht als Feld**: er ist schon gedruckt und geklebt, und jede +Änderung daran wird abgewiesen. + +**Gerät registrieren** führt direkt auf die Geräteseite. + +## Die Geräteseite + +Das ist das Ziel jedes gescannten Aufklebers — die Seite, die ein Techniker am Handy vor dem Gerät +aufschlägt. Sie beantwortet in dieser Reihenfolge: Was ist das hier? Was wartet daran? Was kann ich +sofort anfangen? Was war schon? + +![Die Geräteseite mit Kopfdaten, Zuweisungen, Schnellstart und Historie](bilder/geraeteseite-desktop.png) + +- **Kopf** — Name, Tag-Code, Typ, Besitzer, Modell, Serien- und Inventarnummer, Notizen. Rechts + **Etikett drucken**. Hinter **QR-Code anzeigen** steht derselbe Code als Bild auf dem Schirm, für + den Fall, dass der Aufkleber abgerissen ist. +- **Zugewiesen** — was an diesem Gerät wartet. Ein offener Lauf hat den Knopf **Starten**: er setzt + ihn auf „in Arbeit“ und führt hinein. Ein laufender hat **Weiter**, ein fertiger **Ansehen**. +- **Schnellstart** — eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist. Ein Tipp legt + den Lauf mit diesem Gerät und seinem Besitzer an und beginnt ihn, ohne eine einzige Eingabe. + Steht auf der Kachel „fragt noch nach Angaben“, führt sie stattdessen in den Start-Dialog — dort + sind Gerät und Person schon vorbelegt. +- **Vorlage zuweisen** — für später oder für jemand anderen: Vorlage, **Techniker (optional)**, + **Fällig am (optional)**, dann **Lauf zuweisen**. Der Lauf entsteht als **offen** und erscheint + oben unter „Zugewiesen“. +- **Historie** — die letzten 20 Läufe an diesem Gerät, mit Abschlussdatum und Fortschritt. Für die + älteren und die archivierten nimm das [Archiv](02-laeufe-abarbeiten.md). + +Ein archiviertes Gerät und ein Tag ohne Zuordnung nehmen keine neue Arbeit auf. Statt Formularen, +die hinterher abgelehnt würden, steht dort dann der nächste Schritt. + +![Die Geräteseite am Handy](bilder/geraeteseite-handy.png) + +## Am Handy + +- **Scannen ist der Einstieg**, und der Knopf steht deshalb in der Kopfzeile immer da. +- **Die Kamera braucht HTTPS.** Auf einer Adresse ohne Verschlüsselung gibt kein Browser sie frei — + dann bleibt das Feld zum Eintippen. +- **Freigabe verweigert?** Gib die Kamera in den Einstellungen des Browsers für diese Seite frei + und lade neu. +- Die rückwärtige Kamera wird von selbst genommen. Halte den Aufkleber in den hellen Rahmen in der + Bildmitte; bei schlechtem Licht hilft ein Schritt zur Seite mehr als näher herangehen. +- **Registrieren geht am Handy vollständig.** Du stehst vor dem Gerät und liest den Hostname vom + Anmeldebildschirm ab — genau dafür sind es nur fünf Felder. diff --git a/docs/anleitung/04-vorlagen.md b/docs/anleitung/04-vorlagen.md new file mode 100644 index 0000000..c423859 --- /dev/null +++ b/docs/anleitung/04-vorlagen.md @@ -0,0 +1,240 @@ +# Vorlagen bauen + +Eine Vorlage ist die Checkliste selbst: was bei einer PC-Neuinstallation zu tun ist, in welcher +Reihenfolge, mit welchen Hilfetexten und welchen Befehlen. Vorlagen anlegen, bearbeiten und +veröffentlichen darf die Rolle **Redakteur** oder **Administrator**. + +Die wichtigste Regel steht am Anfang, weil alles andere daraus folgt: + +> **Eine veröffentlichte Version wird nie verändert.** Sonst zeigte ein alter Lauf Schritte, die es +> damals nicht gab. „Bearbeiten“ legt deshalb eine vollständige Kopie als **Entwurf** an. Je Vorlage +> gibt es höchstens einen Entwurf. Läufe starten nur an veröffentlichten Versionen und hängen für +> immer an der Version, mit der sie gestartet wurden. + +## Die Vorlagenliste + +![Die Vorlagenliste mit Filtern und Kennzeichen je Vorlage](bilder/vorlagen-liste-desktop.png) + +Gefiltert wird nach **Suche**, **Kategorie**, **Schlagwort** und mit dem Häkchen **auch +archivierte**, dann **Filtern**. + +Die Kennzeichen an einer Zeile sagen den Zustand: + +| Kennzeichen | Bedeutung | +|---|---| +| **v3** | Version 3 ist veröffentlicht und gültig | +| **noch nicht veröffentlicht** | es gibt nur einen Entwurf; daran lässt sich kein Lauf starten | +| **Entwurf vorhanden** | es wird gerade an einer neuen Version gearbeitet | +| **archiviert** | aus dem Angebot genommen | +| **nur …** | gilt nur für einen Kunden (Modus Dienstleistung) | + +Gibt es einen Entwurf, führt **Entwurf öffnen** direkt in den Editor — der häufigste Fall ist, +weiterzuarbeiten an dem, was man gestern angefangen hat. + +Unten auf der Seite steht **Vorlage anlegen**: **Name**, **Beschreibung (optional)**, **Kategorie +(optional)**, **Schlagworte, mit Komma getrennt** und im Modus Dienstleistung **Nur für einen +Kunden (optional)**. Die neue Vorlage beginnt als Entwurf. Namen sind nicht eindeutig; zwei +Vorlagen dürfen gleich heißen. + +## Die Vorlagenseite + +![Die Seite einer Vorlage mit Versionsgeschichte, Kopfdaten und weiteren Aktionen](bilder/vorlagen-seite-desktop.png) + +Sie beantwortet drei Fragen auf einen Blick: Was ist gerade gültig? Wird gerade daran gearbeitet? +Was wurde wann veröffentlicht? + +**Bearbeiten** ist der wichtigste Knopf. Er erzeugt eine tiefe Kopie der letzten veröffentlichten +Version als neuen Entwurf und führt in den Editor. Gibt es schon einen Entwurf, heißt der Knopf +**Entwurf weiter bearbeiten** und führt genau dorthin — auch wenn zwei Redakteure gleichzeitig +klicken. + +Unter **Versionen** steht die Geschichte: Nummer, Veröffentlichungszeitpunkt und -person, die Zahl +der Läufe und die Änderungsnotiz. Je Zeile **Ansehen** (beziehungsweise **Im Editor öffnen** beim +Entwurf) und **Als JSON exportieren**. + +Unter **Kopfdaten** stehen Name, Beschreibung, Kategorie, Schlagworte und die Kundenzuordnung. Sie +hängen an der Vorlage, nicht an einer Version — ein Tippfehler im Namen kostet also keine neue +Version. **Kopfdaten speichern** übernimmt sie. + +Unter **Weiteres**: + +- **Duplizieren** — legt eine neue Vorlage an, mit dem Inhalt der neuesten Version als Entwurf. + Gut als Ausgangspunkt für eine Abwandlung. +- **archivieren** / **wieder freigeben** — archiviert wird, was nicht mehr angeboten werden soll. + Gelöscht wird nichts: an einer Vorlage hängen Läufe. Ein Entwurf einer archivierten Vorlage lässt + sich noch bearbeiten, aber nicht veröffentlichen. +- **Entwurf verwerfen** — mit Rückfrage. Der Entwurf ist danach weg und nicht wiederherstellbar; + die veröffentlichte Version bleibt unberührt. + +## Der Editor + +![Der Vorlagen-Editor mit Variablen, Abschnitten und Schritten](bilder/vorlagen-editor-desktop.png) + +Oben stehen Name, **Version N** und der Zustand — **Entwurf** oder **veröffentlicht** — und daneben +**Nicht gespeichert** oder **gespeichert**. + +Unten klebt eine Leiste mit **Speichern** und derselben Auskunft in Worten. **Es gibt kein +automatisches Speichern**: eine halb umgebaute Vorlage soll nicht nebenbei zur Wahrheit werden. +Solange etwas nicht gespeichert ist, fragt der Browser vor dem Verlassen der Seite nach. + +Eine veröffentlichte Version öffnet der Editor nur lesend — mit denselben Feldern, nur +schreibgeschützt. Oben steht dann, warum. + +### Abschnitte + +Ein Abschnitt fasst die Schritte zusammen, die zusammengehören. **Abschnitt hinzufügen** legt einen +an; er bekommt einen **Titel des Abschnitts** und darunter seine Schritte. + +Die zweistellige Nummer (`01 · Vorbereitung`) kommt aus der Position und wird **nie gespeichert**. +Schreib sie also nicht in den Titel — beim ersten Umsortieren wäre sie falsch. + +Sortiert wird auf zwei Wegen: mit dem Anfasser links ziehen, oder über die Pfeile **nach oben** und +**nach unten** an der Zeile. Der zweite Weg ist am Handy der bessere. Schritte werden innerhalb +ihres Abschnitts sortiert und lassen sich nicht in einen anderen ziehen. + +Höchstens 200 Abschnitte, höchstens 500 Schritte je Abschnitt. + +### Schritte und ihre Typen + +Jeder Schritt hat **Titel**, **Typ** und das Häkchen **Pflichtschritt**. Dazu je nach Typ: + +| Typ | Was der Techniker tut | Einstellungen | +|---|---|---| +| **Haken** | abhaken | keine | +| **Text** | Text eintragen | **Mehrzeiliges Feld** | +| **Zahl** | Zahl eintragen | **Mindestens**, **Höchstens**, **Einheit** | +| **Auswahl** | aus einer Liste wählen | **Optionen** (mindestens eine) | +| **Datum** | Datum wählen | keine | +| **Befehl** | Befehl kopieren und ausführen | **Shell** (powershell, bash, cmd), **Befehl** | + +Mindestens und Höchstens sind **Hinweise, kein Verbot**: ein Wert außerhalb wird im Lauf markiert +und trotzdem gespeichert. + +**Pflichtschritt** heißt: der Lauf lässt sich nicht abschließen, solange dieser Schritt weder +erledigt noch ausdrücklich als nicht zutreffend eingetragen ist. Setz das sparsam — sind alle +Schritte Pflicht, ist keiner Pflicht. + +Beim Wechsel des Typs räumt der Editor die Einstellungen auf, damit nicht der Befehl eines +ehemaligen Befehlsschritts unsichtbar mitwandert. + +### Hilfetext und Doku-Link + +Der **Hilfetext** ist in der Abarbeitungsansicht ausklappbar und darf **Markdown** enthalten: +`**fett**`, Listen, Links, `` `Code` ``. **Kein HTML.** Hier steht das, was jemand beim ersten Mal +wissen muss — im Modus „Kompakt“ blendet der Techniker ihn später aus. + +Der **Doku-Link** erscheint im Lauf als **Anleitung öffnen**. Nur `http`- und `https`-Adressen. + +### Variablen + +Variablen werden **beim Start eines Laufs abgefragt** und füllen die Platzhalter. **Variable +hinzufügen** legt eine an: + +- **Schlüssel** — der Name im Platzhalter `{{schluessel}}`. Ein Variablenschlüssel beginnt mit einem + Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich, höchstens 40 Zeichen. + `vorname`, `ad_gruppe` und `raum2` gehen; `Vorname`, `mit-strich` und `2te_wahl` nicht. Die + Strenge hat einen Grund: derselbe Schlüssel steht in PowerShell-Befehlen, und `{{Vor-Name}}` ließe + sich dort nicht mehr von der Syntax der Shell trennen. +- **Beschriftung** — was im Startformular über dem Feld steht. +- **Typ** — Text, Zahl, Auswahl oder Datum. +- **Pflichtangabe beim Start** — ohne Eingabe lässt sich der Lauf nicht anlegen. +- **Optionen** — nur bei Auswahl. + +Ein Schlüssel darf nur einmal vorkommen; der Editor markiert eine Doppelung sofort. Eine Variable, +die nirgends verwendet wird, ist erlaubt — sie wird trotzdem beim Start abgefragt. + +Fünf Namen sind als Variable gesperrt: `techniker`, `datum`, `kunde`, `kundenkuerzel` und +`generiertes_passwort`. Sie sagen, **wer, wann und für wen** — eine Variable dafür hieße, dass in +einem fertigen Befehl ein fremder Name oder ein falsches Datum stehen kann, ohne dass es auffällt. + +Höchstens 200 Variablen. + +### Platzhalter + +Ein Platzhalter `{{schluessel}}` in **Titel, Hilfetext oder Befehl** wird beim Abarbeiten ersetzt. +Leerzeichen in den Klammern sind erlaubt: `{{ vorname }}` wird erkannt. + +Er wird aus einer Variablen der Vorlage gefüllt — oder aus einem der **eingebauten** Werte. Welche +es gibt, steht im Editor hinter **Welche Platzhalter gibt es?**, nach Quelle gruppiert: + +| Quelle | Platzhalter | +|---|---| +| Gerät und Lauf | `hostname`, `seriennummer`, `inventarnummer`, `ticket`, `datum`, `techniker`, und im Modus Dienstleistung `kunde`, `kundenkuerzel` | +| Mitarbeiter | `mitarbeiter`, `benutzer`, `vorname`, `nachname`, `upn`, `sam`, `mail`, `abteilung`, `titel`, `vorgesetzter`, `vorgesetzter_sam`, `dn` | +| Abteilung | `ou` (Ziel-Container), `gruppen` (Standardgruppen, kommagetrennt) | +| Einstellungen | `ou_gesperrt` (Ziel-OU für deaktivierte Konten) | +| Besonders | `generiertes_passwort` | + +`{{generiertes_passwort}}` ist der Sonderfall: **20 Zeichen, bei jeder Anzeige neu erzeugt und +nirgends gespeichert** — nicht im Lauf, nicht im Kommentar, nicht im Protokoll, nicht im Ausdruck. +Auf einem Ausdruck steht an seiner Stelle `[Passwort – nicht gespeichert]`. Bau also keinen Ablauf, +der davon ausgeht, dass man das Passwort später nachschlagen kann. + +Ein Platzhalter, der zu keiner Variablen und keinem eingebauten Wert passt, wird oben im Editor +**gewarnt**, aber nicht abgewiesen: ein Tippfehler soll hier auffallen und nicht erst dem Techniker +im Lauf — es kann aber sein, dass die Variable gleich noch kommt. Im Lauf bleibt ein unbekannter +Platzhalter als Text stehen. + +Für Befehlsschritte gilt eine zusätzliche Regel: **die Anführungszeichen gehören in die Vorlage, +nicht in den Wert.** Schreib also `New-ADUser -Name "{{vorname}} {{nachname}}"`. Steht ein +Platzhalter in einem Befehl nicht zwischen den erwarteten Anführungszeichen, wird der Schritt im +Lauf abgewiesen, statt einen halb maskierten Befehl zum Kopieren anzubieten. + +### Vorschau + +**Vorschau** zeigt die Vorlage so, wie sie im Lauf aussieht: mit Beispielwerten in allen +Platzhaltern, abgeschalteten Feldern und nummerierten Abschnitten. Gespeichert wird dabei nichts, +und für `{{generiertes_passwort}}` entsteht **kein** Passwort — dort steht +`BEISPIEL-PASSWORT-NICHT-ECHT`. + +Nutz sie vor dem Veröffentlichen, besonders bei Befehlen: erst hier sieht man, ob der fertige Befehl +so aussieht, wie er aussehen soll. + +## Veröffentlichen + +Im Abschnitt **Veröffentlichen** trägst du eine **Änderungsnotiz (optional)** ein — ein Satz, was +sich gegenüber der letzten Version geändert hat; sie steht später in der Versionsgeschichte. Dann +**Veröffentlichen**. + +**Veröffentlicht wird das Gespeicherte, nicht das, was auf dem Bildschirm steht.** Gibt es nicht +gespeicherte Änderungen, warnt der Editor — erst speichern, dann veröffentlichen. + +Danach ist die Version unveränderlich, und Läufe können daran starten. Der Editor schaltet in den +Lesemodus. + +Willst du weiter daran arbeiten, drückst du auf der Vorlagenseite wieder **Bearbeiten**: es entsteht +ein neuer Entwurf mit der nächsthöheren Nummer. + +## Export und Import + +**Als JSON exportieren** auf der Vorlagenseite lädt eine Version als Datei herunter. Das Dokument +enthält Kopfdaten, Variablen, Abschnitte und Schritte — und ausdrücklich keine Kennungen, +Zeitstempel, Läufe oder Kundenzuordnung. Damit lässt es sich in eine **andere** +Laufzettel-Installation einspielen. + +Eingespielt wird in der Vorlagenliste unter **Vorlage aus einer Datei einspielen**: Datei wählen +oder den Inhalt einfügen, dann **Einspielen**. Zu wissen: + +- Es entsteht immer eine **neue** Vorlage; der Import schreibt nie in eine bestehende hinein. +- Sie bekommt genau eine Version als **Entwurf**. Erst nach dem Ansehen und Veröffentlichen lässt + sich ein Lauf daran starten. +- Die Versionsnummer der Quelle wandert nicht mit: hier fängt die Vorlage wieder bei 1 an. +- Eine eingespielte Vorlage gilt zunächst für alle Kunden. Die Zuordnung setzt du danach in den + Kopfdaten. +- Schlägt die Prüfung fehl, wird **nichts** geschrieben, und die Meldung nennt die erste + Beanstandung mit ihrem Pfad, etwa + `struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.` + +Das Format ist vollständig beschrieben in [docs/vorlagen-format.md](../vorlagen-format.md) — für +den Fall, dass jemand eine Vorlage von Hand schreiben oder ein Skript dafür bauen will. + +## Vorlagen einem Gerätetyp zuordnen + +Damit auf der Geräteseite ein **Schnellstart** erscheint, muss für den Gerätetyp eine Vorlage +hinterlegt sein — etwa „PC Neuinstallation“ für den Typ PC. Diese Zuordnung wird beim Einrichten der +Anwendung gesetzt und lässt sich in der Oberfläche noch nicht ändern; wende dich dafür an die +Person, die Laufzettel betreibt. + +Ohne Zuordnung bleibt der Schnellstart leer, und das ist kein Mangel: unter **Vorlage zuweisen** +steht auf der Geräteseite jede Vorlage, die zu diesem Gerät passt, und der Start-Dialog kennt sie +ohnehin alle. diff --git a/docs/anleitung/05-mitarbeiter-geraete.md b/docs/anleitung/05-mitarbeiter-geraete.md new file mode 100644 index 0000000..20f901f --- /dev/null +++ b/docs/anleitung/05-mitarbeiter-geraete.md @@ -0,0 +1,178 @@ +# Mitarbeiter, Abteilungen und Geräte + +Unter **Mitarbeiter & Geräte** (im Modus Dienstleistung: **Personen & Geräte**) liegen die +Stammdaten. Oben schalten zwei Reiter um: **Personen** und **Geräte**. + +Personen und Geräte pflegen darf die Rolle **Techniker**, **Redakteur** oder **Administrator** — +beim Onboarding steht der Techniker vor der Person, bevor der nächste Verzeichnisabgleich gelaufen +ist. **Abteilungen** bleiben beim **Administrator**: an ihnen hängen Ziel-OU und Standardgruppen, +also das, was die späteren PowerShell-Befehle in ein Verzeichnis schreiben. + +## Personen + +![Die Personenliste mit Suche und Kennzeichen je Person](bilder/mitarbeiter-liste-desktop.png) + +Gesucht wird über **Name, Mail, UPN oder sam** — im Ticket steht mal das eine, mal das andere. Dazu +der Filter **Abteilung** und das Häkchen **auch deaktivierte**; ohne das erscheinen nur aktive +Personen. + +Eine Zeile trägt den Namen, die Kennungen, die Abteilung und die Zahl der Geräte und Läufe. Zwei +Kennzeichen sind wichtig: + +- **aus dem Verzeichnis** — diese Person kommt aus dem Active Directory. Ihre Stammdaten werden + dort gepflegt. +- **deaktiviert** — nicht mehr im Haus. Steht mit Durchstreichung da. + +### Eine Person von Hand anlegen + +Unter **Person von Hand anlegen**: **Vorname**, **Nachname**, **Mail-Adresse (optional)** und +**Abteilung (optional)** — für die Abteilung schlägt das Feld vorhandene Namen vor. Hinter +**Verzeichnis-Angaben (UPN, sAMAccountName, Titel)** stehen die drei Felder, die das Verzeichnis +sonst liefert. Im Modus Dienstleistung außerdem der **Kunde**. Dann **Person anlegen**. + +Gedacht ist das für das Onboarding: die Person entsteht hier, das AD-Konto danach. Eine so angelegte +Person bleibt vollständig änderbar, und der nächste Verzeichnisabgleich **übernimmt** sie über UPN +oder sAMAccountName, statt sie ein zweites Mal anzulegen. + +### Die Seite einer Person + +![Die Seite einer Person mit Geräten und Stammdaten](bilder/mitarbeiter-seite-desktop.png) + +Oben Name, Titel, Abteilung, gegebenenfalls der Vorgesetzte. Darunter: + +- der Knopf **deaktivieren** beziehungsweise **wieder aktivieren**, +- **Geräte** — was dieser Person zugeordnet ist, mit Tag-Code, +- **Stammdaten** — das Formular, **Stammdaten speichern** übernimmt. + +Kommt die Person **aus dem Verzeichnis**, ist jedes Feld der Stammdaten gesperrt, und oben steht, +warum: „Die Stammdaten werden dort gepflegt — eine Änderung hier würde der nächste Abgleich +zurücksetzen.“ Die Werte stehen trotzdem da, nur schreibgeschützt; ein ausgeblendetes Feld wäre eine +schlechtere Auskunft als ein gesperrtes. **Deaktivieren lässt sie sich trotzdem.** + +![Eine Person aus dem Verzeichnis mit gesperrten Stammdaten](bilder/mitarbeiter-verzeichnis-desktop.png) + +### Deaktivieren statt löschen + +**Personen werden nie gelöscht, nur deaktiviert.** Ein archivierter Lauf muss auch in zehn Jahren +noch sagen können, für wen er lief. Eine deaktivierte Person verschwindet aus den Auswahllisten, +bleibt aber in jeder Historie auffindbar. + +## Personen aus einer CSV-Datei einlesen + +![Der CSV-Import für Personen mit Spaltenliste und Meldungen je Zeile](bilder/personen-import-desktop.png) + +Unter **Personen aus einer CSV-Datei einlesen**. Die Spalten heißen wie im Verzeichnis, die +deutschen Entsprechungen gehen genauso: + +| Spalte | Auch | +|---|---| +| `givenName` | vorname | +| `sn` | surname, nachname | +| `displayName` | anzeigename | +| `mail` | email, mailadresse | +| `upn` | userPrincipalName | +| `sam` | sAMAccountName | +| `department` | abteilung | +| `title` | titel | + +Groß- und Kleinschreibung ist unerheblich, Trennzeichen darf Komma oder Semikolon sein, höchstens +500 Zeilen je Datei. Datei wählen oder Inhalt einfügen, im Modus Dienstleistung den **Kunden** +wählen, dann **Einlesen**. + +Was der Import zusagt: + +- **Wiedererkannt** wird eine Person über `sam` oder `upn`, sonst über den Anzeigenamen. Wer schon + da ist, wird aktualisiert statt ein zweites Mal angelegt. +- **Eine leere Zelle löscht nichts.** Was in der Datei nicht steht, bleibt, wie es ist. +- Fehlen `givenName` und `sn`, wird `displayName` am letzten Leerzeichen geteilt. +- **Personen aus dem Verzeichnis werden nicht überschrieben** — solche Zeilen werden gemeldet. +- Eine **deaktivierte** Person wird erkannt und aktualisiert, aber **nicht wieder aktiviert**. Dafür + gibt es den Schalter auf ihrer Seite. +- **Abteilungen legt der Import nicht an.** Der Name wird nur bei der Person eingetragen; als + Abteilung mit Ziel-OU und Standardgruppen übernimmt sie ein Administrator. + +Eine fehlerhafte Zeile bricht den Import nicht ab. Sie wird unten mit ihrer **Zeilennummer** und dem +Grund gemeldet — genau die Zeilen sind in der Datei zu ändern. + +## Abteilungen + +Abteilungen liegen unter **Admin → Abteilungen**, weil an ihnen die Struktur des Verzeichnisses +hängt. + +![Die Abteilungsliste mit Ziel-OU und Standardgruppen](bilder/abteilungen-desktop.png) + +Eine Abteilung hat: + +- **Name** — er muss **genauso lauten wie im Verzeichnis**, sonst findet die Zuordnung ihn nicht. + Verknüpft wird über den Abteilungsnamen der Person, nicht über eine Kennung. +- **Ziel-OU (optional)** — wo ein neues Konto landet, etwa + `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de`. Sie füllt den Platzhalter `{{ou}}`. +- **Standardgruppen, mit Komma getrennt** — sie füllen `{{gruppen}}`. **Die Reihenfolge bleibt + erhalten**, sie landet so im Gruppen-Befehl. +- **Mail-Domain (optional)** und **Notizen (optional)**. + +Ein Tippfehler in der Ziel-OU legt ein Konto an der falschen Stelle an — das ist der Grund, warum +diese Seite dem Administrator gehört. Fehlen Ziel-OU und Standardgruppen, bleiben `{{ou}}` und +`{{gruppen}}` im Lauf leer. + +**Bearbeiten** klappt das Formular an der Zeile auf. **archivieren** nimmt eine Abteilung aus dem +Angebot; gelöscht wird nichts, an einer Abteilung hängen Läufe. **wieder freigeben** macht es +rückgängig. + +### Noch nicht gepflegte Abteilungen + +![Vorschläge für Abteilungen, die bei Personen vorkommen, aber nicht gepflegt sind](bilder/abteilungen-vorschlaege-desktop.png) + +Steht ein Abteilungsname bei Personen, ohne dass es dazu eine Abteilung gibt, erscheint oben +**Noch nicht gepflegt**. Ein Klick auf **Anlegen** legt die Abteilung mit diesem Namen an — **und +nur den Namen.** Ziel-OU, Standardgruppen und Mail-Domain sind danach leer und werden in der Liste +darunter nachgetragen. + +## Geräte + +![Die Geräteliste mit Filtern, Kennzeichen und Aktionen](bilder/geraete-liste-desktop.png) + +Gesucht wird über **Hostname, Bezeichnung, Seriennummer, Tag-Code**, gefiltert nach **Typ** und mit +dem Häkchen **auch archivierte**. + +Jede Karte zeigt Tag-Code, Namen, Typ, Besitzer, Modell, Serien- und Inventarnummer und die Zahl der +Läufe. Der Name führt auf die [Geräteseite](03-qr-tags.md). Kennzeichen: **Tag ohne Zuordnung** für +einen vorgedruckten Aufkleber, **archiviert** für ein Gerät außer Dienst. + +**Stammdaten bearbeiten** klappt das Formular an der Karte auf: Hostname, Bezeichnung, Typ, Modell, +Seriennummer, Inventarnummer, Besitzer und Notizen. Zur Wahl als Besitzer stehen nur aktive Personen +desselben Kunden. + +### Ein Gerät anlegen + +Unter **Gerät anlegen**: **Hostname** und **Bezeichnung** (eines von beiden genügt), **Typ**, +**Modell**, **Seriennummer**, **Inventarnummer**, **Besitzer (optional)**, **Notizen (optional)** +und im Modus Dienstleistung der **Kunde**. Dann **Gerät anlegen**. + +Der **Tag-Code entsteht dabei** und steht danach in der Erfolgsmeldung — er kommt auf den Aufkleber. +Drucke das Etikett gleich mit, solange du weißt, welches Gerät gemeint ist. + +### Geräte aus einer CSV-Datei einlesen + +Dieselbe Mechanik wie bei den Personen, höchstens 500 Zeilen. Spalten: `hostname`, `label` +(bezeichnung), `serialNumber`, `inventoryNumber`, `type`, `model`. **Eine der beiden Spalten +`hostname` oder `label` muss es geben** — sonst weiß niemand, was das Gerät ist. Jede eingelesene +Zeile verbraucht einen Tag-Code; lieber zweimal drucken als einmal zu viel einlesen. + +### Archivieren statt löschen + +**archivieren** nimmt ein Gerät aus dem Bestand: es taucht in keiner Auswahl mehr auf und nimmt +keine neue Arbeit an, bleibt aber in jeder Historie und lässt sich mit **wieder freigeben** +zurückholen. Das ist der normale Weg für ein ausgemustertes Gerät. + +**löschen** ist die einzige echte Löschung in Laufzettel, und sie ist eng eingehegt: + +- Sie erscheint nur, wenn **kein Lauf** an dem Gerät hängt. Sonst sagt Laufzettel: „An … hängen 3 + Läufe. Das Gerät lässt sich deshalb nur archivieren.“ +- Sie verlangt die Rolle **Administrator**. +- Sie fragt nach. + +Der Grund für die Ausnahme: der Vertipper von eben stünde sonst für immer in der Liste. Der Preis +steht in der Rückfrage: **der Tag-Code bleibt für immer vergeben.** Er wird nie neu ausgegeben, ein +Aufkleber zeigt nie auf ein zweites Gerät — und ein schon gedruckter Aufkleber dieses Codes ist +danach wertlos. diff --git a/docs/anleitung/06-verzeichnis.md b/docs/anleitung/06-verzeichnis.md new file mode 100644 index 0000000..ff22f4d --- /dev/null +++ b/docs/anleitung/06-verzeichnis.md @@ -0,0 +1,171 @@ +# Verzeichnis anbinden + +Unter **Admin → Verzeichnis** wird ein Active Directory als Quelle der Personen eingerichtet. Dann +muss beim Start eines Laufs nichts abgetippt werden, und `{{vorname}}`, `{{upn}}`, `{{sam}}` und +`{{abteilung}}` stehen von selbst zur Verfügung. + +Zwei Dinge vorweg: + +- Der Reiter gibt es **nur im Modus Intern**. Dort hat das Haus ein Verzeichnis, aus dem seine Leute + kommen. Im Modus Dienstleistung wären es Verzeichnisse fremder Kunden, und die bindet Laufzettel + ausdrücklich nicht an — dort kommen Personen von Hand oder per CSV. +- **Laufzettel schreibt nie ins Verzeichnis.** Ein Dienstkonto mit **Leserechten** genügt. + Gespeichert werden nur die zugeordneten Felder — keine Passwörter, keine + Gruppenmitgliedschaften. + +Wer im Verzeichnis verschwindet oder dort deaktiviert wird, wird hier **inaktiv**. Gelöscht wird +nie, sonst verlöre die Historie ihre Namen. + +## Eine Quelle anlegen + +![Die Seite Verzeichnis mit Quelle, Status und Formular](bilder/verzeichnis-desktop.png) + +Das Formular **Quelle anlegen** steht unten auf der Seite. Die Felder: + +| Feld | Was hinein gehört | +|---|---| +| **Name** | wie du die Quelle nennst, etwa „Firmen-AD“ | +| **Adresse** | `ldaps://dc01.firma.local:636` — nur Schema, Rechner und Port | +| **Anmelde-DN** | das Dienstkonto, etwa `CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local` | +| **Passwort** | wird verschlüsselt gespeichert und nie wieder angezeigt | +| **Basis-DN** | wo gesucht wird, etwa `OU=Benutzer,DC=firma,DC=local` | +| **Filter (optional)** | leer lassen nimmt den Standard: aktive Personenkonten | +| **Abgleich alle … Minuten** | zwischen 5 und 10080 (fünf Minuten bis eine Woche) | +| **Nach Zeitplan abgleichen** | das Häkchen schaltet den Zeitplan ein | + +Für die **Adresse** gibt es drei Formen: + +- `ldaps://host:636` — verschlüsselt ab dem ersten Byte. **Die richtige Wahl.** +- `ldap+starttls://host:389` — unverschlüsselt verbunden und dann hochgestuft. +- `ldap://host:389` — im Klartext. Sobald du das eintippst, warnt das Formular: dann geht auch das + Bind-Passwort im Klartext über das Netz. Für ein Dienstkonto im Active Directory ist das keine gute + Idee. + +In die Adresse gehören **kein Pfad und keine Zugangsdaten**. Der Basis-DN hat sein eigenes Feld, +Bind-DN und Passwort ebenso; Laufzettel weist eine Adresse mit `/` oder `@` dahinter ausdrücklich ab. + +Der **Standardfilter** schließt deaktivierte Konten aus. Das ist der Sinn: wer aus dem Ergebnis +verschwindet, wird hier deaktiviert. Ein eigener Filter engt weiter ein, etwa auf eine einzelne OU. + +Dann **Quelle anlegen**. + +## Attributzuordnung + +Hinter **Attributzuordnung anpassen** steht, welches Verzeichnis-Attribut auf welches Feld geht. Die +Vorgaben sind die Namen eines Active Directory: + +| Feld | Vorgabe | +|---|---| +| Vorname | `givenName` | +| Nachname | `sn` | +| Anzeigename | `displayName` | +| E-Mail | `mail` | +| UPN | `userPrincipalName` | +| Anmeldename (sam) | `sAMAccountName` | +| Abteilung | `department` | +| Titel | `title` | +| Vorgesetzter | `manager` | +| Kennung (dauerhaft) | `objectGUID` | + +Ein OpenLDAP heißt an einigen Stellen anders — dort ist die dauerhafte Kennung etwa `entryUUID`. +Leer gelassene Felder bekommen die Vorgabe zurück. + +> **Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte.** An ihr erkennt der +> Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf **alle Personen neu an**. + +## Eigenes CA-Zertifikat + +Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt. Hinter **Eigenes +CA-Zertifikat hinterlegen** fügst du den **PEM**-Text ein, beginnend mit +`-----BEGIN CERTIFICATE-----`. Ohne Eintrag gelten die Wurzelzertifikate des Systems. + +Ist eines hinterlegt, sagt die Seite das. Das Feld bleibt beim Bearbeiten trotzdem leer: **Text +eintragen ersetzt es, leer lassen behält es**, und das Häkchen **Hinterlegtes Zertifikat entfernen** +nimmt es weg. Beides zugleich ergibt keinen Sinn und wird abgewiesen. + +Dasselbe gilt für das **Passwort**: leer lassen behält das gespeicherte. Sonst nähme jede +Namensänderung der Quelle ihre Anmeldung. + +## Verbindung testen + +![Das Ergebnis eines Verbindungstests: „Die Verbindung steht. 4 Einträge gelesen, 1 übersprungen“ und darunter die ersten drei Einträge mit UPN, Mail, DN, sam und Abteilung](bilder/verbindungstest-desktop.png) + +**Verbindung testen** liest die ersten Einträge und zeigt sie so, wie sie ankämen — **gespeichert +wird nichts**. Mach das immer vor dem ersten Abgleich. Klappt es nicht, steht der Grund in Rot +darunter; die häufigsten stehen in [Kapitel 08](08-fehlerbehebung.md). + +Der Wert steckt nicht im „geht“, sondern in der Liste darunter: erst an den zugeordneten Feldern +siehst du, ob die Zuordnung stimmt. Ein grünes „Verbindung erfolgreich“ sähe genauso aus, wenn `sam` +und `upn` vertauscht wären — und der Fehler fiele erst auf, wenn ein PowerShell-Befehl den falschen +Anmeldenamen trägt. + +Prüf also Zeile für Zeile: Steht unter **UPN** wirklich der UPN? Unter **sam** der Anmeldename? Ist +die **Abteilung** genau der Name, unter dem du die Abteilung gepflegt hast? + +Ein **Vorgesetzter** erscheint im Test nur, wenn er zufällig unter diesen Einträgen ist. Beim +vollständigen Abgleich wird er aufgelöst. + +## Zeitplan und „Jetzt abgleichen“ + +Ist **Nach Zeitplan abgleichen** gesetzt, läuft der Abgleich von selbst im gewählten Intervall; an +der Quelle steht dann „alle 60 min“, sonst „kein Zeitplan“. + +**Jetzt abgleichen** startet ihn sofort — nach einer Änderung am Filter will niemand bis zur nächsten +vollen Stunde warten. Es ist derselbe Vorgang, den auch der Zeitplan ruft, es gibt also keinen +zweiten Weg mit eigenen Regeln. + +Läuft für diese Quelle gerade schon ein Abgleich, sagt Laufzettel das und tut nichts: „Für … läuft +gerade schon ein Abgleich. Bitte das Ergebnis abwarten.“ + +## Was der Abgleich tut + +1. Er liest das Verzeichnis in Seiten von je tausend Einträgen. +2. Jeder Eintrag wird über die **Kennung** wiedererkannt und aktualisiert, oder neu angelegt. +3. Eine **von Hand angelegte** Person wird über `upn` oder `sam` gefunden und **übernommen**, nicht + verdoppelt. Genau so ist das Onboarding gedacht: die Person entsteht hier, das AD-Konto danach. +4. **Erst nach einem vollständigen Durchlauf** wird deaktiviert, wer nicht wiedergesehen wurde. Ein + Netzwerkfehler nach der halben Liste soll nicht die andere Hälfte der Firma abschalten. + +Drei Sicherungen, die alle denselben Fehler verhindern — dass eine kaputte Eingabe wie „alle sind +gegangen“ aussieht: + +- **Ließ sich kein einziger Eintrag zuordnen, gilt der Abgleich als gescheitert** und es wird niemand + deaktiviert. Die Meldung sagt: „Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders + das Kennungsattribut.“ +- **Übersprungene Einträge sind nicht verschwunden** und werden aus der Abschaltung herausgenommen. +- Eine Person, die schon eine **andere** Quelle führt, wird nie übernommen. + +Der Abgleich läuft nicht in einer Transaktion. Bricht er in der Mitte ab, bleiben die bis dahin +geschriebenen Personen stehen — ein zweiter Lauf holt den Rest. + +## Das Abgleich-Log + +![Das aufgeklappte Abgleich-Log mit Zeitpunkt, Bilanz und Fehlermeldung eines Laufs](bilder/abgleich-log-desktop.png) + +An der Quelle steht der letzte Stand: „Zuletzt abgeglichen am …“ oder „Letzter Abgleich gescheitert“ +mit der Meldung dazu. + +Hinter **Abgleich-Log** stehen die letzten Läufe mit Zeitpunkt, wer sie ausgelöst hat (steht dort +**Zeitplan**, war es niemand von Hand — das ist der Regelfall) und der Bilanz: + +| Zahl | Bedeutung | +|---|---| +| **gelesen** | Einträge, die aus dem Verzeichnis kamen | +| **angelegt** | neue Personen | +| **übernommen** | von Hand angelegte Personen, die jetzt am Verzeichnis hängen | +| **aktualisiert** | vorhandene Personen mit geänderten Feldern | +| **deaktiviert** | nicht mehr wiedergesehene Personen | +| **übersprungen** | Einträge, die sich nicht zuordnen ließen | + +**Nullen werden gezeigt.** „0 angelegt, 0 aktualisiert“ ist die Auskunft, dass der Abgleich lief und +sich im Verzeichnis nichts geändert hat. Fehlte die Zeile, sähe ein erfolgreicher Lauf ohne Änderung +aus wie ein Lauf, der nie stattfand. + +**Übersprungene** Einträge sind kein Fehler der Quelle, sondern einzelne krumme Konten — sie stehen +deshalb als Hinweis und nicht als Fehler da. Steigt die Zahl, prüf die Attributzuordnung. + +## Ohne Verzeichnis + +Eine Quelle ist keine Voraussetzung. Personen lassen sich von Hand anlegen oder aus einer CSV-Datei +einlesen — ein Verzeichnis nimmt nur die Arbeit ab. Siehe +[Kapitel 05](05-mitarbeiter-geraete.md). diff --git a/docs/anleitung/07-admin.md b/docs/anleitung/07-admin.md new file mode 100644 index 0000000..18dd0ff --- /dev/null +++ b/docs/anleitung/07-admin.md @@ -0,0 +1,154 @@ +# Verwaltung + +Der Bereich **Admin** gehört der Rolle **Administrator**. Oben schalten Reiter um: +**Einstellungen**, **Benutzer**, **Abteilungen** und — nur im Modus Intern — **Verzeichnis**. + +Abteilungen sind in [Kapitel 05](05-mitarbeiter-geraete.md) beschrieben, das Verzeichnis in +[Kapitel 06](06-verzeichnis.md). + +## Benutzer und Rollen + +![Die Benutzerliste mit Rollenwahl je Konto](bilder/admin-benutzer-desktop.png) + +**Benutzer** sind die Menschen, die Laufzettel bedienen. Nicht zu verwechseln mit den Mitarbeitern +aus dem Verzeichnis: die melden sich hier nie an. + +Je Konto stehen Name, E-Mail, die Anmeldewege (**Anbieter**, **Passwort** oder „kein Anmeldeweg“) und +der Zeitpunkt der letzten Anmeldung. Rechts: + +- die **Rollenwahl** mit dem Knopf **Setzen**, +- der Schalter **aktiv** / **deaktiviert**. + +Beim eigenen Konto steht statt des Schalters **das bist du** — niemand soll sich selbst aussperren. + +Ein deaktiviertes Konto wird auch mit einem noch gültigen Sitzungs-Cookie abgewiesen, und eine +geänderte Rolle greift sofort: Rolle und Aktiv-Status werden bei jeder Anfrage aus der Datenbank +gelesen. + +**Bei Anmeldung über den Anbieter wird die Rolle aus den Gruppen gesetzt und überschreibt eine hier +gewählte.** Wer eine Rolle dauerhaft ändern will, ändert sie also in der Gruppenzuordnung des +Anbieters, nicht hier. + +Welche Rolle was darf, steht in [Kapitel 01](01-erste-schritte.md). + +### Ein lokales Konto anlegen + +Unter **Lokales Konto anlegen**: **Name**, **E-Mail**, **Rolle** und **Passwort (mindestens 12 +Zeichen)**, dann **Konto anlegen**. Das Passwort wird nur als Hash gespeichert. + +Gedacht ist das als **Notfallzugang**, falls der Anmeldeanbieter ausfällt. Ist der lokale Login +abgeschaltet, sagt die Seite das; neue Konten entstehen dann beim ersten Anmelden über den Anbieter +von selbst. + +## Einstellungen + +![Die Einstellungen mit Betriebsmodus, Organisation, Namensregeln und Sperr-OU](bilder/admin-einstellungen-desktop.png) + +### Organisation + +Der Name des eigenen Betriebs. Im Modus **Intern** erscheint er nirgends in der Oberfläche — er wird +erst sichtbar, wenn du auf Dienstleistung umstellst, denn dann wird daraus der erste Kunde. + +### Namensregeln + +Vorlagen für **UPN**, **Anmeldename (sam)**, **Mail** und **Hostname**. Beim Start eines Laufs +berechnet Laufzettel daraus Vorschläge, die der Techniker überschreiben kann — der Knopf dafür heißt +dort **Namen aus den Regeln vorschlagen**. + +Platzhalter in den Regeln: + +| Platzhalter | Bedeutung | +|---|---| +| `{vorname}` | der Vorname | +| `{nachname}` | der Nachname | +| `{v}` | der erste Buchstabe des Vornamens | +| `{nnn}` | eine fortlaufende Nummer | +| `{kundenkuerzel}` | das Kürzel des Kunden (nur Modus Dienstleistung) | + +Beispiele: `{vorname}.{nachname}@firma.de` für den UPN, `{v}{nachname}` für den Anmeldenamen, +`PC-{nnn}` für den Hostname. + +Ein Vorschlag ist ein Angebot, keine Zusage: **verbindlich wird ein Name erst beim Anlegen im +Verzeichnis.** Sind keine Regeln hinterlegt, sagt der Start-Dialog das und lässt die Felder leer. + +### Sperr-OU + +Die Ziel-OU für deaktivierte Konten, etwa `OU=Gesperrt,OU=Benutzer,DC=firma,DC=local`. Sie füllt den +Platzhalter `{{ou_gesperrt}}` und wird beim Offboarding gebraucht. + +**Speichern** übernimmt alle drei Abschnitte auf einmal. + +### Adresse für die QR-Codes + +Ganz unten steht, welche Adresse in den gedruckten QR-Codes landet, als Beispiel ausgeschrieben: +`https://…/t/LZ-7K3F9Q`. + +**Sie lässt sich hier nicht ändern.** Sie kommt aus der Umgebung der Anwendung, weil gedruckte +Aufkleber sonst ins Leere zeigen würden. Steht dort „APP_URL ist nicht gesetzt — Etiketten können +noch nicht gedruckt werden“, muss das erst beim Betrieb der Anwendung eingetragen werden. **Drucke +vorher keine Etiketten.** + +## Betriebsmodus umstellen + +![Der Bestätigungsdialog für den Moduswechsel mit den Folgen](bilder/admin-moduswechsel-desktop.png) + +Oben auf der Einstellungsseite steht der aktuelle Modus mit einer Erklärung und darunter der Weg zum +anderen. Der Wechsel braucht keinen Neustart. + +**Zu Dienstleistung wechseln** klappt einen Dialog auf, der die Folgen aufzählt — dieselbe Prüfung, +die der Wechsel danach durchsetzt, keine nachgebaute zweite Wahrheit: + +- Die bisherige eigene Organisation wird zum **ersten Kunden**. +- Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen. +- Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, + Kundenkürzel auf den Etiketten. + +Dafür trägst du **Name als erster Kunde** und **Kürzel (2–6 Zeichen)** ein, setzt das Häkchen „Ja, +den Betriebsmodus jetzt auf … umstellen“ und drückst **Auf Dienstleistung umstellen**. + +Der Rückweg ist enger: **er geht nur mit genau einem nicht archivierten Kunden.** + +![Der abgelehnte Moduswechsel mit der Begründung](bilder/admin-moduswechsel-abgelehnt-desktop.png) + +Gibt es mehr, steht dort „Wechsel zu Intern gerade nicht möglich“ mit der Zahl und dem Hinweis, die +übrigen Kunden zu archivieren. Geht es, lauten die Folgen: + +- Der einzige Kunde wird zur eigenen Organisation und **verschwindet aus der Oberfläche**. +- Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet. +- Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf. + +Der Wechsel ist also in beide Richtungen eine Datenänderung und kein Datenverlust. Trotzdem ist er +keine Kleinigkeit: er ändert, was die ganze Anwendung zeigt — deshalb das Häkchen. + +## Kunden + +![Die Kundenliste mit Kürzel, Zählung und der eigenen Organisation](bilder/kunden-desktop.png) + +Den Menüpunkt **Kunden** gibt es nur im Modus Dienstleistung. Geräte, Personen, Abteilungen und +Läufe hängen an genau einem Kunden, und diese Grenze ist nicht nur eine Anzeige: ein Lauf kann kein +Gerät, keine Person und keine Abteilung eines fremden Kunden wählen. + +Je Kunde stehen das **Kürzel**, der Name und die Zählung (Geräte, Personen, Läufe). Der eigene +Betrieb trägt das Kennzeichen **eigene Organisation**. + +**Kunde anlegen** verlangt Name und Kürzel. Das **Kürzel erscheint auf den Etiketten** und lässt +sich in der Hostname-Regel als `{kundenkuerzel}` verwenden — wähl es also so, dass man es auf einem +Aufkleber erkennt. + +**Bearbeiten** ändert Name, Kürzel und Notizen. **archivieren** nimmt einen Kunden außer Dienst; +gelöscht wird nichts, sonst verlöre das Archiv seine Bezüge. Die eigene Organisation lässt sich +nicht archivieren. + +Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten +lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden. + +## Was protokolliert wird + +Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen, +veröffentlichte Vorlagen, eingespielte Vorlagen, archivierte und zurückgeholte Läufe, angelegte und +gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie. Eine +eigene Ansicht dafür gibt es in der Oberfläche noch nicht; was am Lauf selbst geschehen ist, steht in +seiner Abarbeitungsansicht und im Ausdruck. + +Ein **erzeugtes Passwort steht nie im Protokoll** — es wird nirgends gespeichert. Gäbe es ein Feld +dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben. diff --git a/docs/anleitung/08-fehlerbehebung.md b/docs/anleitung/08-fehlerbehebung.md new file mode 100644 index 0000000..6ee2620 --- /dev/null +++ b/docs/anleitung/08-fehlerbehebung.md @@ -0,0 +1,229 @@ +# Fehlerbehebung + +Symptom, Ursache, was zu tun ist. Jede Meldung steht hier so, wie Laufzettel sie zeigt. + +## „E-Mail-Adresse oder Passwort stimmt nicht.“ + +**Ursache:** falsche Zugangsdaten — **oder** ein deaktiviertes Konto. Die Meldung unterscheidet das +nicht, weil sie sonst verriete, welche Adressen es gibt. + +**Was tun:** Zugangsdaten prüfen. Bist du sicher, dass sie stimmen, lass einen Administrator unter +**Admin → Benutzer** nachsehen, ob dein Konto auf **aktiv** steht. Ein deaktiviertes Konto wird auch +mit noch gültiger Anmeldung abgewiesen. + +## „Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.“ + +**Ursache:** der Anmeldeanbieter antwortet nicht. + +**Was tun:** Gibt es ein lokales Konto, melde dich damit an — genau dafür ist es da. Sonst hilft nur +warten und die Störung melden. Der Notfallzugang steht unter **Admin → Benutzer → Lokales Konto +anlegen** und sollte eingerichtet sein, *bevor* der Anbieter ausfällt. + +## „Dafür fehlt dir die Berechtigung“ + +**Ursache:** deine Rolle darf diese Seite nicht. + +**Was tun:** In der Seitenleiste steht ohnehin nur, was du darfst — die Meldung kommt meist von einem +Link, den jemand weitergegeben hat. Brauchst du den Bereich wirklich, lass dir vom Administrator eine +stärkere Rolle geben. Kommt die Rolle vom Anmeldeanbieter, muss sie dort in der Gruppenzuordnung +geändert werden; eine hier gesetzte Rolle wird beim nächsten Anmelden überschrieben. + +## „Deine Anmeldung gilt nicht mehr.“ + +**Ursache:** die Sitzung ist abgelaufen, während das Formular offen stand. + +**Was tun:** Genau das, was die Meldung sagt: in einem **zweiten Tab** neu anmelden und dann hier +noch einmal klicken. Was du eingegeben hast, steht weiterhin auf der Seite — **lade sie nicht neu**, +das würde die Eingaben verwerfen. + +## Ein Schritt lässt sich nicht speichern + +**Ursache und was tun**, in dieser Reihenfolge prüfen: + +1. **„… ist archiviert und damit schreibgeschützt.“** — der Lauf ist archiviert. Siehe unten. +2. **„Ein Schritt, der nicht zutrifft, braucht eine Begründung“** oder **„Ein fehlgeschlagener + Schritt braucht eine Notiz“** — füll das Feld **Begründung** und drück **Mit Begründung + speichern**. Ohne Begründung stünde im Archiv ein übersprungener Schritt ohne Grund. +3. **Die Pille springt zurück, ohne Meldung** — die Verbindung war weg. Tippe noch einmal. Jeder + Schritt speichert einzeln, es geht also nur dieser eine Klick verloren. +4. **Nichts passiert, gar nichts** — lade die Seite neu. Ist die Anmeldung abgelaufen, siehst du + danach die Anmeldeseite und kommst nach dem Anmelden auf dieselbe Stelle zurück. +5. **Deine Rolle ist Leser** — dann gibt es die Pillen nicht, sondern nur die Anzeige des Zustands. + Läufe bearbeiten darf erst der Techniker. + +## Der Lauf lässt sich nicht abschließen + +**Ursache:** es ist noch ein Pflichtschritt offen. Der Knopf ist dann gesperrt, und darunter steht, +wie viele es sind; die Meldung nennt die Titel: „Der Lauf lässt sich noch nicht abschließen. Offen +sind: …“ + +**Was tun:** Die genannten Schritte erledigen — oder, wenn sie nicht zutreffen, mit Begründung als +**nicht zutreffend** eintragen. **Ein fehlgeschlagener Schritt zählt nicht als erledigt:** er ist +entweder zu beheben oder mit Begründung als nicht zutreffend einzutragen. Trifft ein ganzer Abschnitt +nicht zu, nimm **Abschnitt trifft nicht zu** — eine Begründung für achtzehn Schritte. + +## Der Lauf ist archiviert + +**Ursache:** jemand hat ihn archiviert. Damit ist er schreibgeschützt: kein Haken, kein Wert, keine +Notiz, kein Kommentar geht mehr. Das ist die Zusage, für die das Archiv da ist. + +**Was tun:** Prüf zuerst, ob du es wirklich brauchst. Für eine Ergänzung genügt oft ein neuer Lauf, +der auf den alten verweist — das ist der ehrlichere Weg, weil er datiert ist. + +Muss es der alte Lauf sein, bittet du einen **Administrator**: nur er sieht auf der Laufseite **aus +dem Archiv zurückholen**. Der Lauf steht danach wieder auf „abgeschlossen“ und lässt sich ändern; das +Zurückholen wird protokolliert. + +**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein +abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar. + +## „Kein Laufzettel-Tag“ nach dem Scannen + +**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die +Unterscheidung verriete, welche Codes es gibt: + +- Es gibt den Code nicht. +- Der Code hat nicht die richtige Form. +- Der Code gehört zu einem **anderen Kunden**. + +**Was tun:** Lies den Code unter dem QR ab und vergleich ihn. Ein Laufzettel-Code beginnt mit `LZ-` +und hat sechs Zeichen dahinter, **ohne** die Zeichen `0`, `O`, `1` und `I`. Wer `O` statt `Q` liest, +tippt einen Code, den es nicht geben kann — das ist Absicht, denn ein „verbessertes“ Zeichen könnte +auf ein anderes, vorhandenes Gerät führen. + +Stimmt der Code und du siehst trotzdem nichts, gehört der Aufkleber zu einem Kunden, den du nicht +sehen darfst. Frag einen Administrator. + +## Der Scanner startet nicht + +| Meldung | Ursache | Was tun | +|---|---|---| +| „Die Kamera gibt es nur über HTTPS.“ | die Seite läuft unverschlüsselt | Die verschlüsselte Adresse verwenden. Bis dahin: Code eintippen. | +| „Die Kamera ist nicht freigegeben.“ | Berechtigung verweigert | In den Einstellungen des Browsers die Kamera für diese Seite freigeben und neu laden. | +| „Dieser Browser gibt keine Kamera frei.“ | kein Kamerazugriff möglich | Code eintippen. | +| „Der QR-Leser lässt sich nicht starten.“ | der Decoder ließ sich nicht laden | Seite neu laden. Bleibt es dabei, einem Administrator melden; bis dahin Code eintippen. | +| „Das ist kein Laufzettel-Tag.“ | ein fremder QR-Code im Bild | Den richtigen Aufkleber ins Bild halten — den mit `LZ-…`. | + +In allen Fällen gilt: unter dem Kamerabild steht das Feld **Code eintippen**. Der Code steht lesbar +unter dem QR. Ein Scanner, der nicht startet, ist keine Sackgasse. + +## Das Etikett wird nicht gelesen + +**Ursache und was tun:** + +1. **Falsch skaliert gedruckt.** Im Druckdialog **Skalierung auf 100 %** stellen und **Kopf- und + Fußzeilen abschalten**. „An Seite anpassen“ verkleinert den QR-Code und frisst die Ruhezone am + Rand, die der Leser braucht. +2. **Der Drucker hat gespart.** Manche Treiber lassen Flächen weg oder drucken sie grau. Ein QR-Code + braucht harten Schwarz-Weiß-Kontrast; stell den Farbmodus auf normal, nicht auf Toner-Sparen. +3. **Der Aufkleber ist beschädigt oder verschmutzt.** Tipp den Code darunter ein und drucke das + Etikett neu: auf der Geräteseite **Etikett drucken**. Der Code bleibt derselbe. +4. **Das Etikett sitzt neben der Stanzung.** Dann war die Skalierung falsch — siehe Punkt 1. Das + Raster des A4-Bogens füllt das Blatt genau aus und verträgt keinen Seitenrand. +5. **Der QR-Code führt ins Leere.** Dann war beim Drucken die Adresse der Anwendung nicht gesetzt + (die Druckansicht warnt davor in Rot). Ein geklebter Aufkleber lässt sich nicht berichtigen: die + Adresse eintragen lassen, Etiketten neu drucken und **überkleben**. Der Tag-Code selbst bleibt + gültig. + +## „APP_URL ist nicht gesetzt“ + +**Ursache:** die Anwendung weiß nicht, unter welcher Adresse sie erreichbar ist. Die QR-Codes zeigen +dann auf `http://localhost:3000` und führen von keinem anderen Gerät irgendwohin. + +**Was tun:** **Nicht drucken.** Ein Administrator trägt die Adresse in der Umgebung der Anwendung ein; +in der Oberfläche geht das nicht, weil gedruckte Aufkleber sonst ins Leere zeigen würden. Unter +**Admin → Einstellungen** steht ganz unten, welche Adresse gerade gilt. + +## Der Verzeichnisabgleich meldet einen Fehler + +| Meldung | Ursache | Was tun | +|---|---|---| +| „… ist keine LDAP-Adresse.“ | Schema fehlt oder falsch | `ldaps://host:636`, `ldap+starttls://host:389` oder `ldap://host:389` eintragen. | +| „In … fehlt der Rechnername.“ | nur das Schema eingetragen | Rechner und Port ergänzen. | +| „… enthält einen Pfad.“ | der Basis-DN steht in der Adresse | Adresse auf Schema, Rechner und Port kürzen; der Basis-DN hat sein eigenes Feld. | +| „… enthält Zugangsdaten.“ | `benutzer@host` in der Adresse | Bind-DN und Passwort in ihre eigenen Felder. | +| „Das Bind-Passwort fehlt.“ | leeres Passwortfeld beim Anlegen | Passwort eintragen; ein anonymer Bind wird nicht unterstützt. | +| Fehler beim Verbinden oder Anmelden | Netz, Firewall, Zertifikat, falsches Dienstkonto | Erreichbarkeit und Port prüfen. Bei einem Zertifikat aus eigener CA das **PEM** hinterlegen. Dann **Verbindung testen**. | +| „Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders das Kennungsattribut.“ | kein einziger Eintrag ließ sich zuordnen | Das ist die Notbremse: es wurde **nichts** abgeschaltet. Unter **Attributzuordnung anpassen** die **Kennung (dauerhaft)** prüfen — im Active Directory `objectGUID`, im OpenLDAP meist `entryUUID`. | +| „Für … läuft gerade schon ein Abgleich.“ | ein zweiter Lauf, oft der Zeitplan | Abwarten und das **Abgleich-Log** ansehen. | +| „Das CA-Zertifikat muss im PEM-Format vorliegen …“ | falscher Text eingefügt | Der Text muss mit `-----BEGIN CERTIFICATE-----` beginnen. | + +**Übersprungene Einträge** im Log sind kein Fehler der Quelle, sondern einzelne krumme Konten. Steigt +die Zahl, prüf die Attributzuordnung. + +**Der Abgleich lief, hat aber nichts getan** („0 angelegt, 0 aktualisiert“): das ist die Auskunft, +dass sich im Verzeichnis nichts geändert hat. Alles in Ordnung. + +## „Der Entwurf wurde zwischenzeitlich von jemand anderem gespeichert.“ + +**Ursache:** zwei Redakteure am selben Entwurf. + +**Was tun:** **Lade die Seite nicht neu** — deine Eingaben stehen noch da und wären danach weg. Öffne +den Entwurf in einem zweiten Tab, sieh dir die fremde Fassung an und trag deine Änderungen dort nach. +Wird es viel, hilft **Aufbau als JSON herauskopieren** unten an der Speicherleiste: damit rettest du +deinen Stand als Text heraus. + +Denselben Ausweg gibt es bei **jedem** fehlgeschlagenen Speichern im Editor, egal woran es lag. + +## Der Lauf zeigt einen Platzhalter wie `{{ad_gruppe}}` + +**Ursache:** der Platzhalter gehört zu keiner Variablen und zu keinem eingebauten Wert. Im Lauf bleibt +er als Text stehen — sichtbar, aber nutzlos. Meist ein Tippfehler in der Vorlage. + +**Was tun:** Im Befehl vor dem Ausführen von Hand ersetzen (der Kasten warnt davor: „konnte nicht +eingesetzt werden und steht so im Befehl“). Dann sag es dem Redakteur: im Editor steht die Warnung +oben, und die Vorlage braucht entweder die Variable oder den richtigen Schlüssel. Das wirkt erst in +der **nächsten** Version — laufende Läufe hängen an ihrer. + +## Das Passwort ist weg + +**Ursache:** `{{generiertes_passwort}}` wird bei jeder Anzeige neu erzeugt und **nirgends +gespeichert**. Sobald sich die Seite neu aufbaut — auch nach dem Speichern eines Schritts — steht dort +ein anderes. Auf dem Ausdruck steht `[Passwort – nicht gespeichert]`. + +**Was tun:** Es gibt keinen Weg, es nachzuschlagen; es existiert nicht mehr. Setz im Zielsystem ein +neues Passwort und gib es weiter. Beim nächsten Mal: **kopieren und sofort weiterreichen**, bevor du +etwas anderes anklickst. Meldet der Streifen oben „Beim Neuladen entsteht ein neues Passwort“, +kopiere das angezeigte erst. + +## Ein Gerät lässt sich nicht löschen + +**Ursache:** an dem Gerät hängt mindestens ein Lauf — die Meldung nennt die Zahl. Ein Lauf muss für +immer nachvollziehbar bleiben. Oder deine Rolle ist nicht **Administrator**; nur er sieht den Knopf. + +**Was tun:** **archivieren** statt löschen. Das Gerät verschwindet aus allen Auswahllisten und nimmt +keine neue Arbeit an, bleibt aber in jeder Historie — und lässt sich mit **wieder freigeben** +zurückholen. + +Und bedenke: eine echte Löschung **verbrennt den Tag-Code für immer**. Ein schon gedruckter Aufkleber +dieses Codes ist danach wertlos. + +## Eine Vorlage steht nicht zur Wahl + +**Ursache:** eine von vier, in dieser Reihenfolge zu prüfen: + +1. Sie hat **keine veröffentlichte Version** — ein Entwurf ist noch niemandes Arbeitsanweisung. +2. Sie ist **archiviert**. +3. Sie gehört **einem anderen Kunden** („nur Musterfirma GmbH“). +4. Auf der Geräteseite: sie verlangt **Pflichtangaben**. Dann steht sie mit dem Zusatz „fragt noch + nach Angaben“ dabei und führt in den Start-Dialog, statt sofort zu starten — hier wird ja niemand + gefragt. + +**Was tun:** Fall 1 und 2 löst ein Redakteur auf der Vorlagenseite (**Veröffentlichen** +beziehungsweise **wieder freigeben**), Fall 3 ein Administrator über die Kundenzuordnung in den +Kopfdaten. Fall 4 ist kein Fehler: nimm den Start-Dialog. + +## Ein Lauf oder ein Gerät ist nicht auffindbar + +**Ursache und was tun:** + +- **Die Liste ist gekappt.** Die Laufliste zeigt höchstens 200 Läufe, die Archivsuche höchstens 100 + Treffer, die Gerätehistorie die letzten 20. Steht das dabei, such genauer statt zu blättern: + Titel, Ticket, Hostname, Person oder Tag-Code. +- **Der Lauf ist archiviert.** Archivierte Läufe stehen **nicht** in der Laufliste. Nimm das + **Archiv** — dort auch das Häkchen **auch offene und laufende Läufe**, wenn du beides zugleich + suchst. +- **Die Person ist deaktiviert.** Setz in der Personenliste das Häkchen **auch deaktivierte**. +- **Das Gerät ist archiviert.** Setz in der Geräteliste das Häkchen **auch archivierte**. +- **Es gehört einem anderen Kunden.** Dann ist es für dich nicht vorhanden, und daran ändert auch ein + weitergegebener Link nichts. diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md new file mode 100644 index 0000000..6e3439f --- /dev/null +++ b/docs/anleitung/README.md @@ -0,0 +1,55 @@ +# Laufzettel — Anleitung + +Laufzettel ist eine Checkliste für wiederkehrende IT-Arbeiten. Ein Redakteur schreibt einmal auf, +was bei einer PC-Neuinstallation, einem Onboarding oder einem Patchday zu tun ist — das ist eine +**Vorlage**. Aus einer veröffentlichten Vorlage entsteht ein **Lauf**: die Abschrift für einen +bestimmten Fall, mit einem bestimmten Gerät und einer bestimmten Person. Den Lauf arbeitet ein +Techniker Schritt für Schritt ab, oft am Handy, direkt am Gerät. Am Ende wird er abgeschlossen und +archiviert und ändert sich nie wieder. + +Auf jedem Gerät klebt ein QR-Aufkleber. Wer ihn scannt, landet auf der Geräteseite und sieht, was +dort zu tun ist und was dort schon getan wurde. Der Code auf dem Aufkleber wird nie geändert und +nie an ein zweites Gerät vergeben — er ist die einzige Angabe am Gerät, die Jahre hält. + +![Die Übersicht nach dem Anmelden mit den Blöcken „Überfällig“, „Mir zugewiesen“ und „Zuletzt bearbeitet“](bilder/uebersicht-desktop.png) + +## Wer welches Kapitel braucht + +**Techniker** — du arbeitest Läufe ab, scannst Aufkleber und druckst Etiketten: + +- [01 Erste Schritte](01-erste-schritte.md) — anmelden, wo was steht +- [02 Läufe abarbeiten](02-laeufe-abarbeiten.md) — der Alltag, am Schreibtisch und am Handy +- [03 QR-Aufkleber und Tags](03-qr-tags.md) — scannen, registrieren, Etiketten drucken +- [08 Fehlerbehebung](08-fehlerbehebung.md) — wenn etwas klemmt + +**Administrator oder Redakteur** — du richtest ein und pflegst: + +- [04 Vorlagen bauen](04-vorlagen.md) — Abschnitte, Schritte, Variablen, veröffentlichen +- [05 Mitarbeiter, Abteilungen und Geräte](05-mitarbeiter-geraete.md) — Stammdaten und CSV-Import +- [06 Verzeichnis anbinden](06-verzeichnis.md) — Active Directory als Quelle der Personen +- [07 Verwaltung](07-admin.md) — Benutzer, Rollen, Einstellungen, Betriebsmodus, Kunden + +## Zwei Betriebsmodi + +Laufzettel läuft in einem von zwei Modi. Der Modus entscheidet, ob es eine Kundenebene gibt, und +ändert einige Beschriftungen. + +| Modus | Für wen | Kundenebene | +|---|---|---| +| **Intern** | eine Firma und ihre eigenen Mitarbeiter | unsichtbar | +| **Dienstleistung** | IT-Dienstleister mit mehreren Kunden | sichtbar: Kundenliste, Kundenwahl, Kürzel auf dem Etikett | + +Im Modus Intern heißt der Menüpunkt „Mitarbeiter & Geräte“, im Modus Dienstleistung „Personen & +Geräte“; dort kommt außerdem „Kunden“ dazu, und das Verzeichnis fällt weg. Wo diese Anleitung +„Person“ schreibt, heißt es im Modus Intern auch „Mitarbeiter“ — gemeint ist dasselbe. Umgestellt +wird der Modus unter Admin, siehe [Kapitel 07](07-admin.md). + +## Drei Zusagen, die überall gelten + +1. **Eine veröffentlichte Version wird nie verändert.** Sonst zeigte ein alter Lauf Schritte, die + es damals nicht gab. „Bearbeiten“ erzeugt stattdessen eine Kopie als Entwurf. +2. **Ein archivierter Lauf ist schreibgeschützt.** Kein Haken, keine Notiz, kein Kommentar geht + danach noch. Nur ein Administrator holt ihn zurück. +3. **Gelöscht wird fast nichts.** Geräte, Abteilungen, Vorlagen und Kunden werden archiviert, + Personen nur deaktiviert. Ein archivierter Lauf muss auch in zehn Jahren noch sagen können, für + wen er lief. diff --git a/docs/anleitung/bilder/abgleich-log-desktop.png b/docs/anleitung/bilder/abgleich-log-desktop.png new file mode 100644 index 0000000..800bdd9 Binary files /dev/null and b/docs/anleitung/bilder/abgleich-log-desktop.png differ diff --git a/docs/anleitung/bilder/abgleich-log-handy.png b/docs/anleitung/bilder/abgleich-log-handy.png new file mode 100644 index 0000000..b94c8c2 Binary files /dev/null and b/docs/anleitung/bilder/abgleich-log-handy.png differ diff --git a/docs/anleitung/bilder/abteilungen-desktop.png b/docs/anleitung/bilder/abteilungen-desktop.png new file mode 100644 index 0000000..24d503d Binary files /dev/null and b/docs/anleitung/bilder/abteilungen-desktop.png differ diff --git a/docs/anleitung/bilder/abteilungen-handy.png b/docs/anleitung/bilder/abteilungen-handy.png new file mode 100644 index 0000000..4a43e5f Binary files /dev/null and b/docs/anleitung/bilder/abteilungen-handy.png differ diff --git a/docs/anleitung/bilder/abteilungen-vorschlaege-desktop.png b/docs/anleitung/bilder/abteilungen-vorschlaege-desktop.png new file mode 100644 index 0000000..24d503d Binary files /dev/null and b/docs/anleitung/bilder/abteilungen-vorschlaege-desktop.png differ diff --git a/docs/anleitung/bilder/abteilungen-vorschlaege-handy.png b/docs/anleitung/bilder/abteilungen-vorschlaege-handy.png new file mode 100644 index 0000000..4a43e5f Binary files /dev/null and b/docs/anleitung/bilder/abteilungen-vorschlaege-handy.png differ diff --git a/docs/anleitung/bilder/admin-benutzer-desktop.png b/docs/anleitung/bilder/admin-benutzer-desktop.png new file mode 100644 index 0000000..e290f08 Binary files /dev/null and b/docs/anleitung/bilder/admin-benutzer-desktop.png differ diff --git a/docs/anleitung/bilder/admin-benutzer-handy.png b/docs/anleitung/bilder/admin-benutzer-handy.png new file mode 100644 index 0000000..f3f4d82 Binary files /dev/null and b/docs/anleitung/bilder/admin-benutzer-handy.png differ diff --git a/docs/anleitung/bilder/admin-einstellungen-desktop.png b/docs/anleitung/bilder/admin-einstellungen-desktop.png new file mode 100644 index 0000000..66a4902 Binary files /dev/null and b/docs/anleitung/bilder/admin-einstellungen-desktop.png differ diff --git a/docs/anleitung/bilder/admin-einstellungen-handy.png b/docs/anleitung/bilder/admin-einstellungen-handy.png new file mode 100644 index 0000000..9542480 Binary files /dev/null and b/docs/anleitung/bilder/admin-einstellungen-handy.png differ diff --git a/docs/anleitung/bilder/admin-moduswechsel-abgelehnt-desktop.png b/docs/anleitung/bilder/admin-moduswechsel-abgelehnt-desktop.png new file mode 100644 index 0000000..6b5d4f5 Binary files /dev/null and b/docs/anleitung/bilder/admin-moduswechsel-abgelehnt-desktop.png differ diff --git a/docs/anleitung/bilder/admin-moduswechsel-abgelehnt-handy.png b/docs/anleitung/bilder/admin-moduswechsel-abgelehnt-handy.png new file mode 100644 index 0000000..67ca0b0 Binary files /dev/null and b/docs/anleitung/bilder/admin-moduswechsel-abgelehnt-handy.png differ diff --git a/docs/anleitung/bilder/admin-moduswechsel-desktop.png b/docs/anleitung/bilder/admin-moduswechsel-desktop.png new file mode 100644 index 0000000..13dc46b Binary files /dev/null and b/docs/anleitung/bilder/admin-moduswechsel-desktop.png differ diff --git a/docs/anleitung/bilder/admin-moduswechsel-handy.png b/docs/anleitung/bilder/admin-moduswechsel-handy.png new file mode 100644 index 0000000..586cec2 Binary files /dev/null and b/docs/anleitung/bilder/admin-moduswechsel-handy.png differ diff --git a/docs/anleitung/bilder/anmelden-desktop.png b/docs/anleitung/bilder/anmelden-desktop.png new file mode 100644 index 0000000..a009bfb Binary files /dev/null and b/docs/anleitung/bilder/anmelden-desktop.png differ diff --git a/docs/anleitung/bilder/anmelden-handy.png b/docs/anleitung/bilder/anmelden-handy.png new file mode 100644 index 0000000..3dd1a9f Binary files /dev/null and b/docs/anleitung/bilder/anmelden-handy.png differ diff --git a/docs/anleitung/bilder/archiv-desktop.png b/docs/anleitung/bilder/archiv-desktop.png new file mode 100644 index 0000000..95dec46 Binary files /dev/null and b/docs/anleitung/bilder/archiv-desktop.png differ diff --git a/docs/anleitung/bilder/archiv-handy.png b/docs/anleitung/bilder/archiv-handy.png new file mode 100644 index 0000000..6b41dec Binary files /dev/null and b/docs/anleitung/bilder/archiv-handy.png differ diff --git a/docs/anleitung/bilder/druckansicht-desktop.png b/docs/anleitung/bilder/druckansicht-desktop.png new file mode 100644 index 0000000..887bf52 Binary files /dev/null and b/docs/anleitung/bilder/druckansicht-desktop.png differ diff --git a/docs/anleitung/bilder/druckansicht-handy.png b/docs/anleitung/bilder/druckansicht-handy.png new file mode 100644 index 0000000..b6e8ac2 Binary files /dev/null and b/docs/anleitung/bilder/druckansicht-handy.png differ diff --git a/docs/anleitung/bilder/etikett-62x29-desktop.png b/docs/anleitung/bilder/etikett-62x29-desktop.png new file mode 100644 index 0000000..1788601 Binary files /dev/null and b/docs/anleitung/bilder/etikett-62x29-desktop.png differ diff --git a/docs/anleitung/bilder/etikett-62x29-handy.png b/docs/anleitung/bilder/etikett-62x29-handy.png new file mode 100644 index 0000000..254bdf1 Binary files /dev/null and b/docs/anleitung/bilder/etikett-62x29-handy.png differ diff --git a/docs/anleitung/bilder/etiketten-a4-desktop.png b/docs/anleitung/bilder/etiketten-a4-desktop.png new file mode 100644 index 0000000..0c86539 Binary files /dev/null and b/docs/anleitung/bilder/etiketten-a4-desktop.png differ diff --git a/docs/anleitung/bilder/etiketten-a4-handy.png b/docs/anleitung/bilder/etiketten-a4-handy.png new file mode 100644 index 0000000..e3db30f Binary files /dev/null and b/docs/anleitung/bilder/etiketten-a4-handy.png differ diff --git a/docs/anleitung/bilder/geraete-liste-desktop.png b/docs/anleitung/bilder/geraete-liste-desktop.png new file mode 100644 index 0000000..e1f504e Binary files /dev/null and b/docs/anleitung/bilder/geraete-liste-desktop.png differ diff --git a/docs/anleitung/bilder/geraete-liste-handy.png b/docs/anleitung/bilder/geraete-liste-handy.png new file mode 100644 index 0000000..cb323d8 Binary files /dev/null and b/docs/anleitung/bilder/geraete-liste-handy.png differ diff --git a/docs/anleitung/bilder/geraeteseite-desktop.png b/docs/anleitung/bilder/geraeteseite-desktop.png new file mode 100644 index 0000000..e245f29 Binary files /dev/null and b/docs/anleitung/bilder/geraeteseite-desktop.png differ diff --git a/docs/anleitung/bilder/geraeteseite-handy.png b/docs/anleitung/bilder/geraeteseite-handy.png new file mode 100644 index 0000000..2940ede Binary files /dev/null and b/docs/anleitung/bilder/geraeteseite-handy.png differ diff --git a/docs/anleitung/bilder/kunden-desktop.png b/docs/anleitung/bilder/kunden-desktop.png new file mode 100644 index 0000000..200d0f8 Binary files /dev/null and b/docs/anleitung/bilder/kunden-desktop.png differ diff --git a/docs/anleitung/bilder/kunden-handy.png b/docs/anleitung/bilder/kunden-handy.png new file mode 100644 index 0000000..9a17f38 Binary files /dev/null and b/docs/anleitung/bilder/kunden-handy.png differ diff --git a/docs/anleitung/bilder/laeufe-liste-desktop.png b/docs/anleitung/bilder/laeufe-liste-desktop.png new file mode 100644 index 0000000..740180d Binary files /dev/null and b/docs/anleitung/bilder/laeufe-liste-desktop.png differ diff --git a/docs/anleitung/bilder/laeufe-liste-handy.png b/docs/anleitung/bilder/laeufe-liste-handy.png new file mode 100644 index 0000000..ccb59f0 Binary files /dev/null and b/docs/anleitung/bilder/laeufe-liste-handy.png differ diff --git a/docs/anleitung/bilder/lauf-abarbeiten-desktop.png b/docs/anleitung/bilder/lauf-abarbeiten-desktop.png new file mode 100644 index 0000000..9aa3971 Binary files /dev/null and b/docs/anleitung/bilder/lauf-abarbeiten-desktop.png differ diff --git a/docs/anleitung/bilder/lauf-abarbeiten-handy.png b/docs/anleitung/bilder/lauf-abarbeiten-handy.png new file mode 100644 index 0000000..5691cc7 Binary files /dev/null and b/docs/anleitung/bilder/lauf-abarbeiten-handy.png differ diff --git a/docs/anleitung/bilder/lauf-befehlsschritt-desktop.png b/docs/anleitung/bilder/lauf-befehlsschritt-desktop.png new file mode 100644 index 0000000..33507d2 Binary files /dev/null and b/docs/anleitung/bilder/lauf-befehlsschritt-desktop.png differ diff --git a/docs/anleitung/bilder/lauf-befehlsschritt-handy.png b/docs/anleitung/bilder/lauf-befehlsschritt-handy.png new file mode 100644 index 0000000..d3ad785 Binary files /dev/null and b/docs/anleitung/bilder/lauf-befehlsschritt-handy.png differ diff --git a/docs/anleitung/bilder/lauf-starten-desktop.png b/docs/anleitung/bilder/lauf-starten-desktop.png new file mode 100644 index 0000000..39a3a50 Binary files /dev/null and b/docs/anleitung/bilder/lauf-starten-desktop.png differ diff --git a/docs/anleitung/bilder/lauf-starten-handy.png b/docs/anleitung/bilder/lauf-starten-handy.png new file mode 100644 index 0000000..aad7714 Binary files /dev/null and b/docs/anleitung/bilder/lauf-starten-handy.png differ diff --git a/docs/anleitung/bilder/mitarbeiter-liste-desktop.png b/docs/anleitung/bilder/mitarbeiter-liste-desktop.png new file mode 100644 index 0000000..46b78fc Binary files /dev/null and b/docs/anleitung/bilder/mitarbeiter-liste-desktop.png differ diff --git a/docs/anleitung/bilder/mitarbeiter-liste-handy.png b/docs/anleitung/bilder/mitarbeiter-liste-handy.png new file mode 100644 index 0000000..d5020d4 Binary files /dev/null and b/docs/anleitung/bilder/mitarbeiter-liste-handy.png differ diff --git a/docs/anleitung/bilder/mitarbeiter-seite-desktop.png b/docs/anleitung/bilder/mitarbeiter-seite-desktop.png new file mode 100644 index 0000000..996d18c Binary files /dev/null and b/docs/anleitung/bilder/mitarbeiter-seite-desktop.png differ diff --git a/docs/anleitung/bilder/mitarbeiter-seite-handy.png b/docs/anleitung/bilder/mitarbeiter-seite-handy.png new file mode 100644 index 0000000..13089a1 Binary files /dev/null and b/docs/anleitung/bilder/mitarbeiter-seite-handy.png differ diff --git a/docs/anleitung/bilder/mitarbeiter-verzeichnis-desktop.png b/docs/anleitung/bilder/mitarbeiter-verzeichnis-desktop.png new file mode 100644 index 0000000..946a61a Binary files /dev/null and b/docs/anleitung/bilder/mitarbeiter-verzeichnis-desktop.png differ diff --git a/docs/anleitung/bilder/mitarbeiter-verzeichnis-handy.png b/docs/anleitung/bilder/mitarbeiter-verzeichnis-handy.png new file mode 100644 index 0000000..7a3eecd Binary files /dev/null and b/docs/anleitung/bilder/mitarbeiter-verzeichnis-handy.png differ diff --git a/docs/anleitung/bilder/personen-import-desktop.png b/docs/anleitung/bilder/personen-import-desktop.png new file mode 100644 index 0000000..a0883f1 Binary files /dev/null and b/docs/anleitung/bilder/personen-import-desktop.png differ diff --git a/docs/anleitung/bilder/personen-import-handy.png b/docs/anleitung/bilder/personen-import-handy.png new file mode 100644 index 0000000..5a20c0d Binary files /dev/null and b/docs/anleitung/bilder/personen-import-handy.png differ diff --git a/docs/anleitung/bilder/scannen-desktop.png b/docs/anleitung/bilder/scannen-desktop.png new file mode 100644 index 0000000..e100cae Binary files /dev/null and b/docs/anleitung/bilder/scannen-desktop.png differ diff --git a/docs/anleitung/bilder/scannen-handy.png b/docs/anleitung/bilder/scannen-handy.png new file mode 100644 index 0000000..db173d2 Binary files /dev/null and b/docs/anleitung/bilder/scannen-handy.png differ diff --git a/docs/anleitung/bilder/tag-registrieren-desktop.png b/docs/anleitung/bilder/tag-registrieren-desktop.png new file mode 100644 index 0000000..536d236 Binary files /dev/null and b/docs/anleitung/bilder/tag-registrieren-desktop.png differ diff --git a/docs/anleitung/bilder/tag-registrieren-handy.png b/docs/anleitung/bilder/tag-registrieren-handy.png new file mode 100644 index 0000000..8ace6eb Binary files /dev/null and b/docs/anleitung/bilder/tag-registrieren-handy.png differ diff --git a/docs/anleitung/bilder/tags-desktop.png b/docs/anleitung/bilder/tags-desktop.png new file mode 100644 index 0000000..7bbda12 Binary files /dev/null and b/docs/anleitung/bilder/tags-desktop.png differ diff --git a/docs/anleitung/bilder/tags-handy.png b/docs/anleitung/bilder/tags-handy.png new file mode 100644 index 0000000..eb20264 Binary files /dev/null and b/docs/anleitung/bilder/tags-handy.png differ diff --git a/docs/anleitung/bilder/uebersicht-desktop.png b/docs/anleitung/bilder/uebersicht-desktop.png new file mode 100644 index 0000000..335d898 Binary files /dev/null and b/docs/anleitung/bilder/uebersicht-desktop.png differ diff --git a/docs/anleitung/bilder/uebersicht-handy.png b/docs/anleitung/bilder/uebersicht-handy.png new file mode 100644 index 0000000..cfd6333 Binary files /dev/null and b/docs/anleitung/bilder/uebersicht-handy.png differ diff --git a/docs/anleitung/bilder/verbindungstest-desktop.png b/docs/anleitung/bilder/verbindungstest-desktop.png new file mode 100644 index 0000000..04953a5 Binary files /dev/null and b/docs/anleitung/bilder/verbindungstest-desktop.png differ diff --git a/docs/anleitung/bilder/verbindungstest-handy.png b/docs/anleitung/bilder/verbindungstest-handy.png new file mode 100644 index 0000000..8e86c44 Binary files /dev/null and b/docs/anleitung/bilder/verbindungstest-handy.png differ diff --git a/docs/anleitung/bilder/verzeichnis-desktop.png b/docs/anleitung/bilder/verzeichnis-desktop.png new file mode 100644 index 0000000..278f45e Binary files /dev/null and b/docs/anleitung/bilder/verzeichnis-desktop.png differ diff --git a/docs/anleitung/bilder/verzeichnis-handy.png b/docs/anleitung/bilder/verzeichnis-handy.png new file mode 100644 index 0000000..d586177 Binary files /dev/null and b/docs/anleitung/bilder/verzeichnis-handy.png differ diff --git a/docs/anleitung/bilder/vorlagen-editor-desktop.png b/docs/anleitung/bilder/vorlagen-editor-desktop.png new file mode 100644 index 0000000..bc86cde Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-editor-desktop.png differ diff --git a/docs/anleitung/bilder/vorlagen-editor-handy.png b/docs/anleitung/bilder/vorlagen-editor-handy.png new file mode 100644 index 0000000..6ac8e08 Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-editor-handy.png differ diff --git a/docs/anleitung/bilder/vorlagen-liste-desktop.png b/docs/anleitung/bilder/vorlagen-liste-desktop.png new file mode 100644 index 0000000..22e08fe Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-liste-desktop.png differ diff --git a/docs/anleitung/bilder/vorlagen-liste-handy.png b/docs/anleitung/bilder/vorlagen-liste-handy.png new file mode 100644 index 0000000..48a1975 Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-liste-handy.png differ diff --git a/docs/anleitung/bilder/vorlagen-seite-desktop.png b/docs/anleitung/bilder/vorlagen-seite-desktop.png new file mode 100644 index 0000000..378c748 Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-seite-desktop.png differ diff --git a/docs/anleitung/bilder/vorlagen-seite-handy.png b/docs/anleitung/bilder/vorlagen-seite-handy.png new file mode 100644 index 0000000..996fbb5 Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-seite-handy.png differ diff --git a/docs/devlog.md b/docs/devlog.md index bb87336..57d6e8a 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -20,11 +20,12 @@ als Unterpunkte mit Aufgabe und Dauer. | #015 | M10 – Dashboard und Archiv | 2 h 20 min | | #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min | | #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min | -| | **Summe** | **27 h 40 min** | +| #018 | M13 – E2E-Tests, Anleitung, PDF | 2 h 15 min | +| | **Summe** | **29 h 55 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 27 h 40 min | in Arbeit | +| v0.1.0 | 29 h 55 min | in Arbeit | --- @@ -829,3 +830,72 @@ als Unterpunkte mit Aufgabe und Dauer. [#021](issues/021-stabiler-schluessel-vorlagen.md) (stabiler Schlüssel für Seed-Vorlagen) - #018 E2E-Tests, Anleitung und PDF — die Hauptabläufe in beiden Betriebsmodi, die bebilderte Anleitung für Techniker und Admins und der PDF-Export über Chromium. + +--- + +## 2026-09-26 · Sitzung 14 · 05:30–10:50 + +**Issue:** #018 (M13) +**Version:** 0.1.0 + +### Erledigt + +- **Vier Teile parallel**: vorhandene Abläufe robust machen, vier neue Abläufe, die Anleitung, der + PDF-Export. Das Fundament — gemeinsamer Helfer, Vorabprüfung, Betriebsmodus — kam vorher von mir +- **Anleitung** unter `docs/anleitung/`: neun Kapitel, rund 11.600 Wörter, getrennt für Technik und + Verwaltung, mit 35 Bildern und einem Fehlerbehebungskapitel aus 18 echten Meldungen +- **`pnpm docs:pdf`**: 72 Seiten A4, Deckblatt, Inhaltsverzeichnis, Seitenzahlen, hell gedruckt, + ohne neue Abhängigkeit (`react-markdown` über `react-dom/server`) +- **Markierungen** in den Screenshots: Rahmen in der Akzentfarbe mit Nummer, für die Stellen, die + ein Kapitel gerade beschreibt +- **Vier neue Abläufe**: Hauptablauf von vorn bis hinten, Onboarding mit Zwischenablage-Vergleich, + Rollen, und 390 px auf achtzehn Seiten +- **101 Ende-zu-Ende-Abläufe** grün (vorher 67), Gesamtsuite unverändert grün + +### Entscheidungen + +- **Die Voraussetzungen eines Laufs werden geprüft, nicht behauptet.** `tests/e2e/global-setup.ts` + fragt vor dem ersten Ablauf: läuft ein gebauter Stand, steht der Seed-Bestand, und liest der + Server **dieselbe** Datenbank wie der Testprozess? Jede Meldung nennt die Ursache und was zu tun + ist. Die dritte Frage hat in #017 einen halben Abend gekostet. +- **Das PDF kommt nicht ins Repo.** Es entsteht aus Kapiteln und Bildern, die beide versioniert + sind, und würde bei jedem Lauf 8,7 MB neu anhängen. Der Auftrag sagt „liegt jedem Release bei“ — + genau so wird es gemacht. +- **Die `beispiel:*`-Skripte bleiben.** Sie sollten laut ihrem Kopfkommentar mit #017 verschwinden; + dort wurden zwei Dinge verwechselt. Der Seed richtet eine **Installation** ein, die Skripte + stellen her, was **Screenshots und Abläufe** brauchen: einen offenen Entwurf, einen halb + abgearbeiteten Lauf, eine Person aus dem Verzeichnis. + +### Probleme + +- **Das Projekt „handy" prüfte 412 px statt 390.** `devices["Pixel 7"]` ist 412 breit; CLAUDE.md und + das Abnahmekriterium nennen 390. Jeder „trägt am Handy ohne horizontales Scrollen"-Test lief damit + gegen eine Breite, die niemand verlangt hatte — zweiundzwanzig Pixel, die eine ausbrechende Seite + tadellos aussehen lassen. Nach der Korrektur blieb die Suite grün; die Zusage hielt also, sie war + nur nie geprüft. +- **Eine Auskunft, die niemand je gesehen hat.** Ein angemeldeter Benutzer ohne Recht wurde auf + `/anmelden?fehler=keinrecht` geschickt — und die Anmeldeseite schickt einen Angemeldeten sofort + weiter auf die Übersicht. Die Meldung „Für diesen Bereich fehlt dir die Berechtigung" war + unerreichbar, der Kommentar im Code versprach sie trotzdem. Jetzt gibt es `/kein-zugriff`. +- **Zwei Tests, die Schaden anrichteten.** Der Verzeichnis-Ablauf nahm die erste vorhandene Quelle + und schrieb ihre Attributzuordnung um — in einer echten Installation die produktive. Und ein + Nachlauf setzte die Namensregeln auf leer, statt den vorherigen Wert zurückzustellen: er löschte, + was der Seed eingerichtet hatte. Beides stammt aus meinen eigenen Meilensteinen. +- **Vier stille Annahmen**: Vorlage über die Position in der Liste, „die Datenbank enthält schon + Läufe", `test.skip` bei fehlenden Daten, und Fixtures im Kopfkommentar statt geprüft. Alle vier + fielen erst auf, als zum ersten Mal gegen eine **frisch eingerichtete** Installation geprüft + wurde. +- **Sechs Abläufe kannten die Kundenebene nicht.** Im Modus DIENSTLEISTUNG hat jedes Anlege-Formular + ein Feld mehr, und es ist Pflicht: `kundeErmitteln` wirft sonst. Schlimmer als das Feld ist die + Regel dahinter — ein Lauf kann strukturell kein Gerät und keine Abteilung eines fremden Kunden + wählen, dafür sorgen zusammengesetzte Fremdschlüssel. „Irgendein Kunde" ist dort nicht ungenau, + sondern rot. Beim Onboarding war es sogar eine Reihenfolge: die Kundenwahl stand hinter dem + Namensvorschlag, und ohne Kunden können die Namensregeln gar nicht greifen. +- **Parallele Arbeit an einem Verzeichnis geht schief.** Eine Wegwerf-Probe für den PDF-Bau hat die + `README.md` der Anleitung überschrieben, während sie geschrieben wurde. Wiederhergestellt, aber + der Ablageort für Proben gehört in das Verzeichnis für Zwischenstände, nicht neben die Arbeit. + +### Nächster Schritt + +- #019 Betriebswege und Release `v0.1.0` — `deploy/install.sh`, der Proxmox-One-Liner, die + Betriebsdokumentation und der Tag mit Release-Notes. diff --git a/docs/issues/018-e2e-anleitung-pdf.md b/docs/issues/018-e2e-anleitung-pdf.md index 67c88e3..2f1f1e8 100644 --- a/docs/issues/018-e2e-anleitung-pdf.md +++ b/docs/issues/018-e2e-anleitung-pdf.md @@ -1,9 +1,9 @@ # #018 M13 – E2E-Tests, Anleitung, PDF -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, test, docs -**Geschätzt:** 14 h **Tatsächlich:** – +**Geschätzt:** 14 h **Tatsächlich:** 2 h 15 min ## Ziel @@ -11,27 +11,75 @@ Nachweis, dass die Abläufe wirklich durchlaufen, und eine Anleitung, mit der ei ## Aufgaben -- [ ] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift -- [ ] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog -- [ ] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis -- [ ] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen) -- [ ] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth` -- [ ] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen -- [ ] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt -- [ ] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden) -- [ ] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017) -- [ ] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017) -- [ ] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/` -- [ ] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf` -- [ ] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung -- [ ] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank +- [x] Playwright-Hauptablauf: anmelden → „PC Neuinstallation“ für `PC-001` starten → Schritte abhaken → Wert eintragen → einen Schritt „nicht zutreffend“ mit Notiz → abschließen → archivieren → im Archiv finden → Schreibschutz greift +- [x] Derselbe Hauptablauf mit `SEED_MODE=SERVICE` gegen `MUS-PC-001`, mit Kundenwahl im Start-Dialog +- [x] Playwright-QR-Ablauf: `/t/LZ-7K3F9Q` ohne Anmeldung → Login → Geräteseite → „Vorlage zuweisen“ → Lauf erscheint unter „Zugewiesen“ → „Starten“ setzt `IN_ARBEIT`; dazu unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis +- [x] Playwright-Onboarding-Ablauf: Vorlage starten → Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen → Abteilung „Buchhaltung“ wählen → im Schritt „Konto anlegen“ steht der fertige `New-ADUser`-Befehl → Kopieren liefert exakt diesen Text (Clipboard auslesen) +- [x] Playwright-Mobilprüfung: auf allen Hauptseiten bei 390 × 844 ist `scrollWidth <= clientWidth` +- [x] Playwright-Rollenprüfung: `LESER` sieht keine Schreib-Knöpfe und wird serverseitig abgewiesen +- [x] Die Abläufe suchen ihre Vorlage **am Namen**, nicht über `select ... { index: 1 }`. Aufgefallen in #017: gegen eine frisch geseedete Datenbank steht an erster Stelle „Mitarbeiter-Offboarding“ mit Pflichtvariablen, die der Helfer nicht füllt — sechs Abläufe liefen je 30 s in den Zeitablauf. Ein Ablauf, der von der Sortierung der Vorlagenliste abhängt, prüft nicht, was er zu prüfen vorgibt +- [x] Die Voraussetzungen eines E2E-Laufs stehen an einer Stelle und werden geprüft, statt im Kopfkommentar zu stehen (`pnpm beispiel:vorlage`, `pnpm beispiel:stammdaten`, `pnpm beispiel:lauf`) — besser noch: die Abläufe legen an, was sie brauchen. Die drei Skripte tragen auch die Screenshots; was davon bleibt und was in die Abläufe wandert, wird hier entschieden. In den Seed gehört es **nicht**: eine frische Installation soll keine Demo-Läufe und keine offenen Entwürfe mitbringen (in #017 geprüft und so entschieden) +- [x] Kein Ablauf setzt voraus, dass die Datenbank schon Läufe enthält. `uebersicht-druck.spec.ts` sucht sich mit `lauf.findFirstOrThrow()` irgendeinen Lauf und „Zuletzt bearbeitet" muss gefüllt sein — gegen eine frisch eingerichtete Installation scheitert beides mit „No record was found", und das sagt nichts über die Ursache (gesehen in #017) +- [x] `pnpm beispiel:stammdaten` und `pnpm beispiel:lauf` laufen nur im Modus `INTERN`: sie rufen die Dienste ohne Kunden und scheitern im Modus `DIENSTLEISTUNG` an `MandantFehler: Im Modus Dienstleistung muss ein Kunde gewählt werden`. Das ist richtiges Verhalten der App und eine Lücke der Skripte — wer den Hauptablauf in beiden Modi fahren will, braucht sie in beiden +- [x] Der E2E-Lauf prüft beim Start, dass er **dieselbe** Datenbank benutzt wie der Server unter Test. Die Abläufe legen ihre Daten über `prisma` aus `.env` an, die Adresse des Servers steht in `E2E_APP_URL` — zeigen beide auf verschiedene Datenbanken, schreibt der Test in die eine und sucht in der anderen. Die Meldung heißt dann „0 Treffer“ und sagt nichts über die Ursache (gesehen in #017) +- [x] `pnpm docs:screenshots`: Playwright-Skript, Desktop 1440 × 900 und Handy 390 × 844, Seed-Daten, dunkles Design, mit Markierungen (Rahmen, Nummern) für die beschriebenen Stellen; Ablage `docs/anleitung/bilder/` +- [x] `pnpm docs:pdf`: Anleitung als PDF über Chromium (`page.pdf`, A4, `printBackground`), Ablage `docs/anleitung/laufzettel-anleitung.pdf` +- [x] `docs/anleitung/` auf Deutsch, getrennt für Techniker und Admins: Erste Schritte · Vorlagen bauen · Läufe abarbeiten (Desktop und Handy) · QR-Tags drucken und registrieren · Mitarbeiter und Abteilungen · Betriebsmodus · Admin: Verzeichnis, Benutzer, Einstellungen · Fehlerbehebung +- [x] `README.md` vollständig: Setup, alle Umgebungsvariablen, Betrieb mit Docker Compose, authentik-Anbindung inkl. Gruppen für Rollen, Seed, Sicherungshinweis für die Datenbank ## Akzeptanzkriterien -- [ ] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG` -- [ ] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft -- [ ] Keine Hauptseite scrollt bei 390 px horizontal -- [ ] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu -- [ ] `pnpm pruefe:doku` meldet kein fehlendes Bild -- [ ] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten -- [ ] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt +- [x] Alle Playwright-Abläufe grün, in `INTERN` und in `DIENSTLEISTUNG` +- [x] Der Kopier-Knopf liefert exakt den erwarteten Befehlstext, zeichengenau geprüft +- [x] Keine Hauptseite scrollt bei 390 px horizontal +- [x] `pnpm docs:screenshots` erzeugt alle in der Anleitung verlinkten Bilder reproduzierbar neu +- [x] `pnpm pruefe:doku` meldet kein fehlendes Bild +- [x] Das PDF enthält alle Kapitel mit Bildern, Umlaute korrekt, keine abgeschnittenen Seiten +- [x] Die README beschreibt authentik so, dass ein Dritter die Anbindung ohne Rückfrage hinbekommt + +## Ergebnis + +Erledigt am 2026-09-26. **101 Ende-zu-Ende-Abläufe** im Modus `INTERN` und **97** im Modus +`DIENSTLEISTUNG`, beide ohne Fehlschlag; Vitest unverändert bei 1130. Die Anleitung steht unter +`docs/anleitung/` mit 35 Bildern, das PDF hat 72 Seiten. + +### Was die Abläufe jetzt anders machen + +- **Die Voraussetzungen werden geprüft, nicht behauptet.** `tests/e2e/global-setup.ts` fragt vor dem + ersten Ablauf: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server + **dieselbe** Datenbank wie der Testprozess? Die dritte Frage hat in #017 einen halben Abend + gekostet, weil ihre Antwort „0 Treffer" hieß. +- **Jeder Ablauf legt an, was er braucht, und räumt es weg.** Keine Vorlage über `index: 1`, keine + Annahme „die Datenbank enthält schon Läufe", kein `test.skip` bei fehlenden Daten. +- **Zehn Kopien von `anmelden()`** sind zu einer geworden. + +### Drei Fehler, die dabei herauskamen + +1. **Das Projekt „handy" prüfte 412 px statt 390.** `devices["Pixel 7"]` ist 412 breit; CLAUDE.md und + das Abnahmekriterium nennen 390. Jeder „trägt am Handy ohne horizontales Scrollen"-Test lief + gegen eine Breite, die niemand verlangt hatte. Nach der Korrektur blieb alles grün — die Zusage + hielt, sie war nur nie geprüft. +2. **Eine Auskunft, die niemand je gesehen hat.** Ein angemeldeter Benutzer ohne Recht wurde auf + `/anmelden?fehler=keinrecht` geschickt, und die Anmeldeseite leitet einen Angemeldeten sofort + weiter. Die Meldung war unerreichbar, der Kommentar im Code versprach sie. Jetzt gibt es + `/kein-zugriff` mit der eigenen Rolle darin. +3. **Zwei Tests, die Schaden anrichteten:** der Verzeichnis-Ablauf schrieb die Attributzuordnung der + **ersten vorhandenen** Quelle um — in einer echten Installation die produktive; und ein Nachlauf + setzte die Namensregeln auf leer, statt den vorherigen Wert zurückzustellen. + +### Der zweite Betriebsmodus + +Der erste Durchgang im Modus `DIENSTLEISTUNG` hatte **28 rote Abläufe**, sechs Ursachen, alle +dieselbe blinde Stelle: die Kundenebene. Dort hat jedes Anlege-Formular ein Pflichtfeld mehr, und +dahinter steht eine Regel, die nicht verhandelbar ist — ein Lauf kann **strukturell** kein Gerät und +keine Abteilung eines fremden Kunden wählen, dafür sorgen zusammengesetzte Fremdschlüssel. „Irgendein +Kunde" ist dort nicht ungenau, sondern rot. Beim Onboarding war es sogar eine Reihenfolge: die +Kundenwahl stand hinter dem Namensvorschlag, und ohne Kunden greifen die Namensregeln nicht. + +Ohne dieses Abnahmekriterium wäre der zweite Betriebsmodus ungeprüft ins Release gegangen. + +### Beim Schreiben der Anleitung aufgefallen + +Zwei Stellen, an denen die App etwas kann, das niemand bedienen kann — beide als Issue festgehalten, +nicht weggeschwiegen: [#022](022-schnellstart-pflegen.md) (Schnellstart je Gerätetyp setzt nur der +Seed) und [#023](023-protokoll-ansicht.md) (das Protokoll wird geschrieben, aber nie angezeigt). diff --git a/docs/issues/019-betrieb-release.md b/docs/issues/019-betrieb-release.md index f127a88..aa1c298 100644 --- a/docs/issues/019-betrieb-release.md +++ b/docs/issues/019-betrieb-release.md @@ -26,6 +26,8 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release. - [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan - [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt +- [ ] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern + ## Akzeptanzkriterien - [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit` diff --git a/docs/issues/022-schnellstart-pflegen.md b/docs/issues/022-schnellstart-pflegen.md new file mode 100644 index 0000000..6a97fdf --- /dev/null +++ b/docs/issues/022-schnellstart-pflegen.md @@ -0,0 +1,34 @@ +# #022 Schnellstart je Gerätetyp pflegen + +**Status:** offen +**Im Tracker:** [#34](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/34) +**Meilenstein:** v0.2.0 +**Labels:** phase-2, frontend, backend +**Geschätzt:** 3 h **Tatsächlich:** – + +## Ziel + +Welche Vorlagen auf der Geräteseite als **Schnellstart** erscheinen, steht in `GeraetetypVorlage` — +und lässt sich nur über den Seed setzen. In der Oberfläche gibt es dafür nichts. Wer eine eigene +Vorlage als Schnellstart für Notebooks haben will, kommt nicht heran; wer eine der sieben +Seed-Vorlagen dort loswerden will, auch nicht. + +Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md): Kapitel 03 beschreibt +den Schnellstart, konnte aber nicht sagen, wer ihn einrichtet. + +## Aufgaben + +- [ ] Admin-Seite (oder ein Abschnitt unter Admin → Einstellungen): je Gerätetyp eine sortierbare + Liste der Vorlagen, die dort erscheinen sollen +- [ ] Nur **veröffentlichte**, nicht archivierte Vorlagen zur Auswahl; die Reihenfolge ist die + Reihenfolge der Kacheln (`position`) +- [ ] Recht `verwaltung`, Änderung ins `Protokoll` +- [ ] Der Seed legt die Vorgaben weiter an, überschreibt aber nie eine im Betrieb geänderte + Zuordnung — dieselbe Regel wie für alles andere im Seed +- [ ] Anleitung Kapitel 03 und 07 nachziehen + +## Akzeptanzkriterien + +- [ ] Test: eine zugewiesene Vorlage erscheint als Kachel auf der Geräteseite des passenden Typs +- [ ] Test: eine archivierte Vorlage verschwindet aus dem Schnellstart, ohne dass jemand aufräumt +- [ ] Test: `LESER` und `TECHNIKER` dürfen nicht ändern diff --git a/docs/issues/023-protokoll-ansicht.md b/docs/issues/023-protokoll-ansicht.md new file mode 100644 index 0000000..6e292ae --- /dev/null +++ b/docs/issues/023-protokoll-ansicht.md @@ -0,0 +1,35 @@ +# #023 Protokoll ansehen + +**Status:** offen +**Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35) +**Meilenstein:** v0.2.0 +**Labels:** phase-2, frontend + +## Ziel + +Laufzettel schreibt ein Protokoll über alles, was nachvollziehbar bleiben muss — Rollenänderungen, +veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, den Moduswechsel. +**Ansehen kann es niemand.** Es gibt keine Oberfläche dafür; wer wissen will, wer wann eine Rolle +geändert hat, braucht `psql`. + +Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md). + +**Geschätzt:** 4 h **Tatsächlich:** – + +## Aufgaben + +- [ ] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar +- [ ] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion +- [ ] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar +- [ ] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft + über Menschen +- [ ] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als + Lücke +- [ ] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt + +## Akzeptanzkriterien + +- [ ] Test: ein Eintrag eines fremden Kunden erscheint nicht +- [ ] Test: `TECHNIKER` kommt nicht auf die Seite +- [ ] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf +- [ ] Am Handy kein horizontales Scrollen diff --git a/docs/issues/README.md b/docs/issues/README.md index d0f44a9..27e6d1d 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -22,8 +22,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min | | #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min | -| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | +| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | | #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – | -| | **Summe** | | | **141 h** | **27 h 40 min** | +| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – | +| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – | +| | **Summe** | | | **148 h** | **29 h 55 min** | diff --git a/playwright.config.ts b/playwright.config.ts index c80a4b8..ca594dd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,6 +4,13 @@ const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000"; export default defineConfig({ testDir: "./tests/e2e", + /* + * Prueft vor dem ersten Ablauf, dass die Voraussetzungen stimmen: ein gebauter + * Stand antwortet, der Seed-Bestand steht, und der Server liest **dieselbe** + * Datenbank wie dieser Prozess. Ohne das faellt jede fehlende Voraussetzung + * erst mitten im Lauf auf — als Zeitablauf oder als „0 Treffer“ (#018). + */ + globalSetup: "./tests/e2e/global-setup.ts", // Die Ablaeufe aendern gemeinsame Daten, deshalb nacheinander. fullyParallel: false, workers: 1, @@ -20,6 +27,16 @@ export default defineConfig({ }, projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } }, - { name: "handy", use: { ...devices["Pixel 7"] } }, + { + /* + * **390 px, nicht 412.** `devices["Pixel 7"]` ist 412 × 839; CLAUDE.md und + * das Abnahmekriterium nennen 390. Zweiundzwanzig Pixel sind kein + * Rundungsfehler — eine Seite, die bei 390 ausbricht, sieht bei 412 + * tadellos aus. Jeder „trägt am Handy ohne horizontales Scrollen“-Test lief + * damit gegen eine Breite, die niemand verlangt hatte (#018). + */ + name: "handy", + use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } }, + }, ], }); diff --git a/scripts/anleitung-pdf.ts b/scripts/anleitung-pdf.ts new file mode 100644 index 0000000..87bbc07 --- /dev/null +++ b/scripts/anleitung-pdf.ts @@ -0,0 +1,571 @@ +#!/usr/bin/env tsx +/** + * Baut die Kapitel unter docs/anleitung/ zu einem PDF zusammen: + * docs/anleitung/laufzettel-anleitung.pdf. + * + * pnpm docs:pdf + * + * Gerendert wird mit denselben Bausteinen, die auch die Hilfetexte in der App + * benutzen – `react-markdown` und `remark-gfm`, hier serverseitig ueber + * `react-dom/server` zu HTML. Gedruckt wird mit dem Chromium aus + * `@playwright/test`, der auch die Screenshots aufnimmt. Keine neue + * Abhaengigkeit, und damit auch keine zweite Markdown-Auslegung, die von der + * Oberflaeche abweichen koennte. + * + * **Hell, nicht dunkel.** Die App ist dunkel, ein Ausdruck ist es nie + * (CLAUDE.md, „Gestaltung“: Druckansichten und Etiketten immer hell). Der + * Akzent ist deshalb nicht die Signal-Lime, sondern der Akzent des hellen + * Modus aus `globals.css` – Lime auf Weiss liest niemand. + * + * **Fehlt ein verlinktes Bild, bricht der Lauf ab.** Ein PDF mit Luecken ist + * schlimmer als keines: es sieht fertig aus. Erst `pnpm docs:screenshots`, + * dann `pnpm docs:pdf`. + */ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { extname, join, resolve } from "node:path"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { chromium } from "@playwright/test"; + +const WURZEL = resolve(import.meta.dirname, ".."); +const QUELLE = join(WURZEL, "docs", "anleitung"); +const ZIEL = join(QUELLE, "laufzettel-anleitung.pdf"); + +const TITEL = "Laufzettel"; +const UNTERTITEL = "Anleitung für Technik und Verwaltung"; + +/** Ein Eintrag des Inhaltsverzeichnisses. */ +type Eintrag = { + readonly ebene: 1 | 2; + readonly text: string; + readonly anker: string; +}; + +/** Ein eingelesenes Kapitel, schon zu HTML gerendert. */ +type Kapitel = { + readonly datei: string; + /** Zweistellige Nummer aus dem Dateinamen, falls vorhanden – `README.md` hat keine. */ + readonly nummer: string | undefined; + readonly anker: string; + readonly html: string; + readonly eintraege: readonly Eintrag[]; +}; + +/** Der kleine Teil von hast, den dieses Skript anfasst. */ +type HastKnoten = { + readonly value?: string; + readonly children?: readonly HastKnoten[]; +}; + +const UMLAUTE: Record = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" }; + +/** + * Sprungmarke aus einer Ueberschrift. Umlaute fallen auf ASCII zusammen, damit + * ein Anker zitierbar bleibt – dieselbe Faltung wie bei Bezeichnern. + */ +function anker(text: string): string { + const gefaltet = text + .toLowerCase() + .replace(/[äöüß]/g, (zeichen) => UMLAUTE[zeichen] ?? zeichen) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return gefaltet === "" ? "abschnitt" : gefaltet; +} + +/** Der reine Text einer Ueberschrift, ohne `*`, `` ` `` und Verweise. */ +function textAus(knoten: HastKnoten | undefined): string { + if (!knoten) return ""; + if (typeof knoten.value === "string") return knoten.value; + return (knoten.children ?? []).map(textAus).join(""); +} + +function maskiere(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** + * Kapitel in Lesereihenfolge: `README.md` zuerst, danach nach Dateinamen. + * + * Die Zahl der Kapitel ist offen – das Skript nimmt, was da liegt. + */ +async function kapitelDateien(): Promise { + const eintraege = await readdir(QUELLE, { withFileTypes: true }).catch(() => { + throw new Error(`Es gibt kein Verzeichnis ${QUELLE}. Erst die Kapitel schreiben.`); + }); + const dateien = eintraege + .filter((eintrag) => eintrag.isFile() && eintrag.name.endsWith(".md")) + .map((eintrag) => eintrag.name) + .sort((a, b) => { + const rang = (name: string) => (name.toLowerCase() === "readme.md" ? 0 : 1); + return rang(a) - rang(b) || a.localeCompare(b, "de"); + }); + if (dateien.length === 0) { + throw new Error( + `In ${QUELLE} liegt kein Kapitel (*.md). Ohne Kapitel gibt es keine Anleitung – ` + + `erst die Kapitel schreiben, dann "pnpm docs:pdf".`, + ); + } + return dateien; +} + +const BILDTYPEN: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".gif": "image/gif", +}; + +/** + * Liest jedes verlinkte Bild ein und liefert es als `data:`-URI. + * + * Eingebettet statt verlinkt, weil das PDF danach fuer sich steht und keine + * Rolle spielt, aus welchem Verzeichnis Chromium die Seite geladen hat. + * Fehlt ein Bild, bricht der Lauf ab und nennt jede fehlende Datei. + */ +async function bilderEinlesen( + kapitel: readonly { readonly datei: string; readonly text: string }[], +): Promise> { + const bilder = new Map(); + const fehlend = new Set(); + + for (const { datei, text } of kapitel) { + for (const treffer of text.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)) { + const quelle = treffer[1]; + if (!quelle || /^(https?|data):/i.test(quelle)) continue; + if (bilder.has(quelle)) continue; + const pfad = resolve(QUELLE, decodeURI(quelle)); + const typ = BILDTYPEN[extname(pfad).toLowerCase()]; + if (!typ) { + fehlend.add(`${datei} → ${quelle} (unbekannter Bildtyp)`); + continue; + } + const bytes = await readFile(pfad).catch(() => undefined); + if (!bytes) { + fehlend.add(`${datei} → ${quelle}`); + continue; + } + bilder.set(quelle, `data:${typ};base64,${bytes.toString("base64")}`); + } + } + + if (fehlend.size > 0) { + throw new Error( + `Diese Bilder fehlen, deshalb entsteht kein PDF:\n` + + [...fehlend].map((zeile) => ` - ${zeile}`).join("\n") + + `\nMit "pnpm docs:screenshots" neu aufnehmen (gebauter Stand, siehe CLAUDE.md).`, + ); + } + return bilder; +} + +/** + * Die Bausteine fuer ein Kapitel: Ueberschriften bekommen Sprungmarken und + * landen im Inhaltsverzeichnis, Bilder werden eingebettet, Verweise auf andere + * Kapitel werden zu Sprungmarken im selben Dokument. + * + * `eintraege` wird beim Rendern gefuellt. Das ist Absicht: so kann das + * Inhaltsverzeichnis nicht von den wirklich gesetzten Ankern abweichen – ein + * zweiter Durchlauf durch das Markdown waere eine zweite Auslegung. + */ +function bausteine( + kapitelAnker: string, + eintraege: Eintrag[], + bilder: ReadonlyMap, + ankerJeDatei: ReadonlyMap, +): Components { + return { + h1: ({ node, children }) => { + eintraege.push({ ebene: 1, text: textAus(node), anker: kapitelAnker }); + return createElement("h1", null, children); + }, + h2: ({ node, children }) => { + const marke = `${kapitelAnker}--${anker(textAus(node))}`; + eintraege.push({ ebene: 2, text: textAus(node), anker: marke }); + return createElement("h2", { id: marke }, children); + }, + /* + * Kein `figure`: `react-markdown` setzt ein Bild in einen Absatz, und ein + * `figure` in einem `p` beendet den Absatz – der Browser raeumt das auf, + * aber mit leeren Absaetzen im Druckbild. Ein `span` mit `display: block` + * tut dasselbe, ohne die Absatzgrenze zu brechen. + */ + img: ({ src, alt }) => { + const quelle = typeof src === "string" ? src : ""; + const beschriftung = typeof alt === "string" ? alt : ""; + return createElement( + "span", + { className: "bild" }, + createElement("img", { src: bilder.get(quelle) ?? quelle, alt: beschriftung }), + beschriftung === "" ? null : createElement("span", { className: "bildtext" }, beschriftung), + ); + }, + a: ({ href, children }) => { + const ziel = typeof href === "string" ? href : ""; + const pfad = ziel.split("#")[0] ?? ""; + const kapitel = ankerJeDatei.get(pfad.replace(/^\.\//, "")); + /* + * Ein Verweis auf ein Nachbarkapitel fuehrt im PDF an dessen Anfang – auch + * wenn er einen Abschnitt nennt. Die Anker im Markdown folgen der Zaehlung + * von Gitea, die Anker hier der ASCII-Faltung des Projekts; aus + * „#ueberblick“ und „#überblick“ wuerde sonst mal ein Treffer und mal ein + * toter Sprung. Der Kapitelanfang trifft immer. + */ + return createElement("a", { href: kapitel ? `#${kapitel}` : ziel }, children); + }, + }; +} + +/** + * Die drei Schriften des Projekts, eingebettet als `data:`-URI. + * + * Nur die Latin-Schnitte: Umlaute, `ß`, Anfuehrungszeichen und `·` liegen alle + * darin, und ein zweiter Schnitt je Familie braeuchte `unicode-range`, um + * ueberhaupt etwas zu bewirken. Fehlt eine Datei, druckt Chromium mit der + * Systemschrift – das PDF entsteht trotzdem, nur sieht es fremd aus. + */ +async function schriftregeln(): Promise { + const familien = [ + { familie: "Space Grotesk Variable", paket: "space-grotesk" }, + { familie: "Manrope Variable", paket: "manrope" }, + { familie: "JetBrains Mono Variable", paket: "jetbrains-mono" }, + ]; + const regeln: string[] = []; + for (const { familie, paket } of familien) { + const pfad = join( + WURZEL, + "node_modules", + "@fontsource-variable", + paket, + "files", + `${paket}-latin-wght-normal.woff2`, + ); + const bytes = await readFile(pfad).catch(() => undefined); + if (!bytes) { + console.warn(` Hinweis: ${paket} nicht gefunden, es druckt die Systemschrift.`); + continue; + } + regeln.push( + `@font-face{font-family:"${familie}";font-style:normal;font-weight:200 800;` + + `font-display:block;src:url(data:font/woff2;base64,${bytes.toString("base64")}) format("woff2")}`, + ); + } + return regeln.join("\n"); +} + +/** Das Stilblatt des Ausdrucks: hell, eine Linie, ein Akzent. */ +const STIL = ` +:root { + /* + * Die Token des hellen Modus aus globals.css. Die Signal-Lime ist auf Weiss + * nicht lesbar; im hellen Modus tritt an ihre Stelle #5e8a00, und ein + * Ausdruck ist immer hell. + */ + --grund: #ffffff; + --flaeche: #f6f7f4; + --linie: #d9ddd4; + --text: #0b0d10; + --nebentext: #5b6472; + --akzent: #5e8a00; +} +* { box-sizing: border-box; } +html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } +body { + margin: 0; + background: var(--grund); + color: var(--text); + font-family: "Manrope Variable", ui-sans-serif, system-ui, sans-serif; + font-size: 10.5pt; + line-height: 1.55; + hyphens: auto; +} +h1, h2, h3, h4 { + font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif; + line-height: 1.2; + break-after: avoid; +} +h1 { font-size: 21pt; margin: 0 0 4mm; } +h2 { font-size: 14pt; margin: 8mm 0 2mm; padding-top: 2mm; border-top: 1px solid var(--linie); } +h3 { font-size: 11.5pt; margin: 5mm 0 1mm; } +h4 { font-size: 10.5pt; margin: 4mm 0 1mm; color: var(--nebentext); } +p, ul, ol, blockquote, table { margin: 0 0 2.5mm; } +ul, ol { padding-left: 6mm; } +li { margin: 0 0 1mm; } +li > p { margin: 0; } +a { color: var(--akzent); text-decoration: none; } +strong { font-weight: 700; } +code { + font-family: "JetBrains Mono Variable", ui-monospace, monospace; + font-size: 0.88em; + background: var(--flaeche); + border: 1px solid var(--linie); + border-radius: 3px; + padding: 0 0.3em; +} +pre { + font-family: "JetBrains Mono Variable", ui-monospace, monospace; + font-size: 8.5pt; + line-height: 1.45; + background: var(--flaeche); + border: 1px solid var(--linie); + border-radius: 3mm; + padding: 3mm; + margin: 0 0 3mm; + white-space: pre-wrap; + word-break: break-word; + break-inside: avoid; +} +pre code { background: none; border: 0; padding: 0; font-size: inherit; } +blockquote { + border-left: 2px solid var(--akzent); + padding: 0 0 0 3mm; + color: var(--nebentext); +} +table { border-collapse: collapse; width: 100%; font-size: 9.5pt; break-inside: avoid; } +th, td { border: 1px solid var(--linie); padding: 1.5mm 2mm; text-align: left; vertical-align: top; } +th { background: var(--flaeche); font-weight: 600; } +hr { border: 0; border-top: 1px solid var(--linie); margin: 5mm 0; } + +/* Bilder: eine feine Linie, und nie ueber den Rand – auch nicht in der Hoehe. */ +.bild { display: block; margin: 3mm 0 4mm; break-inside: avoid; text-align: center; } +.bild img { + display: block; + margin: 0 auto; + max-width: 100%; + max-height: 200mm; + width: auto; + height: auto; + border: 1px solid var(--linie); + border-radius: 2mm; +} +.bildtext { + display: block; + margin-top: 1.5mm; + font-size: 8.5pt; + color: var(--nebentext); +} + +/* Deckblatt */ +.deckblatt { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 228mm; + break-after: page; +} +.deckblatt .marke { + font-family: "JetBrains Mono Variable", ui-monospace, monospace; + font-size: 9pt; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--nebentext); +} +.deckblatt .titel { + font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif; + font-size: 52pt; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; +} +.deckblatt .strich { width: 40mm; height: 3px; background: var(--akzent); margin: 4mm 0 5mm; } +.deckblatt .untertitel { font-size: 15pt; color: var(--nebentext); margin: 0; } +.deckblatt .angaben { + border-top: 1px solid var(--linie); + padding-top: 3mm; + font-size: 9.5pt; + color: var(--nebentext); + display: flex; + gap: 8mm; +} +.deckblatt .angaben span strong { color: var(--text); font-weight: 600; } + +/* Inhaltsverzeichnis */ +.inhalt { break-after: page; } +.inhalt ol { list-style: none; padding: 0; margin: 0; } +.inhalt a { color: var(--text); display: flex; gap: 3mm; } +.inhalt .ebene1 { margin-top: 3mm; font-weight: 600; } +.inhalt .ebene1 .nummer { + font-family: "JetBrains Mono Variable", ui-monospace, monospace; + color: var(--akzent); + min-width: 8mm; +} +.inhalt .ebene2 { padding-left: 11mm; color: var(--nebentext); font-size: 9.5pt; } +.inhalt .ebene2 a { color: var(--nebentext); } + +/* Kapitel beginnen auf einer neuen Seite. */ +.kapitel { break-before: page; } +.kapitel .kennung { + font-family: "JetBrains Mono Variable", ui-monospace, monospace; + font-size: 9pt; + letter-spacing: 0.16em; + color: var(--akzent); + display: block; + margin-bottom: 2mm; +} +`; + +/** + * Die Fusszeile kommt aus einem eigenen Dokument, in das die eingebetteten + * Schriften nicht reichen – deshalb eine Systemschrift. + */ +const FUSSZEILE = ` +
+ ${TITEL} · ${UNTERTITEL} + / +
`; + +function deckblattHtml(version: string, datum: string): string { + return `
+
+

Handbuch

+
+
+

${maskiere(TITEL)}

+
+

${maskiere(UNTERTITEL)}

+
+
+ Version ${maskiere(version)} + Stand ${maskiere(datum)} +
+
`; +} + +function inhaltHtml(kapitel: readonly Kapitel[]): string { + const zeilen = kapitel.flatMap((k) => + k.eintraege.map((eintrag) => { + const nummer = eintrag.ebene === 1 ? (k.nummer ?? "·") : ""; + return ( + `
  • ` + + `${maskiere(nummer)}` + + `${maskiere(eintrag.text)}
  • ` + ); + }), + ); + return `
    +

    Inhalt

    +
      +${zeilen.map((zeile) => ` ${zeile}`).join("\n")} +
    +
    `; +} + +function seiteHtml(schriften: string, koerper: string): string { + return ` + + + +${maskiere(TITEL)} – ${maskiere(UNTERTITEL)} + + + +${koerper} + +`; +} + +/** + * Seitenzahl aus dem erzeugten PDF, so gut es ohne Fremdpaket geht: Chromium + * schreibt den Seitenbaum unkomprimiert. Findet die Regel nichts, bleibt die + * Angabe weg, statt eine falsche Zahl zu melden. + */ +function seitenzahl(pdf: Uint8Array): number | undefined { + const text = Buffer.from(pdf).toString("latin1"); + const zahlen = [...text.matchAll(/\/Type\s*\/Pages\b[^>]*?\/Count\s+(\d+)/g)].map((t) => Number(t[1])); + return zahlen.length > 0 ? Math.max(...zahlen) : undefined; +} + +// ---- Ablauf --------------------------------------------------------------- + +const dateien = await kapitelDateien(); +const rohkapitel = await Promise.all( + dateien.map(async (datei) => ({ datei, text: await readFile(join(QUELLE, datei), "utf8") })), +); +const bilder = await bilderEinlesen(rohkapitel); + +const ankerJeDatei = new Map( + rohkapitel.map(({ datei }) => [datei, `kapitel-${anker(datei.replace(/\.md$/, ""))}`]), +); + +const kapitel: Kapitel[] = rohkapitel.map(({ datei, text }) => { + const kapitelAnker = ankerJeDatei.get(datei) ?? `kapitel-${anker(datei)}`; + const eintraege: Eintrag[] = []; + const html = renderToStaticMarkup( + createElement( + ReactMarkdown, + { + remarkPlugins: [remarkGfm], + components: bausteine(kapitelAnker, eintraege, bilder, ankerJeDatei), + }, + text, + ), + ); + return { + datei, + nummer: /^(\d{2})-/.exec(datei)?.[1], + anker: kapitelAnker, + html, + eintraege, + }; +}); + +const { version } = JSON.parse(await readFile(join(WURZEL, "package.json"), "utf8")) as { + version: string; +}; +const datum = new Intl.DateTimeFormat("de-DE", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "Europe/Berlin", +}).format(new Date()); + +const koerper = [ + deckblattHtml(version, datum), + inhaltHtml(kapitel), + ...kapitel.map( + (k) => + `
    ` + + (k.nummer ? `Kapitel ${maskiere(k.nummer)}` : "") + + `${k.html}
    `, + ), +].join("\n"); + +const html = seiteHtml(await schriftregeln(), koerper); + +const browser = await chromium.launch(); +try { + const seite = await browser.newPage(); + // Hell, und zwar ausdruecklich: die App richtet sich sonst nach dem Schema + // des Systems und brachte ihre dunklen Flaechen mit in den Druck. + await seite.emulateMedia({ colorScheme: "light", media: "print" }); + await seite.setContent(html, { waitUntil: "load" }); + await seite.evaluate(() => document.fonts.ready); + + const pdf = await seite.pdf({ + format: "A4", + printBackground: true, + margin: { top: "18mm", right: "18mm", bottom: "20mm", left: "18mm" }, + displayHeaderFooter: true, + headerTemplate: "", + footerTemplate: FUSSZEILE, + }); + await writeFile(ZIEL, pdf); + + const seiten = seitenzahl(pdf); + console.log(` ${ZIEL.replace(`${WURZEL}/`, "")}`); + console.log( + ` ${kapitel.length} Kapitel, ${bilder.size} Bilder, ` + + `${(pdf.byteLength / 1024 / 1024).toFixed(1)} MB` + + (seiten === undefined ? "" : `, ${seiten} Seiten`), + ); +} finally { + await browser.close(); +} +console.log("Anleitung als PDF erzeugt."); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 70f48bd..c8cb6aa 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -54,6 +54,20 @@ type Seite = { * wie nebenan, nur groesser. */ readonly ausschnitt?: (tab: Page) => Locator; + /** + * Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit + * einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018). + * Ohne Angabe entsteht genau dasselbe Bild wie vorher. + */ + readonly markierungen?: readonly Markierung[]; +}; + +/** + * Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text + * der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“). + */ +type Markierung = { + readonly ziel: (tab: Page) => Locator; }; /** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */ @@ -137,7 +151,16 @@ const SEITEN: readonly Seite[] = [ }, { name: "geraete-liste", pfad: "/geraete" }, { name: "laeufe-liste", pfad: "/laeufe" }, - { name: "lauf-starten", pfad: "/laeufe/starten" }, + { + name: "lauf-starten", + pfad: "/laeufe/starten", + // (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf + // starten“ redet; alles darunter ist freiwillig. + markierungen: [ + { ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') }, + { ziel: (tab) => tab.locator('select[name="geraetId"]') }, + ], + }, { name: "lauf-abarbeiten", pfad: "/laeufe", @@ -148,6 +171,24 @@ const SEITEN: readonly Seite[] = [ .click(); await tab.waitForURL(/\/laeufe\/[^/]+$/); }, + // (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare. + markierungen: [ + { ziel: (tab) => tab.getByRole("progressbar") }, + { + ziel: (tab) => + tab + .getByRole("listitem") + .filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) }) + .first() + .getByRole("button", { name: "erledigt", exact: true }) + // Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden. + .locator(".."), + }, + { + ziel: (tab) => + tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }), + }, + ], }, { // Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy @@ -176,6 +217,15 @@ const SEITEN: readonly Seite[] = [ await tab.getByRole("link", { name: "NB-001" }).first().click(); await tab.waitForURL(/\/geraete\/[^/]+$/); }, + // (1) der Weg zum QR-Code, (2) was schon zugewiesen ist, + // (3) der Weg zu einer weiteren Vorlage. + markierungen: [ + // Der Code selbst steht in einem zugeklappten `details` und waere im Bild + // nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt. + { ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) }, + { ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") }, + { ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") }, + ], }, { /* @@ -340,6 +390,64 @@ async function kachelnErzeugen(browser: Browser): Promise { } } +/** + * Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht. + * + * Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect` + * plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort + * sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche + * aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit. + * + * Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit + * fehlender „(2)“ macht den Text der Anleitung falsch. + */ +async function markierungenEinspritzen( + tab: Page, + seitenname: string, + markierungen: readonly Markierung[], +): Promise { + for (const [stelle, markierung] of markierungen.entries()) { + const ziel = markierung.ziel(tab).first(); + if ((await ziel.count()) === 0) { + throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`); + } + await ziel.evaluate((element, nummer) => { + const kasten = element.getBoundingClientRect(); + const links = kasten.left + window.scrollX; + const oben = kasten.top + window.scrollY; + const rahmen = document.createElement("div"); + Object.assign(rahmen.style, { + position: "absolute", + left: `${links - 5}px`, + top: `${oben - 5}px`, + width: `${kasten.width + 10}px`, + height: `${kasten.height + 10}px`, + border: "2px solid #C6F135", + borderRadius: "14px", + pointerEvents: "none", + zIndex: "2147483647", + }); + const marke = document.createElement("div"); + marke.textContent = String(nummer); + Object.assign(marke.style, { + position: "absolute", + // Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab. + left: links < 24 ? "4px" : "-14px", + top: "-14px", + width: "26px", + height: "26px", + borderRadius: "999px", + background: "#C6F135", + color: "#0B0D10", + font: "700 15px/26px ui-sans-serif, system-ui, sans-serif", + textAlign: "center", + }); + rahmen.append(marke); + document.body.append(rahmen); + }, stelle + 1); + } +} + const zielMeilenstein = argument("ziel"); const nur = argument("nur"); /** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */ @@ -376,6 +484,7 @@ try { // Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen. await tab.evaluate(() => document.fonts.ready); await tab.waitForLoadState("domcontentloaded"); + if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen); const datei = join(ziel, `${seite.name}-${ansicht.name}.png`); if (seite.ausschnitt) { diff --git a/src/app/(app)/kein-zugriff/page.tsx b/src/app/(app)/kein-zugriff/page.tsx new file mode 100644 index 0000000..1602af1 --- /dev/null +++ b/src/app/(app)/kein-zugriff/page.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; +import { ShieldAlert } from "lucide-react"; +import { ROLLEN_TEXT } from "@/lib/rollen-text"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +/** + * „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018). + * + * Vorher schickte `sitzungErforderlich` ihn auf `/anmelden?fehler=keinrecht`, und + * die Anmeldeseite leitete einen Angemeldeten sofort auf die Übersicht weiter: + * die Erklärung war unerreichbar, er stand ohne Hinweis da und wusste nicht, ob + * er sich verklickt hatte oder ob etwas kaputt ist. Der Kommentar in + * `sitzungspflicht.ts` versprach „eine Weiterleitung mit Erklärung“ — die + * Erklärung fehlte. + * + * Die Seite sagt deshalb drei Dinge: dass es an der Rolle liegt, welche Rolle er + * hat, und wohin er stattdessen kann. Sie nennt **nicht**, welche Rolle nötig + * wäre — das ist eine Auskunft über die Rechteverteilung, und wer sie braucht, + * fragt den Administrator. + */ +export default async function KeinZugriffSeite({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { benutzer } = await sitzungErforderlich(undefined, "/kein-zugriff"); + const parameter = await searchParams; + const roh = parameter["bereich"]; + const bereich = (Array.isArray(roh) ? roh[0] : roh)?.trim(); + // Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein + // Text, den jemand anderes auf diese Seite schreibt. + const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined; + + return ( +
    +
    + ); +} diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx index ccb77ab..d0094ac 100644 --- a/src/app/anmelden/page.tsx +++ b/src/app/anmelden/page.tsx @@ -22,7 +22,6 @@ const MELDUNGEN = new Map( "oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.", "oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.", "oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.", - keinrecht: "Für diesen Bereich fehlt dir die Berechtigung.", }), ); diff --git a/src/server/auth/sitzungspflicht.ts b/src/server/auth/sitzungspflicht.ts index cbccc75..5d2a6e9 100644 --- a/src/server/auth/sitzungspflicht.ts +++ b/src/server/auth/sitzungspflicht.ts @@ -87,11 +87,18 @@ export async function sitzungErforderlich(recht?: Recht, ziel?: string): Promise redirect(ziel ? `/anmelden?ziel=${encodeURIComponent(ziel)}` : "/anmelden"); } if (recht && !darf(vorhanden.benutzer.rolle, recht)) { - // Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden, - // der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft. - // Deshalb hier eine Weiterleitung mit Erklaerung; in Server Actions bleibt - // das Werfen richtig, siehe rechtErforderlich. - redirect("/anmelden?fehler=keinrecht"); + /* + * Bei einer Seite ist ein geworfener Fehler eine 500er-Seite – fuer jemanden, + * der einfach ein altes Lesezeichen geoeffnet hat, die falsche Auskunft. + * Deshalb eine Weiterleitung mit Erklaerung; in Server Actions bleibt das + * Werfen richtig, siehe `rechtErforderlich`. + * + * Und zwar auf **`/kein-zugriff`**, nicht auf die Anmeldeseite: dort leitet + * ein Angemeldeter sofort auf die Uebersicht weiter, und die Erklaerung war + * damit unerreichbar. Der Kommentar hier versprach sie trotzdem — anderthalb + * Jahre haette niemand gemerkt, dass der Hinweis nie ankommt (#018). + */ + redirect(ziel ? `/kein-zugriff?bereich=${encodeURIComponent(ziel)}` : "/kein-zugriff"); } return vorhanden; } diff --git a/tests/e2e/archiv.spec.ts b/tests/e2e/archiv.spec.ts index a02196f..eff3321 100644 --- a/tests/e2e/archiv.spec.ts +++ b/tests/e2e/archiv.spec.ts @@ -1,6 +1,7 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; +import { anmelden, SEED_VORLAGE } from "./helfer"; /** * Das Archiv am gebauten Stand (#015). @@ -9,20 +10,18 @@ import { prisma } from "@/lib/db"; * Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort * eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein * Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos. + * + * Voraussetzungen prueft `tests/e2e/global-setup.ts`, aufgeraeumt wird am Ende + * ueber die Datenbank: fuer einen Lauf kennt die Anwendung keinen Loeschweg, und + * das ist richtig so (#018). */ const STEMPEL = Date.now(); -const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`; -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} +/** Fortlaufend, damit zwei Laeufe desselben Durchgangs sich nicht verwechseln. */ +let laufende = 0; + +type Testlauf = { readonly id: string; readonly titel: string; readonly suchwort: string }; /** * Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt. @@ -31,19 +30,31 @@ async function anmelden(seite: Page) { * geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts` * vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei * CHECK-Bedingungen in der Datenbank verlangen sie. + * + * Vorlage **am Namen**, Kunde ueber `istEigeneOrganisation`: ein + * `findFirstOrThrow` ohne Bedingung nahm die Version, die die Sortierung gerade + * nach vorn stellte, und im Modus DIENSTLEISTUNG einen beliebigen Demo-Kunden. + * Dass es `SEED_VORLAGE` veroeffentlicht gibt, prueft der Global-Setup. */ -async function archivierterLauf(): Promise<{ id: string; titel: string }> { +async function archivierterLauf(): Promise { + laufende += 1; + const marke = `${STEMPEL}-${laufende}`; + const suchwort = `Restposten ${marke}`; + const version = await prisma.vorlagenVersion.findFirstOrThrow({ - where: { status: "VEROEFFENTLICHT" }, - orderBy: { veroeffentlichtAm: "desc" }, + where: { status: "VEROEFFENTLICHT", vorlage: { name: SEED_VORLAGE } }, + orderBy: { version: "desc" }, select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } }, }); - const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } }); + const kunde = await prisma.kunde.findFirstOrThrow({ + where: { istEigeneOrganisation: true }, + select: { id: true }, + }); const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id; - if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt."); + if (!schrittId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keinen Schritt.`); const jetzt = new Date(); - const titel = `ZZ E2E Archiv ${STEMPEL}`; + const titel = `ZZ E2E Archiv ${marke}`; const lauf = await prisma.lauf.create({ data: { vorlagenVersionId: version.id, @@ -60,10 +71,10 @@ async function archivierterLauf(): Promise<{ id: string; titel: string }> { laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: "NICHT_ZUTREFFEND", - notiz: NOTIZ, + notiz: `${suchwort} aus dem Lager`, }, }); - return { id: lauf.id, titel }; + return { id: lauf.id, titel, suchwort }; } test.describe("Archiv", () => { @@ -77,7 +88,7 @@ test.describe("Archiv", () => { } await page.getByRole("link", { name: "Archiv" }).first().click(); await page.waitForURL("**/archiv"); - await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`); + await page.getByLabel("Suchbegriff").fill(lauf.suchwort); await page.getByRole("button", { name: "Suchen" }).click(); const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel }); @@ -102,8 +113,16 @@ test.describe("Archiv", () => { test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test.skip(!isMobile, "Nur im Projekt „handy“."); + /* + * Der Ablauf bringt seinen Treffer mit, statt auf einen zu hoffen: gegen + * eine frische Datenbank fand „Restposten“ nichts, und gemessen wurde dann + * die Breite einer Seite ohne Treffer (#018). + */ + const lauf = await archivierterLauf(); await anmelden(page); - await page.goto("/archiv?suche=Restposten"); + await page.goto(`/archiv?suche=${encodeURIComponent(lauf.suchwort)}`); + await expect(page.getByRole("listitem").filter({ hasText: lauf.titel })).toHaveCount(1); + const masse = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, @@ -114,6 +133,7 @@ test.describe("Archiv", () => { test.afterAll(async () => { const laeufe = await prisma.lauf.findMany({ + // Der Praefix gehoert dieser Datei allein – geraeumt wird nur, was sie anlegt. where: { titel: { startsWith: "ZZ E2E Archiv" } }, select: { id: true }, }); diff --git a/tests/e2e/etiketten.spec.ts b/tests/e2e/etiketten.spec.ts index 93ec78b..1ef8582 100644 --- a/tests/e2e/etiketten.spec.ts +++ b/tests/e2e/etiketten.spec.ts @@ -1,6 +1,6 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import "dotenv/config"; -import { prisma } from "@/lib/db"; +import { anmelden, SEED_TAG } from "./helfer"; /** * Der Etikettendruck am gebauten Stand (#014). @@ -14,34 +14,21 @@ import { prisma } from "@/lib/db"; * Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 = * 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732 * Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet. + * + * Gedruckt wird `SEED_TAG` und nicht irgendein Code aus der Datenbank: dass es + * ihn gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf. Ein + * `findFirst` haette gegen eine frische Installation „kein Geraet“ gemeldet und + * gegen eine benutzte einen beliebigen Aufkleber gedruckt (#018). */ const DPI = 300; const PX_JE_MM = 96 / 25.4; -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} - -/** Irgendein Tag-Code, den es in dieser Datenbank wirklich gibt. */ -async function einTagCode(): Promise { - const geraet = await prisma.geraet.findFirst({ select: { tagCode: true } }); - if (!geraet) throw new Error("Kein Gerät in der Datenbank — erst pnpm db:seed."); - return geraet.tagCode; -} - test.describe("Etikettendruck", () => { test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => { test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); await anmelden(page); - const code = await einTagCode(); - await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); + await page.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`); /* * `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die @@ -69,7 +56,6 @@ test.describe("Etikettendruck", () => { test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => { test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät."); - const code = await einTagCode(); /* * Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die @@ -84,7 +70,7 @@ test.describe("Etikettendruck", () => { try { const tab = await kontext.newPage(); await anmelden(tab); - await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); + await tab.goto(`/drucken/etiketten?layout=etikett&codes=${SEED_TAG}`); const etikett = tab.locator("[data-etikett]").first(); await expect(etikett).toBeVisible(); const bild = await etikett.screenshot(); @@ -100,7 +86,7 @@ test.describe("Etikettendruck", () => { * dazulegen koennte. */ const adresse = new URL(gelesen[0] ?? ""); - expect(adresse.pathname).toBe(`/t/${code}`); + expect(adresse.pathname).toBe(`/t/${SEED_TAG}`); expect(adresse.search).toBe(""); expect(adresse.hash).toBe(""); } finally { @@ -157,7 +143,3 @@ function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } { hoehe: Number(treffer[4]) - Number(treffer[2]), }; } - -test.afterAll(async () => { - await prisma.$disconnect(); -}); diff --git a/tests/e2e/geraete.spec.ts b/tests/e2e/geraete.spec.ts index 1d790bc..95f867a 100644 --- a/tests/e2e/geraete.spec.ts +++ b/tests/e2e/geraete.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; +import { anmelden, kundeWaehlen } from "./helfer"; /** * Die Geraeteliste durch die Oberflaeche (#011). @@ -11,8 +12,10 @@ import "dotenv/config"; * einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht: * durch Neuladen. * - * Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und - * `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. + * Voraussetzungen — ein gebauter Stand antwortet, der Seed-Bestand steht und der + * Server liest dieselbe Datenbank wie dieser Lauf — prueft + * `tests/e2e/global-setup.ts` vor dem ersten Ablauf (#018). Eigene Daten legt + * dieser Ablauf selbst an; auf vorhandene baut er nicht. * * Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf, * sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das @@ -21,16 +24,6 @@ import "dotenv/config"; const MARKE = `ZZ-E2E-${Date.now()}`; -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} - /** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */ function anlegenFormular(seite: Page) { return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) }); @@ -70,6 +63,7 @@ test.describe("Geräte", () => { await formular.getByLabel("Hostname").fill(name); await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`); await formular.getByLabel("Typ").selectOption("NOTEBOOK"); + await kundeWaehlen(formular); await formular.getByRole("button", { name: "Gerät anlegen" }).click(); // Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber. @@ -109,6 +103,7 @@ test.describe("Geräte", () => { const formular = anlegenFormular(page); await formular.getByLabel("Hostname").fill(name); + await kundeWaehlen(formular); await formular.getByRole("button", { name: "Gerät anlegen" }).click(); await expect(formular.getByRole("status")).toContainText("wurde angelegt"); @@ -150,6 +145,7 @@ test.describe("Geräte", () => { `${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`, ].join("\r\n"), ); + await kundeWaehlen(bereich); await bereich.getByRole("button", { name: "Einlesen" }).click(); await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen."); @@ -175,6 +171,7 @@ test.describe("Geräte", () => { const formular = anlegenFormular(page); await formular.getByLabel("Bezeichnung").fill(name); await formular.getByLabel("Typ").selectOption("RAUM"); + await kundeWaehlen(formular); await formular.getByRole("button", { name: "Gerät anlegen" }).click(); await expect(formular.getByRole("status")).toContainText("wurde angelegt"); diff --git a/tests/e2e/geraeteseite.spec.ts b/tests/e2e/geraeteseite.spec.ts index 468caf3..897d364 100644 --- a/tests/e2e/geraeteseite.spec.ts +++ b/tests/e2e/geraeteseite.spec.ts @@ -1,6 +1,7 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; +import { anmelden, SEED_TAG } from "./helfer"; /** * Der Weg vom Aufkleber zur Arbeit (#014). @@ -9,19 +10,17 @@ import { prisma } from "@/lib/db"; * tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die * Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter * `/t/`, das Registrieren, die Geraeteseite und der Lauf. + * + * Voraussetzungen prueft `tests/e2e/global-setup.ts`. Das Beispielgeraet wird + * ueber `SEED_TAG` gesucht und nicht ueber den Hostnamen „PC-001“: im Modus + * DIENSTLEISTUNG traegt der Name das Kundenkuerzel, und in einer benutzten + * Datenbank gibt es „PC-001“ mehr als einmal (#018). */ const STEMPEL = Date.now(); -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} +/** Praefix der Geraete, die dieser Ablauf anlegt – und nur dieser. */ +const HOSTNAME_PRAEFIX = "ZZ-E2E-TAG-"; /** * Eine schlichte Vorlage **ohne** Pflichtangaben. @@ -59,7 +58,18 @@ async function tagAnlegen(): Promise { where: { rolle: "ADMIN" }, select: { id: true, rolle: true }, }); - const stapel = await tagsErzeugen(1, null, { id: admin.id, rolle: admin.rolle }); + /* + * Der Kunde muss mit: im Modus DIENSTLEISTUNG verlangt `kundeErmitteln` eine + * Wahl und wirft sonst „Im Modus Dienstleistung muss ein Kunde gewählt + * werden“. Genommen wird der Kunde des Beispielgeraets — an ihm haengt auch + * die Geraeteseite, die dieser Ablauf danach oeffnet. Im Modus INTERN wird die + * Kennung verworfen, die Angabe schadet dort also nicht (#008). + */ + const beispiel = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { kundeId: true }, + }); + const stapel = await tagsErzeugen(1, beispiel.kundeId, { id: admin.id, rolle: admin.rolle }); const code = stapel.tagCodes[0]; if (!code) throw new Error("Der Stapel kam leer zurück."); eigeneTags.push(code); @@ -82,13 +92,13 @@ test.describe("Vom Aufkleber zur Arbeit", () => { * muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst * sucht er das Gerät noch einmal, das er gerade in der Hand hält. */ - const geraet = await prisma.geraet.findFirstOrThrow({ - where: { hostname: "PC-001" }, - select: { id: true, tagCode: true }, + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { id: true, hostname: true }, }); await page.context().clearCookies(); - await page.goto(`/t/${geraet.tagCode}`); + await page.goto(`/t/${SEED_TAG}`); await page.waitForURL(/\/anmelden\?ziel=/); const email = process.env["SEED_ADMIN_EMAIL"]; @@ -99,7 +109,8 @@ test.describe("Vom Aufkleber zur Arbeit", () => { await page.click('button[type="submit"]'); await page.waitForURL(`**/geraete/${geraet.id}`); - await expect(page.getByRole("heading", { name: "PC-001" })).toBeVisible(); + // Der Name kommt aus der Datenbank: er heisst nicht in jedem Modus „PC-001“. + await expect(page.getByRole("heading", { name: geraet.hostname ?? SEED_TAG })).toBeVisible(); }); test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => { @@ -111,7 +122,7 @@ test.describe("Vom Aufkleber zur Arbeit", () => { await page.goto(`/t/${code.toLowerCase()}`); await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible(); - const hostname = `ZZ-E2E-${STEMPEL}`; + const hostname = `${HOSTNAME_PRAEFIX}${STEMPEL}`; await page.getByLabel("Hostname").fill(hostname); await page.getByLabel("Typ").selectOption("NOTEBOOK"); await page.getByRole("button", { name: "Gerät registrieren" }).click(); @@ -156,9 +167,15 @@ test.describe("Vom Aufkleber zur Arbeit", () => { test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test.skip(!isMobile, "Nur im Projekt „handy“."); + /* + * Gesucht wird ueber den Tag-Code und nicht ueber „NB-001“: der Name war eine + * Annahme ueber den Beispielbestand, und in einer benutzten Datenbank fuehrt + * er auf mehr als ein Geraet. Der Code ist eindeutig — die Liste zeigt danach + * genau eine Zeile, und der Weg ueber die Liste bleibt der des Technikers. + */ await anmelden(page); - await page.goto("/geraete"); - await page.getByRole("link", { name: "NB-001" }).first().click(); + await page.goto(`/geraete?suche=${SEED_TAG}`); + await page.locator("ul > li").filter({ hasText: SEED_TAG }).getByRole("link").first().click(); await page.waitForURL(/\/geraete\/[^/]+$/); const masse = await page.evaluate(() => ({ @@ -186,7 +203,12 @@ test.afterAll(async () => { * Entwicklungsdatenbank ist das derselbe Bestand. */ const geraete = await prisma.geraet.findMany({ - where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { tagCode: { in: eigeneTags } }] }, + /* + * Der Praefix gehoert dieser Datei allein. Vorher stand hier `ZZ-E2E-`, und + * damit raeumte dieser Nachlauf auch die Geraete aus + * `tests/e2e/geraete.spec.ts` weg — fremde Daten, wenn auch Testdaten (#018). + */ + where: { OR: [{ hostname: { startsWith: HOSTNAME_PRAEFIX } }, { tagCode: { in: eigeneTags } }] }, select: { id: true }, }); const kennungen = geraete.map((geraet) => geraet.id); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..1c7eb39 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,103 @@ +import { chromium } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer"; + +/** + * Was vor dem ersten Ablauf stimmen muss (#018). + * + * Bisher standen die Voraussetzungen als Satz im Kopfkommentar jeder Datei + * („Voraussetzung: ein gebauter Stand laeuft und `pnpm beispiel:vorlage` hat + * …“). Ein Kommentar prueft nichts. Stimmte etwas davon nicht, fiel das erst + * mitten im Lauf auf, und die Meldung sprach von einem Zeitablauf oder von null + * Treffern — nie von der Ursache. + * + * Hier wird es geprueft, einmal, bevor irgendein Ablauf startet. Jede Meldung + * sagt, **was** fehlt und **was zu tun ist**. + */ + +const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000"; + +function fehlen(was: string, abhilfe: string): never { + throw new Error(`Ende-zu-Ende-Tests: ${was}\n → ${abhilfe}`); +} + +export default async function vorbereiten(): Promise { + if (!process.env["SEED_ADMIN_EMAIL"] || !process.env["SEED_ADMIN_PASSWORD"]) { + fehlen( + "SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt.", + "Beide stehen in der .env und müssen zu dem Administrator passen, den der Seed angelegt hat.", + ); + } + + // 1. Laeuft ueberhaupt etwas an dieser Adresse? + const antwort = await fetch(`${APP_URL}/api/gesundheit`, { signal: AbortSignal.timeout(5000) }).catch( + () => undefined, + ); + if (!antwort?.ok) { + fehlen( + `Unter ${APP_URL} antwortet keine Anwendung.`, + "Erst bauen und starten: pnpm build && PORT=3300 APP_URL=http://127.0.0.1:3300 pnpm start — " + + "und die Adresse über E2E_APP_URL mitgeben. Gegen `pnpm dev` laufen die Abläufe nicht " + + "(siehe CLAUDE.md, „Oberfläche prüfen“).", + ); + } + + // 2. Steht der Seed-Bestand, mit dem die Ablaeufe arbeiten? + const vorlage = await prisma.vorlage.findFirst({ + where: { name: SEED_VORLAGE }, + select: { id: true, versionen: { where: { status: "VEROEFFENTLICHT" }, select: { id: true } } }, + }); + if (!vorlage || vorlage.versionen.length === 0) { + fehlen( + `Die Vorlage „${SEED_VORLAGE}“ fehlt oder ist nicht veröffentlicht.`, + "pnpm db:seed laufen lassen.", + ); + } + const geraet = await prisma.geraet.findUnique({ where: { tagCode: SEED_TAG }, select: { id: true } }); + if (!geraet) { + fehlen(`Das Beispielgerät ${SEED_GERAET} (${SEED_TAG}) fehlt.`, "pnpm db:seed laufen lassen."); + } + + /* + * 2b. In welchem Betriebsmodus laeuft die Installation? + * + * Ein Ablauf soll das nicht selbst aus der Datenbank holen muessen — und zwei + * Quellen fuer dieselbe Auskunft liefen frueher oder spaeter auseinander. Der + * Modus steht danach in `E2E_BETRIEBSMODUS`, und ein Ablauf, der nur in einem + * der beiden Sinn ergibt, ueberspringt sich selbst mit einem Grund. + */ + const einstellung = await prisma.einstellung.findUnique({ + where: { id: "global" }, + select: { modus: true }, + }); + process.env["E2E_BETRIEBSMODUS"] = einstellung?.modus ?? "INTERN"; + + /* + * 3. Liest der Server **dieselbe** Datenbank? + * + * Der Testprozess schreibt über DATABASE_URL aus der .env, der Server bekommt + * seine eigene mit. Zeigen beide woandershin, legt ein Ablauf seine Daten in + * die eine und sucht sie in der anderen. Geprüft wird an einer Vorlage, die + * hier entsteht und gleich wieder verschwindet. + */ + const probe = `ZZ Datenbankprüfung ${Date.now()}`; + const angelegt = await prisma.vorlage.create({ data: { name: probe, kategorie: "ZZ Test" } }); + const browser = await chromium.launch(); + try { + const tab = await browser.newPage({ baseURL: APP_URL }); + await anmelden(tab); + await tab.goto(`/vorlagen?suche=${encodeURIComponent(probe)}`); + if ((await tab.getByRole("link", { name: probe }).count()) === 0) { + fehlen( + "Der Server unter Test liest eine andere Datenbank als dieser Lauf.", + "DATABASE_URL des Testprozesses (aus der .env) und die des Servers müssen auf dieselbe " + + "Datenbank zeigen — sonst schreibt der Ablauf in die eine und sucht in der anderen.", + ); + } + } finally { + await browser.close(); + await prisma.vorlage.delete({ where: { id: angelegt.id } }); + await prisma.$disconnect(); + } +} diff --git a/tests/e2e/hauptablauf.spec.ts b/tests/e2e/hauptablauf.spec.ts new file mode 100644 index 0000000..8d5d2c3 --- /dev/null +++ b/tests/e2e/hauptablauf.spec.ts @@ -0,0 +1,332 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { anmelden, geraetName, SEED_VORLAGE, variablenFuellen, vorlageWaehlen } from "./helfer"; + +/** + * Der Hauptablauf des ganzen Auftrags, in einem Zug (#018). + * + * Anmelden → „PC Neuinstallation“ für das Beispielgerät starten → abarbeiten → + * einen Schritt mit Begründung überspringen → abschließen → archivieren → im + * Archiv über ein Wort aus der Begründung wiederfinden → und danach ändert sich + * nichts mehr. + * + * Zwei Dinge unterscheiden diesen Ablauf von einem Klickprotokoll: + * + * 1. **Geprüft wird gegen die Datenbank.** Nach jeder Änderung wird neu geladen + * und in der Datenbank nachgesehen. Ein Klick, der nur im Browser wirkt, sieht + * auf einem Screenshot genauso aus wie einer, der gespeichert wurde — und + * genau dieser Unterschied ist der Grund, warum es diese Datei gibt + * (CLAUDE.md, „Oberfläche prüfen“). + * 2. **Die Absage wird erzwungen.** „Nicht zutreffend“ ohne Begründung sperrt die + * Oberfläche nur über einen deaktivierten Knopf. Ein deaktivierter Knopf ist + * keine Zusage: dieser Ablauf entfernt ihn und schickt trotzdem ab, damit die + * Absage vom **Server** bewiesen ist und nicht von einem HTML-Attribut. + * + * Der Ablauf legt an, was er braucht (einen eigenen Lauf mit `ZZ E2E `-Titel) und + * räumt ihn hinterher weg. Vorausgesetzt wird nur der Seed-Bestand, den + * `global-setup.ts` prüft. + */ + +const STEMPEL = Date.now(); +const TITEL = `ZZ E2E Hauptablauf ${STEMPEL}`; + +/** Der Schritt, der übersprungen wird — Pflicht, damit auch der Abschluss etwas davon hat. */ +const UEBERSPRUNGEN = "Secure Boot und TPM 2.0 aktiv"; + +/** Der Schritt, in dem ein Wert erfasst wird. Typ TEXT in der Seed-Vorlage. */ +const WERTSCHRITT = "Sondersoftware und Lizenzen des Benutzers notiert"; +const WERT = `ZZ E2E Adobe Acrobat ${STEMPEL}`; + +/** + * Das Wertfeld eines Schritts – ohne Annahme über das Element. + * + * `TEXT` mit `mehrzeilig` ist ein `textarea`, alles andere ein `input`. Ein + * `input[name="wert"]` lief deshalb in den Zeitablauf, und die Meldung sprach von + * einem fehlenden Element statt von der falschen Annahme. + */ +const WERTFELD = '[name="wert"]'; + +/** + * Ein Wort, das es in der ganzen Datenbank nur einmal gibt. + * + * Über dieses Wort wird der Lauf später im Archiv wiedergefunden. Ohne Stempel + * fände die Suche auch die Notizen früherer Durchläufe, und der Treffer sagte + * nichts mehr über diesen hier. + */ +const NOTIZWORT = `Fabrikneu${STEMPEL}`; +const NOTIZ = `${NOTIZWORT}: das Gerät kam versiegelt aus dem Karton, TPM war ab Werk aktiv.`; + +/** + * Legt den Lauf über die Oberfläche an und gibt seine Adresse zurück. + * + * Bewusst nicht über Prisma: das Anlegen **ist** Teil des Hauptablaufs. Die + * Vorlage wird am Namen gewählt (`vorlageWaehlen`), das Gerät am angezeigten + * Namen — in beiden Betriebsmodi, denn im Modus DIENSTLEISTUNG heißt das + * Beispielgerät anders und der Dialog fragt zusätzlich nach dem Kunden. + */ +async function laufStarten(seite: Page): Promise { + await seite.goto("/laeufe/starten"); + await vorlageWaehlen(seite, SEED_VORLAGE); + + const gesucht = geraetName(); + const geraete = await seite + .locator('select[name="geraetId"] option') + .evaluateAll((liste) => + liste + .filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement) + .map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })), + ); + const treffer = geraete.find((eintrag) => eintrag.text.includes(gesucht)); + if (!treffer) { + throw new Error( + `Das Gerät „${gesucht}“ steht nicht zur Auswahl. Zur Wahl stehen: ` + + `${geraete.map((eintrag) => eintrag.text).join(", ")}`, + ); + } + await seite.selectOption('select[name="geraetId"]', treffer.wert); + + /* + * Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden, und die Datenbank + * verlangt, dass es **der Kunde des Geräts** ist: ein zusammengesetzter + * Fremdschlüssel lässt keinen Lauf mit fremdem Gerät zu. Ein „nimm den + * ersten“ wäre also nicht nur ungenau, sondern im Zweifel rot. + */ + const kundenWahl = seite.locator('select[name="kundeId"]'); + if ((await kundenWahl.count()) > 0) { + const geraet = await prisma.geraet.findFirst({ + where: { OR: [{ hostname: gesucht }, { bezeichnung: gesucht }] }, + select: { kunde: { select: { name: true } } }, + }); + if (!geraet) throw new Error(`Das Gerät „${gesucht}“ steht nicht in der Datenbank.`); + await kundenWahl.selectOption({ label: geraet.kunde.name }); + } + + const weitere = seite.locator("details").filter({ hasText: "Titel, Ticket" }); + await weitere.locator("summary").click(); + await weitere.locator('input[name="titel"]').fill(TITEL); + + await variablenFuellen(seite); + + await seite.getByRole("button", { name: "Lauf anlegen" }).click(); + await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 }); + return new URL(seite.url()).pathname; +} + +/** Die Abschnitte der Abarbeitungsansicht – am aufklappbaren Kopf erkannt. */ +function abschnitteVon(seite: Page): Locator { + return seite.locator("section").filter({ has: seite.locator("button[aria-expanded]") }); +} + +test.describe("Hauptablauf", () => { + test("starten, abarbeiten, abschließen, archivieren, wiederfinden", async ({ page }) => { + /* + * Die Seed-Vorlage hat über dreißig Schritte, und jeder speichert einzeln — + * so arbeitet ein Techniker, und so soll es geprüft werden. Das dauert; die + * Voreinstellung von 30 Sekunden reicht dafür nicht. + */ + test.setTimeout(420_000); + + await anmelden(page); + const pfad = await laufStarten(page); + const laufId = pfad.split("/").pop() as string; + + // Ein neuer Lauf ist **offen**: das ist die Zuweisung, nicht der Start. + expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("OFFEN"); + await page.getByRole("button", { name: "Lauf beginnen" }).click(); + const abschliessen = page.getByRole("button", { name: "Lauf abschließen" }); + await expect(abschliessen).toBeVisible(); + await expect(abschliessen).toBeDisabled(); + + // ---- Ein Wert in einem Textschritt -------------------------------------- + const wertzeile = page.getByRole("listitem").filter({ hasText: WERTSCHRITT }); + await wertzeile.locator(WERTFELD).fill(WERT); + await wertzeile.getByRole("button", { name: "Wert speichern" }).click(); + + /* + * Auf die **Datenbank** warten, nicht auf den Bildschirm: das Feld trägt den + * Wert schon, bevor irgendetwas gespeichert ist — es ist ein kontrolliertes + * Eingabefeld. Und ein `reload` mitten in der Aktion bricht sie ab, also erst + * warten, dann neu laden. + */ + await expect + .poll( + async () => + ( + await prisma.schrittergebnis.findFirst({ + where: { laufId, vorlagenSchritt: { titel: WERTSCHRITT } }, + select: { wert: true }, + }) + )?.wert, + { timeout: 30_000, message: `Der Wert des Schritts „${WERTSCHRITT}“ steht nicht in der Datenbank.` }, + ) + .toBe(WERT); + + await page.reload(); + await expect(wertzeile.locator(WERTFELD)).toHaveValue(WERT); + + // ---- „Nicht zutreffend“ verlangt eine Begründung ------------------------ + const zeile = page.getByRole("listitem").filter({ hasText: UEBERSPRUNGEN }); + const pille = zeile.getByRole("button", { name: "nicht zutreffend" }); + await pille.click(); + + const mitBegruendung = zeile.getByRole("button", { name: "Mit Begründung speichern" }); + await expect(mitBegruendung).toBeVisible(); + await expect(mitBegruendung).toBeDisabled(); + + /* + * Und jetzt der eigentliche Nachweis: der gesperrte Knopf wird entsperrt und + * das Pflichtfeld entschärft, dann wird abgeschickt. Ein deaktivierter Knopf + * ist Bequemlichkeit — die Zusage („im Archiv steht kein übersprungener + * Schritt ohne Grund“) muss der Server halten, und nur so ist bewiesen, dass + * er es tut. + */ + await zeile.locator('textarea[name="notiz"]').evaluate((feld) => feld.removeAttribute("required")); + await mitBegruendung.evaluate((knopf) => knopf.removeAttribute("disabled")); + await mitBegruendung.click(); + await expect(zeile.getByRole("alert")).toContainText("braucht eine Begründung"); + expect(await prisma.schrittergebnis.count({ where: { laufId, zustand: "NICHT_ZUTREFFEND" } })).toBe(0); + + // Mit Begründung geht es – und danach steht sie in der Datenbank. + await zeile.locator('textarea[name="notiz"]').fill(NOTIZ); + await zeile.getByRole("button", { name: "Mit Begründung speichern" }).click(); + await expect(pille).toHaveAttribute("aria-pressed", "true"); + await expect + .poll( + async () => + prisma.schrittergebnis.findFirst({ + where: { laufId, vorlagenSchritt: { titel: UEBERSPRUNGEN } }, + select: { zustand: true, notiz: true }, + }), + { timeout: 30_000, message: `Der Schritt „${UEBERSPRUNGEN}“ steht nicht als übersprungen da.` }, + ) + .toEqual({ zustand: "NICHT_ZUTREFFEND", notiz: NOTIZ }); + + await page.reload(); + await expect(zeile.locator('textarea[name="notiz"]')).toHaveValue(NOTIZ); + + // ---- Den Rest abhaken -------------------------------------------------- + const abschnitte = abschnitteVon(page); + const anzahlAbschnitte = await abschnitte.count(); + expect(anzahlAbschnitte).toBeGreaterThan(0); + + for (let nummer = 0; nummer < anzahlAbschnitte; nummer += 1) { + const abschnitt = abschnitte.nth(nummer); + const kopf = abschnitt.locator("button[aria-expanded]").first(); + // Nach dem Neuladen ist zu, was fertig ist – erst aufklappen. + if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); + + const zeilen = abschnitt.locator("> ul > li"); + const anzahlZeilen = await zeilen.count(); + for (let stelle = 0; stelle < anzahlZeilen; stelle += 1) { + const schritt = zeilen.nth(stelle); + // Was schon vom Tisch ist, bleibt es: ein zweiter Klick nähme es zurück. + if ((await schritt.locator('button[aria-pressed="true"]').count()) > 0) continue; + const haken = schritt.getByRole("button", { name: "erledigt", exact: true }); + if ((await haken.count()) === 0) continue; + await haken.click(); + // Auf den **gespeicherten** Stand warten, nicht auf den nächsten Klick: + // ohne das trifft er einen Knopf aus dem alten Baum. + await expect(haken).toHaveAttribute("aria-pressed", "true"); + } + } + + // ---- Abschließen und archivieren --------------------------------------- + await expect(abschliessen).toBeEnabled(); + await abschliessen.click(); + const archivieren = page.getByRole("button", { name: "archivieren" }); + await expect(archivieren).toBeVisible(); + expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ABGESCHLOSSEN"); + + await archivieren.click(); + await page.getByRole("button", { name: "Ja, archivieren" }).click(); + await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible(); + expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ARCHIVIERT"); + + // ---- Im Archiv wiederfinden -------------------------------------------- + await page.goto("/archiv"); + await page.getByLabel("Suchbegriff").fill(NOTIZWORT); + await page.getByRole("button", { name: "Suchen" }).click(); + + const treffer = page.getByRole("listitem").filter({ hasText: TITEL }); + await expect(treffer).toHaveCount(1); + // Und der Grund steht dabei – ein Treffer ohne sichtbare Fundstelle ist wertlos. + await expect(treffer.getByText("Notiz")).toBeVisible(); + await expect(treffer.getByText(new RegExp(NOTIZWORT))).toBeVisible(); + + // ---- Und danach greift der Schreibschutz ------------------------------- + await treffer.getByRole("link").first().click(); + await page.waitForURL(`**/laeufe/${laufId}`); + await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible(); + + /* + * **Erst aufklappen, dann prüfen.** An einem abgeschlossenen Lauf ist jeder + * Abschnitt zugeklappt, und in einem zugeklappten Abschnitt steht überhaupt + * nichts im Baum. Ein „keine Knöpfe gefunden“ wäre dort kein Nachweis für den + * Schreibschutz, sondern nur einer dafür, dass niemand hingesehen hat. + */ + const archiviert = abschnitteVon(page); + const anzahlArchiviert = await archiviert.count(); + expect(anzahlArchiviert).toBeGreaterThan(0); + for (let nummer = 0; nummer < anzahlArchiviert; nummer += 1) { + const kopf = archiviert.nth(nummer).locator("button[aria-expanded]").first(); + if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); + await expect(kopf).toHaveAttribute("aria-expanded", "true"); + } + + for (const knopf of [ + "erledigt", + "nicht zutreffend", + "fehlgeschlagen", + "Wert speichern", + "Kommentar hinzufügen", + "Abschnitt trifft nicht zu", + "Lauf beginnen", + "Lauf abschließen", + ]) { + await expect( + page.getByRole("button", { name: knopf, exact: true }), + `Am archivierten Lauf steht noch der Knopf „${knopf}“.`, + ).toHaveCount(0); + } + /* + * Genau **ein** Formular bleibt stehen, und das ist das Zurückholen: das Archiv + * ist die Zusage, dass sich ein abgeschlossener Lauf nicht mehr ändert, und nur + * ein Administrator darf sie aufheben. Gezählt wird deshalb auf eins und nicht + * auf null — sonst hieße „grün“ hier irgendwann, dass auch der eine legitime + * Weg verschwunden ist. + */ + await expect(page.getByRole("button", { name: "aus dem Archiv zurückholen" })).toBeVisible(); + await expect(page.locator("main form")).toHaveCount(1); + + /* + * Der Wert und die Begründung stehen weiter da – lesbar, nur nicht änderbar. + * Ein Archiv, das seine Inhalte beim Sperren verliert, wäre kein Archiv. + */ + await expect(page.getByText(WERT)).toBeVisible(); + await expect(page.getByText(NOTIZ)).toBeVisible(); + }); +}); + +test.afterAll(async () => { + /* + * Aufgeräumt wird über die Datenbank: für einen Lauf gibt es in der Anwendung + * keinen Löschweg, und das ist richtig so. `Protokoll.laufId` steht auf + * `Restrict` — ohne diese Zeile bleibt der Lauf stehen und der Fremdschlüssel + * meldet sich. + */ + const laeufe = await prisma.lauf.findMany({ + where: { titel: { startsWith: "ZZ E2E Hauptablauf" } }, + select: { id: true }, + }); + const kennungen = laeufe.map((lauf) => lauf.id); + if (kennungen.length > 0) { + await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } }); + await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } }); + await prisma.protokoll.deleteMany({ + where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] }, + }); + await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); + } + await prisma.$disconnect(); +}); diff --git a/tests/e2e/helfer.ts b/tests/e2e/helfer.ts new file mode 100644 index 0000000..86f3a0d --- /dev/null +++ b/tests/e2e/helfer.ts @@ -0,0 +1,138 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import "dotenv/config"; + +/** + * Was alle Ende-zu-Ende-Abläufe teilen (#018). + * + * Bis hierher hatte jede Datei ihr eigenes `anmelden()` — zehnmal dasselbe, und + * jede Verbesserung musste zehnmal nachgezogen werden. Wichtiger als die + * Wiederholung ist aber, was hier sonst noch steht: **die Voraussetzungen eines + * Laufs, geprüft statt im Kopfkommentar behauptet.** + * + * In #017 kostete beides einen halben Abend. Die Abläufe wählten ihre Vorlage + * über die Position in der Liste, und der Testprozess schrieb seine Daten in + * eine andere Datenbank, als der Server las. Beide Male sagte die Meldung nichts + * über die Ursache: einmal „Timeout 30000ms exceeded“, einmal „0 Treffer“. + */ + +/** Die Vorlage, mit der die Abläufe arbeiten — aus dem Seed, nicht aus einem Skript. */ +export const SEED_VORLAGE = "PC Neuinstallation"; + +/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */ +export const SEED_GERAET = "PC-001"; +export const SEED_TAG = "LZ-7K3F9Q"; + +export async function anmelden(seite: Page): Promise { + const email = process.env["SEED_ADMIN_EMAIL"]; + const passwort = process.env["SEED_ADMIN_PASSWORD"]; + if (!email || !passwort) { + throw new Error( + "SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen. Sie stehen in der .env und müssen zu dem " + + "Administrator passen, den der Seed angelegt hat.", + ); + } + await seite.goto("/anmelden"); + await seite.fill('input[name="email"]', email); + await seite.fill('input[name="passwort"]', passwort); + await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); +} + +/** + * Wählt eine Vorlage im Start-Dialog **am Namen**. + * + * Nicht über `{ index: 1 }`: das ist eine Annahme über die Sortierung der + * Vorlagenliste, und sie hielt genau so lange, wie es eine einzige Vorlage gab. + * Steht die gesuchte nicht zur Auswahl, sagt die Meldung, welche fehlt — nicht + * „Timeout beim Warten auf die Adresse“. + */ +export async function vorlageWaehlen(seite: Page, name: string): Promise { + const optionen = await seite + .locator('select[name="vorlagenVersionId"] option') + .evaluateAll((liste) => + liste + .filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement) + .map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })), + ); + const treffer = optionen.find((eintrag) => eintrag.text.includes(name)); + if (!treffer) { + const vorhanden = optionen.map((eintrag) => eintrag.text).join(", "); + throw new Error(`Die Vorlage „${name}“ steht nicht zur Auswahl. Zur Wahl stehen: ${vorhanden}`); + } + await seite.selectOption('select[name="vorlagenVersionId"]', treffer.wert); + // Die Variablenfelder kommen nach, sobald der Server die Vorlage geliefert hat. + await expect(seite.locator("fieldset")).toBeVisible(); +} + +/** + * Füllt alle Pflichtfelder des Start-Dialogs, die noch leer sind. + * + * Bewusst **nur leere**: ein Vorschlag aus den Namensregeln soll stehen bleiben, + * sonst prüfte der Ablauf danach seine eigene Eingabe statt der Regel. + */ +export async function variablenFuellen(seite: Page, ausnahmen: readonly string[] = []): Promise { + const felder = seite.locator("fieldset label"); + for (let nummer = 0; nummer < (await felder.count()); nummer += 1) { + const feld = felder.nth(nummer); + const beschriftung = (await feld.locator("span").first().innerText()).replace("*", "").trim(); + if (ausnahmen.some((ausnahme) => beschriftung.includes(ausnahme))) continue; + + const auswahl = feld.locator("select"); + if ((await auswahl.count()) > 0) { + await auswahl.selectOption({ index: 1 }); + continue; + } + const eingabe = feld.locator("input"); + if ((await eingabe.count()) === 0) continue; + if ((await eingabe.inputValue()) !== "") continue; + + const typ = await eingabe.getAttribute("type"); + if (typ === "date") await eingabe.fill("2026-10-01"); + else if (typ === "number") await eingabe.fill("16"); + else await eingabe.fill("E2E"); + } +} + +/** + * Waehlt den Kunden, wo das Formular danach fragt. + * + * Im Modus INTERN gibt es das Feld nicht — die eigene Organisation ist gesetzt, + * und der Server verwirft eine mitgeschickte Kennung ohnehin (#008). Im Modus + * DIENSTLEISTUNG ist es Pflicht, und ohne Auswahl antwortet der Dienst mit + * „Im Modus Dienstleistung muss ein Kunde gewählt werden“. + * + * `kundeId` gibt man an, wenn es auf **diesen** Kunden ankommt: ein Lauf kann + * strukturell kein Geraet und keine Abteilung eines fremden Kunden waehlen, das + * halten zusammengesetzte Fremdschluessel in der Datenbank fest. Ohne Angabe + * nimmt der Helfer den ersten — richtig ueberall dort, wo nur irgendein Kunde + * gebraucht wird. + */ +export async function kundeWaehlen( + /** + * Seite **oder** der Bereich, in dem das Feld steht. Der Bereich zaehlt, wo + * mehrere Formulare nebeneinander stehen: die Geraeteseite hat eines zum + * Anlegen und eines zum Einlesen, und `.first()` traefe dann das falsche. + */ + bereich: Page | Locator, + kundeId?: string, +): Promise { + const feld = bereich.locator('select[name="kundeId"]').first(); + if ((await feld.count()) === 0 || !(await feld.isVisible())) return false; + await feld.selectOption(kundeId ?? { index: 1 }); + return true; +} + +/** + * Der Betriebsmodus der Installation unter Test. + * + * Gesetzt von `global-setup.ts` aus der Datenbank. Ein Ablauf, der nur in einem + * der beiden Modi Sinn ergibt, fragt hier und ueberspringt sich mit einem Grund + * — statt in einer Oberflaeche zu suchen, die es dort nicht gibt. + */ +export function betriebsmodus(): "INTERN" | "DIENSTLEISTUNG" { + return process.env["E2E_BETRIEBSMODUS"] === "DIENSTLEISTUNG" ? "DIENSTLEISTUNG" : "INTERN"; +} + +/** Das Beispielgerät heisst im Modus DIENSTLEISTUNG anders — dieselbe Regel wie im Seed. */ +export function geraetName(): string { + return betriebsmodus() === "DIENSTLEISTUNG" ? "MUS-PC-001" : SEED_GERAET; +} diff --git a/tests/e2e/laeufe.spec.ts b/tests/e2e/laeufe.spec.ts index 697f062..6e4e524 100644 --- a/tests/e2e/laeufe.spec.ts +++ b/tests/e2e/laeufe.spec.ts @@ -1,6 +1,16 @@ import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { + SEED_GERAET, + SEED_TAG, + SEED_VORLAGE, + anmelden, + kundeWaehlen, + variablenFuellen, + vorlageWaehlen, +} from "./helfer"; /** * Ein Lauf von vorn bis hinten durch die Oberflaeche (#013). @@ -10,68 +20,57 @@ import { prisma } from "@/lib/db"; * was nur mit angehaengtem React gelingt, und danach, ob es wirklich in der * Datenbank steht: durch Neuladen. * - * Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und - * `pnpm beispiel:vorlage` hat die Beispielvorlage angelegt. + * Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein + * **gebauter** Stand antwortet, `SEED_VORLAGE` ist veroeffentlicht, das + * Beispielgeraet steht, und der Server liest dieselbe Datenbank wie dieser Lauf. + * Gearbeitet wird mit dem Bestand aus `pnpm db:seed` — die Vorlage aus + * `pnpm beispiel:vorlage` braucht hier niemand mehr (#018). */ const STEMPEL = Date.now(); const TITEL = `ZZ E2E Lauf ${STEMPEL}`; -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); +/** + * Das Beispielgeraet, geholt ueber den **Tag-Code**. + * + * Nicht ueber `{ index: 1 }` und nicht ueber den Hostnamen: die Position in der + * Liste haengt an der Sortierung, und „PC-001“ gibt es in einer benutzten + * Datenbank mehr als einmal. Der Tag-Code gehoert genau einem Geraet — und dass + * es dieses gibt, prueft der Global-Setup. + */ +async function seedGeraetId(): Promise { + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { id: true }, + }); + return geraet.id; } -/** Die Vorlage aus `pnpm beispiel:vorlage` – diese Ablaeufe arbeiten mit ihr. */ -const BEISPIEL_VORLAGE = "PC-Neuinstallation (Beispiel)"; - /** Legt einen Lauf an und gibt seine Adresse zurueck. */ async function laufStarten(seite: Page, titel: string): Promise { await seite.goto("/laeufe/starten"); - + await vorlageWaehlen(seite, SEED_VORLAGE); /* - * Die Vorlage wird **am Namen** gewaehlt und nicht ueber `index: 1`. Seit dem - * Seed der sieben Vorlagen (#017) steht dort an erster Stelle, was die - * Sortierung gerade nach vorn stellt — bei einer frisch eingerichteten - * Installation etwa „Mitarbeiter-Offboarding“ mit Pflichtangaben, die dieser - * Helfer nicht kennt. Der Ablauf lief dann in den Zeitablauf, und die Meldung - * sagte nichts ueber die Ursache. + * Erst der Kunde, dann das Geraet — und zwar **der Kunde des Geraets**. Im + * Modus DIENSTLEISTUNG fragt der Dialog danach, und ein Lauf kann strukturell + * kein Geraet eines fremden Kunden waehlen: der zusammengesetzte + * Fremdschluessel `Lauf_geraet_gleicher_kunde` weist das ab. „Irgendein Kunde“ + * waere hier also nicht ungenau, sondern rot. Im Modus INTERN gibt es das Feld + * nicht. */ - const vorlagen = await seite - .locator('select[name="vorlagenVersionId"] option') - .evaluateAll((liste) => - liste - .filter((option): option is HTMLOptionElement => option instanceof HTMLOptionElement) - .map((option) => ({ wert: option.value, text: option.textContent })), - ); - const beispiel = vorlagen.find((eintrag) => (eintrag.text ?? "").includes(BEISPIEL_VORLAGE)); - if (!beispiel) { - throw new Error(`Die Vorlage „${BEISPIEL_VORLAGE}“ fehlt. Erst „pnpm beispiel:vorlage“ laufen lassen.`); - } - await seite.selectOption('select[name="vorlagenVersionId"]', beispiel.wert); - await seite.selectOption('select[name="geraetId"]', { index: 1 }); + const geraetId = await seedGeraetId(); + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { id: geraetId }, + select: { kundeId: true }, + }); + await kundeWaehlen(seite, geraet.kundeId); + // Das Geraet setzt `{{hostname}}` im Befehlsschritt – ohne es bliebe der + // Platzhalter stehen, und der Test darauf pruefte nichts. + await seite.selectOption('select[name="geraetId"]', geraetId); await seite.locator("details summary", { hasText: "Titel, Ticket" }).click(); await seite.getByLabel("Titel", { exact: true }).fill(titel); - // Pflichtvariablen der Vorlage fuellen – welche es sind, weiss nur die Vorlage. - for (const feld of await seite.locator("fieldset select").all()) { - await feld.selectOption({ index: 1 }); - } - for (const feld of await seite.locator('fieldset input[type="number"]').all()) { - await feld.fill("16"); - } - for (const feld of await seite.locator('fieldset input[type="date"]').all()) { - await feld.fill("2026-10-01"); - } - for (const feld of await seite.locator('fieldset input[type="text"]').all()) { - // Nur leere Felder: die Namensvorschlaege sollen stehen bleiben. - if ((await feld.inputValue()) === "") await feld.fill("E2E"); - } + await variablenFuellen(seite); await seite.getByRole("button", { name: "Lauf anlegen" }).click(); await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 }); @@ -153,8 +152,11 @@ test.describe("Ein Lauf von vorn bis hinten", () => { await laufStarten(page, `${TITEL} B`); await page.getByRole("button", { name: "Lauf beginnen" }).click(); + // Am Schritt und nicht ueber `.first()`: welcher Schritt zuerst kommt, ist + // eine Annahme ueber die Vorlage (#018). + const schrittZuvor = page.getByRole("listitem").filter({ hasText: "Ticket und Endbenutzer" }); // Der Klick allein speichert nicht – erst erscheint das Begründungsfeld. - await page.getByRole("button", { name: "nicht zutreffend" }).first().click(); + await schrittZuvor.getByRole("button", { name: "nicht zutreffend" }).click(); const speichern = page.getByRole("button", { name: "Mit Begründung speichern" }); await expect(speichern).toBeVisible(); await expect(speichern).toBeDisabled(); @@ -211,15 +213,26 @@ test.describe("Ein Lauf von vorn bis hinten", () => { await laufStarten(page, `${TITEL} C`); /* - * Der Befehl **am Schritt**, nicht die Skript-Kopie: die steckt in einem - * zugeklappten `details` und ist deshalb unsichtbar, bis jemand sie - * aufklappt. + * Am **Schritt**, der den Platzhalter traegt, und nicht am ersten `pre.mono` + * der Seite: nur dort beweist „enthaelt `{{hostname}}` nicht“ etwas. Ein + * Befehl ohne Platzhalter waere auch gruen, wenn nie etwas eingesetzt wuerde. */ - const befehl = page.locator("li pre.mono").first(); + const schritt = page.getByRole("listitem").filter({ hasText: "Hostname gesetzt" }); + const befehl = schritt.locator("pre.mono").first(); await expect(befehl).toBeVisible(); await expect(befehl).not.toContainText("{{hostname}}"); - await expect(page.getByRole("button", { name: "Befehl kopieren" }).first()).toBeVisible(); - await expect(page.getByText("Alle Befehle als Skript kopieren")).toBeVisible(); + // Und was stattdessen dasteht: der Name des gewaehlten Geraets. + await expect(befehl).toContainText(SEED_GERAET); + await expect(schritt.getByRole("button", { name: "Befehl kopieren" })).toBeVisible(); + + /* + * Die Skript-Kopie steckt in einem zugeklappten `details` und steht **je + * Abschnitt**, der Befehle hat — bei dieser Vorlage in dreien. Geprueft wird + * deshalb der Abschnitt dieses Schritts, nicht „irgendwo auf der Seite“: + * ohne die Eingrenzung fand der Test drei Treffer und brach ab (#018). + */ + const abschnitt = page.locator("section").filter({ has: schritt }); + await expect(abschnitt.getByText("Alle Befehle als Skript kopieren")).toBeVisible(); }); test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { @@ -285,7 +298,23 @@ test.describe("Namensvorschläge im Start-Dialog", () => { */ const VORLAGE = `ZZ E2E Onboarding ${STEMPEL}`; + /* + * Die Namensregeln des Hauses, wie sie vorher dastanden. + * + * Sie werden fuer diese beiden Ablaeufe gesetzt und hinterher **zurueckgestellt** + * — nicht auf `{}` geleert. Genau das tat der Nachlauf vorher, und damit nahm + * er der Datenbank die Regeln, die `pnpm db:seed` eingerichtet hatte: in der + * Entwicklungsdatenbank stand danach `namensregeln = {}`, und der Schnellstart + * eines Onboardings schlug ohne erkennbaren Grund fehl (#018). + */ + let namensregelnVorher: Prisma.JsonValue = {}; + test.beforeAll(async () => { + const vorher = await prisma.einstellung.findUniqueOrThrow({ + where: { id: "global" }, + select: { namensregeln: true }, + }); + namensregelnVorher = vorher.namensregeln; await prisma.einstellung.update({ where: { id: "global" }, data: { @@ -318,21 +347,19 @@ test.describe("Namensvorschläge im Start-Dialog", () => { }); }); - /** Waehlt die Testvorlage – ueber den Wert, nicht ueber die Beschriftung. */ - async function vorlageWaehlen(seite: Page) { - const wert = await seite - .locator('select[name="vorlagenVersionId"] option', { hasText: VORLAGE }) - .getAttribute("value"); - if (!wert) throw new Error(`Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl.`); - await seite.selectOption('select[name="vorlagenVersionId"]', wert); - } - test("füllt upn, sam und mail nach den Regeln", async ({ page }) => { test.slow(); await anmelden(page); await page.goto("/laeufe/starten"); - await vorlageWaehlen(page); + await vorlageWaehlen(page, VORLAGE); await page.getByLabel("… oder Name als Freitext").fill("Jörg Müller-Lüdenscheid"); + /* + * Im Modus DIENSTLEISTUNG braucht der Vorschlag einen Kunden: die Regeln + * koennen je Kunde eigene sein, und `istVergeben` fragt innerhalb seiner + * Grenze. Ohne Auswahl antwortet die Aktion mit „Im Modus Dienstleistung + * muss ein Kunde gewählt werden“ und die Felder bleiben leer. + */ + await kundeWaehlen(page); await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); @@ -351,8 +378,9 @@ test.describe("Namensvorschläge im Start-Dialog", () => { test.slow(); await anmelden(page); await page.goto("/laeufe/starten"); - await vorlageWaehlen(page); + await vorlageWaehlen(page, VORLAGE); await page.getByLabel("… oder Name als Freitext").fill("Maria Huber"); + await kundeWaehlen(page); await page.getByLabel("upn").fill("chefin@firma.de"); await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); @@ -367,7 +395,10 @@ test.describe("Namensvorschläge im Start-Dialog", () => { select: { id: true }, }); if (vorlage) await prisma.vorlage.delete({ where: { id: vorlage.id } }); - await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: {} } }); + await prisma.einstellung.update({ + where: { id: "global" }, + data: { namensregeln: namensregelnVorher as Prisma.InputJsonValue }, + }); }); }); diff --git a/tests/e2e/mobil.spec.ts b/tests/e2e/mobil.spec.ts new file mode 100644 index 0000000..828c926 --- /dev/null +++ b/tests/e2e/mobil.spec.ts @@ -0,0 +1,284 @@ +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer"; + +/** + * Am Handy scrollt nichts nach rechts (#018). + * + * Die Zusage steht in CLAUDE.md und im Auftrag: **bei 390 px kein horizontales + * Scrollen**, auf keiner Hauptseite. Sie ist keine Geschmacksfrage. Ein Techniker + * arbeitet einen Lauf einhändig neben einem aufgeschraubten Rechner ab; eine + * Seite, die seitwärts wegläuft, kostet ihn bei jedem Haken einen Griff. + * + * Geprüft wird das schmalste Maß, das es gibt: `scrollWidth <= clientWidth` am + * `html`-Element. Und geprüft wird es **auf allen** Hauptseiten, nicht auf einer + * Auswahl — die eine Seite, die jemand vergisst, ist die, auf der es bricht. + * + * Die Meldung nennt die Seite, die Zahlen und das breiteste Element. „expected + * true“ beschreibt zwar denselben Fehler, aber niemand kann damit etwas anfangen: + * bei einer Seite mit dreihundert Kästen ist die Frage nicht *ob*, sondern *wo*. + * + * Der Ablauf legt an, was er braucht — einen Lauf für die Abarbeitungsansicht und + * einen Entwurf für den Editor — und räumt beides weg. Ein Ablauf, der sich + * darauf verlässt, dass zufällig ein Lauf in der Datenbank steht, scheitert gegen + * eine frische Installation mit „No record was found“, und das sagt nichts über + * die Ursache (Befund aus #017). + */ + +const STEMPEL = Date.now(); +const LAUF_TITEL = `ZZ E2E Mobil ${STEMPEL}`; +const ENTWURF_NAME = `ZZ E2E Mobil Entwurf ${STEMPEL}`; + +let laufId = ""; +let geraetId = ""; +let mitarbeiterId = ""; +let vorlageId = ""; +let entwurfId = ""; +let entwurfVorlageId = ""; + +/* + * Nur im Projekt „handy“. Auf dem Desktop prüfte derselbe Ablauf 1440 px gegen + * 1440 px und wäre immer grün — grün ohne Aussage ist schlimmer als rot. + */ +test.skip(({ isMobile }) => !isMobile, "Nur im Projekt „handy“ — 390 px ist die Zusage."); + +/** + * **390 × 844, ausdrücklich.** + * + * Das Projekt „handy“ benutzt `devices["Pixel 7"]`, und das sind 412 × 839. Die + * Zusage lautet aber auf 390 px (CLAUDE.md, „Gestaltung“; Abnahmekriterium in + * #018), und 22 px sind kein Rundungsfehler: eine Seite, die bei 390 px + * ausbricht, kann bei 412 px tadellos aussehen. Die Zahl, auf die es ankommt, + * steht deshalb hier und nicht in einem Gerätenamen, dessen Maße sich mit der + * nächsten Playwright-Fassung ändern dürfen. + */ +test.use({ viewport: { width: 390, height: 844 } }); + +test.beforeAll(async () => { + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { id: true, kundeId: true }, + }); + geraetId = geraet.id; + + const person = await prisma.mitarbeiter.findFirst({ + where: { aktiv: true, kundeId: geraet.kundeId }, + orderBy: { anzeigename: "asc" }, + select: { id: true }, + }); + if (!person) throw new Error("Es gibt keine aktive Person — pnpm db:seed laufen lassen."); + mitarbeiterId = person.id; + + const vorlage = await prisma.vorlage.findFirstOrThrow({ + where: { name: SEED_VORLAGE }, + select: { + id: true, + versionen: { + where: { status: "VEROEFFENTLICHT" }, + orderBy: { version: "desc" }, + take: 1, + select: { id: true }, + }, + }, + }); + vorlageId = vorlage.id; + const versionId = vorlage.versionen[0]?.id; + if (!versionId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`); + + /* + * Ein Lauf in Arbeit: er trägt die langen Befehlszeilen und die Hilfetexte, und + * das sind die beiden Stellen, an denen eine Seite am Handy nach rechts + * ausbricht. Der Kunde kommt vom Gerät — ein zusammengesetzter Fremdschlüssel + * lässt kein Gerät eines fremden Kunden zu. + */ + const lauf = await prisma.lauf.create({ + data: { + vorlagenVersionId: versionId, + kundeId: geraet.kundeId, + geraetId: geraet.id, + mitarbeiterId, + titel: LAUF_TITEL, + status: "IN_ARBEIT", + gestartetAm: new Date(), + }, + select: { id: true }, + }); + laufId = lauf.id; + + /* + * Ein eigener Entwurf für den Editor. Nicht „irgendein Entwurf“ aus der + * Datenbank: eine frische Installation bringt bewusst keinen mit (so in #017 + * entschieden), und die Seed-Vorlagen zum Bearbeiten zu öffnen hinterließe + * einen Entwurf, den niemand angelegt hat. + */ + const eigene = await prisma.vorlage.create({ + data: { name: ENTWURF_NAME, kategorie: "ZZ Test" }, + select: { id: true }, + }); + entwurfVorlageId = eigene.id; + const entwurf = await prisma.vorlagenVersion.create({ + data: { vorlageId: eigene.id, version: 1, status: "ENTWURF" }, + select: { id: true }, + }); + entwurfId = entwurf.id; + await prisma.vorlagenVariable.create({ + data: { + versionId: entwurf.id, + schluessel: "hostname", + beschriftung: "Hostname des neuen Geräts", + position: 0, + pflicht: true, + }, + }); + const abschnitt = await prisma.vorlagenAbschnitt.create({ + data: { versionId: entwurf.id, titel: "Vorbereitung", position: 0 }, + select: { id: true }, + }); + await prisma.vorlagenSchritt.create({ + data: { + abschnittId: abschnitt.id, + titel: "Rechner umbenennen", + position: 0, + typ: "BEFEHL", + pflicht: true, + // Absichtlich lang: ein kurzer Befehl bricht keine Seite auf. + konfig: { + shell: "powershell", + befehl: 'Rename-Computer -NewName "{{hostname}}" -DomainCredential (Get-Credential) -Restart -Force', + }, + }, + }); +}); + +/** Eine Hauptseite. Die Adresse kommt als Funktion, weil die Kennungen erst in `beforeAll` entstehen. */ +type Hauptseite = { + readonly name: string; + readonly pfad: () => string; + /** Nur in diesem Betriebsmodus vorhanden. Sonst gibt es die Seite nicht. */ + readonly nurModus?: "INTERN" | "DIENSTLEISTUNG"; +}; + +const SEITEN: readonly Hauptseite[] = [ + { name: "Übersicht", pfad: () => "/" }, + { name: "Läufe", pfad: () => "/laeufe" }, + { name: "Lauf-Detail", pfad: () => `/laeufe/${laufId}` }, + { name: "Lauf starten", pfad: () => "/laeufe/starten" }, + { name: "Vorlagen", pfad: () => "/vorlagen" }, + { name: "Vorlagen-Detail", pfad: () => `/vorlagen/${vorlageId}` }, + { name: "Vorlagen-Editor", pfad: () => `/vorlagen/version/${entwurfId}` }, + { name: "Mitarbeiter", pfad: () => "/mitarbeiter" }, + { name: "Mitarbeiter-Detail", pfad: () => `/mitarbeiter/${mitarbeiterId}` }, + { name: "Geräte", pfad: () => "/geraete" }, + { name: "Geräteseite", pfad: () => `/geraete/${geraetId}` }, + { name: "Archiv", pfad: () => "/archiv" }, + { name: "Tags", pfad: () => "/tags" }, + { name: "Scannen", pfad: () => "/scannen" }, + { name: "Admin: Einstellungen", pfad: () => "/admin" }, + { name: "Admin: Benutzer", pfad: () => "/admin/benutzer" }, + { name: "Admin: Abteilungen", pfad: () => "/admin/abteilungen" }, + /* + * Das Verzeichnis gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG + * leitet die Seite auf `/admin` um. Sie dort zu messen hiesse, die Umleitung + * zu messen — die scrollt garantiert nicht. + */ + { name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" }, + { name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" }, +]; + +type Messung = { + readonly scroll: number; + readonly sichtbar: number; + readonly uebeltaeter: readonly string[]; +}; + +/** + * Misst die Seite und benennt, was zu weit rechts endet. + * + * Gemeldet wird nur das **äußerste** Element je Kette: ein Kind, dessen Elter + * schon zu breit ist, sagt nichts Neues und würde die Meldung mit dreißig + * Einträgen zuschütten, die alle dieselbe Ursache haben. + */ +async function messen(seite: Page): Promise { + return seite.evaluate(() => { + const wurzel = document.documentElement; + const sichtbar = wurzel.clientWidth; + const gemeldet: Element[] = []; + + for (const element of Array.from(document.body.querySelectorAll("*"))) { + const kasten = element.getBoundingClientRect(); + if (kasten.width === 0 && kasten.height === 0) continue; + // Ein Pixel Spielraum: gebrochene Bildschirmbreiten runden. + if (kasten.right <= sichtbar + 1) continue; + if (gemeldet.some((frueher) => frueher.contains(element))) continue; + gemeldet.push(element); + } + + const benennen = (element: Element): string => { + const klassen = (element.getAttribute("class") ?? "") + .split(/\s+/u) + .filter(Boolean) + .slice(0, 3) + .join("."); + const text = (element.textContent ?? "").trim().replace(/\s+/gu, " ").slice(0, 40); + const breite = Math.round(element.getBoundingClientRect().right); + return `<${element.tagName.toLowerCase()}${klassen ? `.${klassen}` : ""}> endet bei ${breite} px${ + text ? ` („${text}“)` : "" + }`; + }; + + return { + scroll: wurzel.scrollWidth, + sichtbar, + uebeltaeter: gemeldet.slice(0, 3).map(benennen), + }; + }); +} + +test.describe("Am Handy bei 390 px", () => { + for (const hauptseite of SEITEN) { + test(`scrollt „${hauptseite.name}“ nicht horizontal`, async ({ page }) => { + test.slow(); + test.skip( + hauptseite.nurModus !== undefined && hauptseite.nurModus !== betriebsmodus(), + `„${hauptseite.name}“ gibt es nur im Modus ${hauptseite.nurModus}.`, + ); + await anmelden(page); + + const pfad = hauptseite.pfad(); + await page.goto(pfad); + /* + * Erst prüfen, dass die Seite überhaupt die gemeinte ist: eine + * Weiterleitung auf `/anmelden` scrollt garantiert nicht, und der Ablauf + * hätte grün gemeldet, ohne die Seite gesehen zu haben. + */ + expect(new URL(page.url()).pathname, `${pfad} hat weitergeleitet.`).toBe(pfad); + await page.waitForLoadState("load"); + await expect(page.locator("main, body").first()).toBeVisible(); + + const masse = await messen(page); + expect( + masse.scroll, + `„${hauptseite.name}“ (${pfad}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` + + `war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` + + (masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""), + ).toBeLessThanOrEqual(masse.sichtbar); + }); + } +}); + +test.afterAll(async () => { + if (laufId) { + await prisma.kommentar.deleteMany({ where: { laufId } }); + await prisma.schrittergebnis.deleteMany({ where: { laufId } }); + // `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen. + await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } }); + await prisma.lauf.deleteMany({ where: { id: laufId } }); + } + if (entwurfVorlageId) { + // Ein Entwurf lässt sich löschen; eine veröffentlichte Version nie. Deshalb + // wird hier einer angelegt und keiner veröffentlicht. + await prisma.vorlage.deleteMany({ where: { id: entwurfVorlageId } }); + } + await prisma.$disconnect(); +}); diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 0000000..e4b28dc --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,367 @@ +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { PASSWORT_IM_DRUCK, PASSWORT_LAENGE } from "@/lib/passwort"; +import { anmelden } from "./helfer"; + +/** + * Der Onboarding-Ablauf (#018). + * + * Er prüft die Kette, an der im Auftrag das meiste hängt: aus einem Namen werden + * über die Namensregeln UPN, Anmeldename und Mail-Adresse, aus der gewählten + * Abteilung werden Ziel-OU und Standardgruppen, und aus allem zusammen entsteht + * ein `New-ADUser`-Befehl, den ein Techniker auf einem Domänencontroller + * ausführt. + * + * Zwei Zusagen werden hier eingelöst, und beide nur hier: + * + * 1. **Der Kopier-Knopf liefert zeichengenau das, was auf dem Bildschirm + * steht.** Geprüft wird über die Zwischenablage, nicht über das erwartete + * Ergebnis: ein Vergleich gegen einen selbst zusammengebauten Text prüfte, ob + * der Test rechnen kann, nicht ob der Knopf richtig kopiert. Ein Befehl, der + * beim Kopieren etwas verliert — eine Zeile, ein Fluchtzeichen —, tut auf dem + * Domänencontroller etwas anderes als er liest. + * 2. **`{{generiertes_passwort}}` wird nie gespeichert.** Nicht im + * `Schrittergebnis`, nicht im `Kommentar`, nicht im `Protokoll` und nicht im + * Ausdruck. Das ist die härteste Zusage des Auftrags, und sie lässt sich nur + * so prüfen: das angezeigte Passwort auslesen und danach in der Datenbank + * danach suchen. + */ + +const STEMPEL = Date.now(); +const TITEL = `ZZ E2E Onboarding ${STEMPEL}`; +const VORLAGE = "Mitarbeiter-Onboarding"; +const ABTEILUNG = "Buchhaltung"; + +/* + * Der Name der neuen Person. Kein „ZZ E2E“ davor: aus ihm entstehen Anmeldenamen + * nach den Namensregeln, und ein Leerzeichen im Vornamen wäre eine Prüfung der + * Regeln und nicht des Ablaufs. Angelegt wird ohnehin nichts — die Person steht + * beim Onboarding noch nicht im Verzeichnis, sie ist nur Freitext am Lauf. + */ +const VORNAME = "Zoe"; +const NACHNAME = "Testfall"; + +/** + * Was die Abteilung in die Befehle speist – dieselben Werte wie im Seed + * (`prisma/seed-daten/stammdaten.ts`). + */ +const ZIEL_OU = "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"; +const GRUPPEN = "G-Buchhaltung, G-Alle"; + +/** + * Die Namensregeln, die dieser Ablauf braucht — er setzt sie selbst. + * + * Er darf sie nicht voraussetzen: `Einstellung.namensregeln` sind Betriebsdaten, + * ein Administrator ändert sie, und ein anderer Ablauf hat sie schon einmal + * geleert. Stehen sie leer, antwortet „Namen aus den Regeln vorschlagen“ mit + * „Für diesen Kunden sind keine Namensregeln hinterlegt“ — und der Ablauf + * scheiterte an einer Voraussetzung statt an einem Fehler. Die Werte sind + * dieselben, die `prisma/seed-daten/grundbestand.ts` anlegt. + */ +const REGELN = { + upn: "{v}.{nachname}@firma.example", + mail: "{v}.{nachname}@firma.example", + sam: "{v}{nachname}", +}; + +/** Was vor dem Ablauf in der Einstellung stand – wird hinterher zurückgelegt. */ +let regelnVorher: unknown = null; + +/** + * Der Kunde, dem die Abteilung „Buchhaltung“ gehört. + * + * Im Modus DIENSTLEISTUNG fragt der Start-Dialog nach einem Kunden, und die + * Datenbank verlangt, dass es **derselbe** ist wie der der gewählten Abteilung: + * `Lauf_abteilung_gleicher_kunde` lässt keine Abteilung eines fremden Kunden zu. + * Ein „nimm den ersten Kunden“ wäre dort nicht ungenau, sondern rot. + */ +let abteilungsKunde = ""; + +/** Die Adresse des Laufs, den der erste Ablauf anlegt. */ +let laufPfad = ""; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + const abteilung = await prisma.abteilung.findFirst({ + where: { name: ABTEILUNG }, + select: { zielOu: true, standardGruppen: true, kunde: { select: { name: true } } }, + }); + if (!abteilung) { + throw new Error( + `Die Abteilung „${ABTEILUNG}“ fehlt. Sie kommt aus dem Seed — pnpm db:seed laufen lassen.`, + ); + } + abteilungsKunde = abteilung.kunde.name; + // Lieber hier scheitern als später an einem Befehl, dessen -Path nicht stimmt: + // die Erwartung unten und die Datenbank müssen dasselbe sagen. + expect(abteilung.zielOu).toBe(ZIEL_OU); + expect(abteilung.standardGruppen.join(", ")).toBe(GRUPPEN); + + const einstellung = await prisma.einstellung.findUniqueOrThrow({ + where: { id: "global" }, + select: { namensregeln: true }, + }); + regelnVorher = einstellung.namensregeln; + await prisma.einstellung.update({ where: { id: "global" }, data: { namensregeln: REGELN } }); +}); + +/** Der Befehl eines Schritts, genau so wie er im `pre` steht – ohne jede Normalisierung. */ +async function befehlstext(seite: Page, enthaelt: string): Promise { + const zeile = seite.getByRole("listitem").filter({ hasText: enthaelt }); + const text = await zeile + .locator("pre.mono") + .first() + .evaluate((feld) => feld.textContent ?? ""); + return text; +} + +/** Das Passwort aus dem `New-ADUser`-Befehl. */ +function passwortAus(befehl: string): string { + const treffer = /ConvertTo-SecureString "([^"]+)"/u.exec(befehl); + if (!treffer?.[1]) { + throw new Error(`Im Befehl steht kein Passwort. Der Befehl lautet:\n${befehl}`); + } + return treffer[1]; +} + +test.describe("Onboarding", () => { + test("schlägt Namen vor, füllt OU und Gruppen und kopiert den Befehl zeichengenau", async ({ + page, + context, + }) => { + test.setTimeout(180_000); + /* + * Ohne dieses Recht wirft `navigator.clipboard.writeText` in Chromium, und + * der Knopf fängt das ab (der Text bleibt markierbar) — der Ablauf sähe einen + * Knopf, der scheinbar funktioniert, und eine leere Zwischenablage. + */ + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await anmelden(page); + await page.goto("/laeufe/starten"); + + // ---- Vorlage am Namen wählen ------------------------------------------- + const optionen = await page + .locator('select[name="vorlagenVersionId"] option') + .evaluateAll((liste) => + liste + .filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement) + .map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })), + ); + const treffer = optionen.find((eintrag) => eintrag.text.includes(VORLAGE)); + if (!treffer) { + throw new Error( + `Die Vorlage „${VORLAGE}“ steht nicht zur Auswahl. Zur Wahl stehen: ` + + `${optionen.map((eintrag) => eintrag.text).join(", ")}`, + ); + } + await page.selectOption('select[name="vorlagenVersionId"]', treffer.wert); + + const angaben = page.locator("fieldset"); + await expect(angaben).toBeVisible(); + + // ---- Vorname und Nachname eintragen ------------------------------------ + /* + * Der Vorschlag entsteht aus dem **Freitextnamen**, nicht aus den + * Vorlagenvariablen: beim Onboarding steht die Person noch nicht im + * Verzeichnis, und der Dialog fragt genau dafür nach einem Namen. + */ + await page.getByLabel("… oder Name als Freitext").fill(`${VORNAME} ${NACHNAME}`); + await angaben.getByLabel(/^Kontotyp/u).selectOption("Active Directory"); + await angaben.getByLabel(/^Vorname/u).fill(VORNAME); + await angaben.getByLabel(/^Nachname/u).fill(NACHNAME); + await angaben.getByLabel(/^Abteilung/u).fill(ABTEILUNG); + await angaben.getByLabel(/^Eintrittsdatum/u).fill("2026-10-01"); + + /* + * Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden — und es muss der + * der gewählten Abteilung sein, sonst weist die Datenbank den Lauf ab + * (`Lauf_abteilung_gleicher_kunde`). + * + * Gewählt wird er **vor** dem Namensvorschlag: die Namensregeln können je + * Kunde eigene sein, und ohne Kunden antwortet die Aktion mit „Im Modus + * Dienstleistung muss ein Kunde gewählt werden“ — die Felder blieben leer, + * und der Test läse das als fehlende Regel. + */ + const kundenWahl = page.locator('select[name="kundeId"]'); + if ((await kundenWahl.count()) > 0) await kundenWahl.selectOption({ label: abteilungsKunde }); + + // ---- Namen aus den Regeln vorschlagen ---------------------------------- + await page.getByRole("button", { name: "Namen aus den Regeln vorschlagen" }).click(); + + /* + * Die Zahl am Ende ist kein Zufall: trägt die Datenbank den Namensstamm + * schon, hängt die Regel eine Unterscheidung an. Ob mit oder ohne entscheidet + * der Bestand, deshalb steht hier ein Muster und keine Zeichenkette. + */ + await expect(angaben.getByLabel(/^UPN/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u); + await expect(angaben.getByLabel(/^Anmeldename/u)).toHaveValue(/^ztestfall\d*$/u); + await expect(angaben.getByLabel(/^Mail-Adresse/u)).toHaveValue(/^z\.testfall\d*@firma\.example$/u); + + const upn = await angaben.getByLabel(/^UPN/u).inputValue(); + const sam = await angaben.getByLabel(/^Anmeldename/u).inputValue(); + const mail = await angaben.getByLabel(/^Mail-Adresse/u).inputValue(); + + // Ziel-OU und Gruppen bleiben **leer**: sie sollen aus der Abteilung kommen. + await expect(angaben.getByLabel(/^Ziel-OU/u)).toHaveValue(""); + await expect(angaben.getByLabel(/^Gruppen/u)).toHaveValue(""); + + // ---- Abteilung im aufklappbaren Teil wählen ---------------------------- + const weitere = page.locator("details").filter({ hasText: "Titel, Ticket" }); + await weitere.locator("summary").click(); + await weitere.locator('input[name="titel"]').fill(TITEL); + await weitere.locator('select[name="abteilungId"]').selectOption({ label: ABTEILUNG }); + + await page.getByRole("button", { name: "Lauf anlegen" }).click(); + await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 }); + laufPfad = new URL(page.url()).pathname; + const laufId = laufPfad.split("/").pop() as string; + + // ---- Der fertige New-ADUser-Befehl ------------------------------------- + const anlegen = page.getByRole("listitem").filter({ hasText: "New-ADUser" }); + await expect(anlegen).toHaveCount(1); + const befehl = await befehlstext(page, "New-ADUser"); + + expect(befehl).toContain(`New-ADUser -Name "${VORNAME} ${NACHNAME}"`); + expect(befehl).toContain(`-GivenName "${VORNAME}"`); + expect(befehl).toContain(`-Surname "${NACHNAME}"`); + expect(befehl).toContain(`-SamAccountName "${sam}"`); + expect(befehl).toContain(`-UserPrincipalName "${upn}"`); + expect(befehl).toContain(`-EmailAddress "${mail}"`); + expect(befehl).toContain(`-Department "${ABTEILUNG}"`); + // Das Abnahmekriterium, wörtlich: die Ziel-OU kommt aus der Abteilung. + expect(befehl).toContain(`-Path "${ZIEL_OU}"`); + // Und kein Platzhalter ist stehen geblieben. + expect(befehl).not.toContain("{{"); + + // Der Gruppen-Befehl trägt die Standardgruppen der Abteilung. + const gruppenBefehl = await befehlstext(page, "Add-ADGroupMember"); + expect(gruppenBefehl).toContain(`"${GRUPPEN}".Split(",")`); + expect(gruppenBefehl).toContain(`-Members "${sam}"`); + + // ---- Genau ein generiertes Passwort ------------------------------------ + const passwort = passwortAus(befehl); + expect(passwort).toHaveLength(PASSWORT_LAENGE); + /* + * Ein Passwort, nicht zwei: wer `{{generiertes_passwort}}` zweimal schreibt, + * meint dasselbe. Zwei verschiedene hießen — das Konto bekommt A, der + * Benutzer erfährt B. + */ + expect(befehl.split(passwort)).toHaveLength(2); + // Und der Hinweis steht daneben, weil es nirgends gespeichert wird. + await expect(anlegen.getByText(/Passwort sofort in den Passwortmanager/)).toBeVisible(); + + // ---- Der Kern: die Zwischenablage, zeichengenau ------------------------ + await anlegen.getByRole("button", { name: "Befehl kopieren" }).click(); + await expect(anlegen.getByRole("button", { name: "Kopiert." })).toBeVisible(); + + const zwischenablage = await page.evaluate(() => navigator.clipboard.readText()); + // `toBe` und nicht `toContain`: ein verlorenes Fluchtzeichen oder ein + // umgewandelter Zeilenumbruch ändert den Befehl, ohne ihn falsch aussehen zu + // lassen. + expect(zwischenablage).toBe(befehl); + + // ---- Das Passwort steht nirgends in der Datenbank ---------------------- + /* + * Erst einen Schritt speichern: das ist der Vorgang, bei dem etwas in + * `Schrittergebnis` und `Protokoll` geschrieben wird. Ohne ihn prüfte der + * folgende Abschnitt leere Tabellen. + */ + await anlegen.getByRole("button", { name: "erledigt", exact: true }).click(); + // Auf die Datenbank warten, nicht auf den Bildschirm: der Protokolleintrag + // entsteht in derselben Transaktion, und genau den soll die Suche unten sehen. + await expect + .poll(async () => prisma.schrittergebnis.count({ where: { laufId, zustand: "ERLEDIGT" } }), { + timeout: 30_000, + message: "Der Schritt „Konto anlegen“ steht nicht als erledigt in der Datenbank.", + }) + .toBeGreaterThan(0); + + const ergebnisse = await prisma.schrittergebnis.findMany({ + where: { laufId }, + select: { wert: true, notiz: true }, + }); + const kommentare = await prisma.kommentar.findMany({ where: { laufId }, select: { rumpf: true } }); + const protokolle = await prisma.protokoll.findMany({ + where: { OR: [{ laufId }, { entitaetId: laufId }] }, + select: { aktion: true, vorher: true, nachher: true }, + }); + expect(JSON.stringify({ ergebnisse, kommentare, protokolle })).not.toContain(passwort); + + /* + * Und zur Sicherheit über **alle** Zeilen, nicht nur die dieses Laufs: ein + * Passwort, das an einer ganz anderen Stelle landet, wäre genauso schlimm. + * `strpos` statt `LIKE`: das Alphabet enthält `_`, und das ist in `LIKE` ein + * Platzhalter — ein Treffer wäre dann Zufall und ein Nicht-Treffer nichts wert. + */ + const spuren = await prisma.$queryRaw< + readonly { readonly ergebnisse: bigint; readonly kommentare: bigint; readonly protokoll: bigint }[] + >` + SELECT + (SELECT count(*) FROM "Schrittergebnis" + WHERE strpos(coalesce("notiz", '') || coalesce("wert"::text, ''), ${passwort}) > 0) AS "ergebnisse", + (SELECT count(*) FROM "Kommentar" + WHERE strpos("rumpf", ${passwort}) > 0) AS "kommentare", + (SELECT count(*) FROM "Protokoll" + WHERE strpos("aktion" || coalesce("vorher"::text, '') || coalesce("nachher"::text, ''), ${passwort}) > 0) AS "protokoll" + `; + expect({ + ergebnisse: Number(spuren[0]?.ergebnisse), + kommentare: Number(spuren[0]?.kommentare), + protokoll: Number(spuren[0]?.protokoll), + }).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 }); + + // ---- Auch nicht auf dem Ausdruck --------------------------------------- + await page.goto(`/drucken/lauf/${laufId}`); + const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " "); + expect(blatt).not.toContain(passwort); + expect(blatt).toContain(PASSWORT_IM_DRUCK); + }); + + test("erzeugt bei jedem Aufbau ein anderes Passwort", async ({ page }) => { + /* + * Die Kehrseite der Zusage, und sie gehört geprüft: weil nichts gespeichert + * wird, gibt es auch keinen Weg, dasselbe Passwort noch einmal zu erfahren. + * Wer es nicht sofort weitergibt, hat es verloren — und der Techniker muss + * sich darauf verlassen können, dass das nicht heimlich anders ist. + */ + expect(laufPfad, "Der vorige Ablauf hat keinen Lauf angelegt.").not.toBe(""); + await anmelden(page); + + await page.goto(laufPfad); + const erstes = passwortAus(await befehlstext(page, "New-ADUser")); + await page.reload(); + const zweites = passwortAus(await befehlstext(page, "New-ADUser")); + + expect(zweites).not.toBe(erstes); + expect(zweites).toHaveLength(PASSWORT_LAENGE); + }); +}); + +test.afterAll(async () => { + const laeufe = await prisma.lauf.findMany({ + where: { titel: { startsWith: "ZZ E2E Onboarding" } }, + select: { id: true }, + }); + const kennungen = laeufe.map((lauf) => lauf.id); + if (kennungen.length > 0) { + await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } }); + await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } }); + // `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen. + await prisma.protokoll.deleteMany({ + where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] }, + }); + await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); + } + + // Die Namensregeln zurücklegen, wie sie waren – sie sind Betriebsdaten. + if (regelnVorher !== null) { + await prisma.einstellung.update({ + where: { id: "global" }, + data: { namensregeln: regelnVorher as Record }, + }); + } + await prisma.$disconnect(); +}); diff --git a/tests/e2e/rollen.spec.ts b/tests/e2e/rollen.spec.ts new file mode 100644 index 0000000..0e96556 --- /dev/null +++ b/tests/e2e/rollen.spec.ts @@ -0,0 +1,269 @@ +import { hash } from "@node-rs/argon2"; +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { geraetName, SEED_TAG, SEED_VORLAGE } from "./helfer"; + +/** + * Was ein `LESER` sieht und was der Server ihm verweigert (#018). + * + * Die Rechtematrix selbst prüft `tests/rollen.test.ts`, Rolle für Rolle gegen + * jedes Recht. Hier geht es um die zwei Richtungen, die eine Tabelle nicht + * prüfen kann: + * + * 1. **Die Oberfläche bietet nichts an, was der Server verbietet.** Ein Knopf, + * der zu einer Fehlermeldung führt, ist ein Versprechen, das nicht gehalten + * wird — und wer ihn sieht, hält sich für berechtigt. + * 2. **Der Server verbietet, was die Oberfläche nicht anbietet.** Das Ausblenden + * ist Bequemlichkeit, keine Absicherung: wer die Adresse kennt, ruft sie + * direkt auf. Geprüft wird deshalb jede Adresse, hinter der geschrieben wird. + * + * Der Ablauf legt seinen Leser selbst an — mit `@node-rs/argon2`, genau wie + * `prisma/seed-daten/grundbestand.ts` — und räumt ihn hinterher weg. Ein Konto + * mit Leserechten in einer Datenbank stehen zu lassen, die weiterbenutzt wird, + * wäre eine Hinterlassenschaft, die niemand erwartet. + */ + +const STEMPEL = Date.now(); +const LESER_EMAIL = `zz-e2e-leser-${STEMPEL}@firma.example`; +const LESER_NAME = `ZZ E2E Leser ${STEMPEL}`; +const LESER_PASSWORT = `ZZ-E2E-Leser-${STEMPEL}`; +const LAUF_TITEL = `ZZ E2E Rollen ${STEMPEL}`; + +let leserId = ""; +let laufId = ""; +let geraetId = ""; +let vorlageId = ""; + +test.beforeAll(async () => { + // Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext. + const leser = await prisma.benutzer.create({ + data: { + email: LESER_EMAIL, + name: LESER_NAME, + rolle: "LESER", + passwortHash: await hash(LESER_PASSWORT), + aktiv: true, + }, + select: { id: true }, + }); + leserId = leser.id; + + /* + * Ein Lauf, an dem sich der Schreibschutz zeigen lässt. Angelegt über Prisma + * und nicht über die Oberfläche: hier geht es um die Rechte und nicht um den + * Weg zum Lauf — den prüft `hauptablauf.spec.ts`. Der Kunde kommt vom Gerät, + * weil ein zusammengesetzter Fremdschlüssel keinen Lauf mit dem Gerät eines + * fremden Kunden zulässt. + */ + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { id: true, kundeId: true }, + }); + geraetId = geraet.id; + + const vorlage = await prisma.vorlage.findFirstOrThrow({ + where: { name: SEED_VORLAGE }, + select: { + id: true, + versionen: { + where: { status: "VEROEFFENTLICHT" }, + orderBy: { version: "desc" }, + take: 1, + select: { id: true }, + }, + }, + }); + vorlageId = vorlage.id; + const versionId = vorlage.versionen[0]?.id; + if (!versionId) { + throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`); + } + + const lauf = await prisma.lauf.create({ + data: { + vorlagenVersionId: versionId, + kundeId: geraet.kundeId, + geraetId: geraet.id, + titel: LAUF_TITEL, + status: "IN_ARBEIT", + // `Lauf_begonnen_mit_zeitpunkt` verlangt den Zeitstempel zum Status. + gestartetAm: new Date(), + }, + select: { id: true }, + }); + laufId = lauf.id; +}); + +async function alsLeserAnmelden(seite: Page): Promise { + await seite.goto("/anmelden"); + await seite.fill('input[name="email"]', LESER_EMAIL); + await seite.fill('input[name="passwort"]', LESER_PASSWORT); + await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); +} + +test.describe("Ein Leser", () => { + test("sieht auf Läufen, Vorlagen und Geräten keine Schreib-Knöpfe", async ({ page }) => { + test.slow(); + await alsLeserAnmelden(page); + + // ---- Läufe -------------------------------------------------------------- + await page.goto("/laeufe"); + await expect(page.getByRole("heading", { name: "Läufe" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Lauf starten" })).toHaveCount(0); + + // ---- Ein Lauf in Arbeit ------------------------------------------------- + await page.goto(`/laeufe/${laufId}`); + await expect(page.getByRole("heading", { name: LAUF_TITEL })).toBeVisible(); + for (const knopf of [ + "Lauf beginnen", + "Lauf abschließen", + "archivieren", + "erledigt", + "nicht zutreffend", + "fehlgeschlagen", + "Wert speichern", + "Kommentar hinzufügen", + "Abschnitt trifft nicht zu", + ]) { + await expect( + page.getByRole("button", { name: knopf, exact: true }), + `Ein Leser sieht auf dem Lauf den Knopf „${knopf}“.`, + ).toHaveCount(0); + } + /* + * Und zwar wirklich keinen Schreibweg: im Inhaltsbereich steht für einen Leser + * **kein einziges** Formular. Die Aufzählung oben prüft Beschriftungen, dieses + * eine prüft, dass keine übersehen wurde. Gezählt wird in `main`, nicht auf der + * ganzen Seite — das Abmelden-Formular der Hülle steht daneben und gehört jedem. + */ + await expect(page.locator("main form")).toHaveCount(0); + // Die Angaben selbst stehen trotzdem da – lesen darf er ja. + await expect(page.getByRole("progressbar")).toBeVisible(); + + // ---- Vorlagen ----------------------------------------------------------- + await page.goto("/vorlagen"); + await expect(page.getByRole("heading", { name: "Vorlagen" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Vorlage anlegen" })).toHaveCount(0); + await expect(page.getByText("Vorlage aus einer Datei einspielen")).toHaveCount(0); + + await page.goto(`/vorlagen/${vorlageId}`); + await expect(page.getByRole("button", { name: /bearbeiten/iu })).toHaveCount(0); + await expect(page.getByRole("button", { name: "archivieren" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Kopfdaten" })).toHaveCount(0); + + // ---- Geräte ------------------------------------------------------------- + await page.goto("/geraete"); + await expect(page.getByRole("heading", { name: "Geräte" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Gerät anlegen" })).toHaveCount(0); + await expect(page.getByText("Geräte aus einer CSV-Datei einlesen")).toHaveCount(0); + + await page.goto(`/geraete/${geraetId}`); + await expect(page.getByRole("heading", { name: new RegExp(geraetName()) })).toBeVisible(); + // „Vorlage zuweisen“ und die Schnellstart-Knöpfe hängen an `laeufeBearbeiten`. + await expect(page.getByRole("heading", { name: "Vorlage zuweisen" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Starten" })).toHaveCount(0); + + // ---- Mitarbeiter -------------------------------------------------------- + await page.goto("/mitarbeiter"); + await expect(page.getByText("Person von Hand anlegen")).toHaveCount(0); + + /* + * Und die Verwaltung steht gar nicht in der Navigation. Gesucht wird **in** + * `nav` und mit `exact`: auf der Personenliste steht ein „Systemadministrator“ + * als Berufsbezeichnung, und ein unscharfes „Admin“ traf ihn mit. + */ + if (test.info().project.name === "handy") await page.getByLabel("Menü").click(); + await expect(page.locator("nav").getByRole("link", { name: "Admin", exact: true })).toHaveCount(0); + }); + + test("wird abgewiesen, wenn er die Adresse direkt aufruft", async ({ page }) => { + test.slow(); + await alsLeserAnmelden(page); + + /* + * `/laeufe/starten` verlangt nur `lesen`, damit ein abgelaufenes Lesezeichen + * keine 500er-Seite ergibt — und schickt dann zurück zur Liste. Wer keine + * Läufe bearbeiten darf, soll die Seite nicht als vorhanden erleben. + */ + await page.goto("/laeufe/starten"); + await expect(page).toHaveURL(/\/laeufe$/u); + await expect(page.getByRole("button", { name: "Lauf anlegen" })).toHaveCount(0); + + /* + * Alles unter `verwaltung` weist der Server ab. Kein geworfener Fehler, + * sondern eine Weiterleitung: für jemanden, der ein altes Lesezeichen öffnet, + * wäre eine 500er-Seite die falsche Auskunft. + * + * Geprüft wird beides: dass er **nicht auf der Seite landet** und dass er + * **erfährt, warum**. Bis #018 schickte `sitzungErforderlich` ihn nach + * `/anmelden?fehler=keinrecht` — und die Anmeldeseite schickte einen bereits + * Angemeldeten sofort weiter auf `/`. Die Erklärung war damit unerreichbar, + * er stand ohne Hinweis auf der Übersicht und wusste nicht, ob er sich + * verklickt hatte oder ob etwas kaputt ist. Seitdem führt der Weg auf + * `/kein-zugriff`. + */ + for (const adresse of [ + "/admin", + "/admin/benutzer", + "/admin/abteilungen", + "/admin/verzeichnis", + "/tags", + // Der Etikettenbogen **ohne** Codes zeigt den ganzen Vorrat an noch nicht + // zugeordneten Tags – dieselbe Liste wie /tags, also dasselbe Recht. + "/drucken/etiketten", + ]) { + await page.goto(adresse); + const gelandet = new URL(page.url()).pathname; + expect(gelandet, `${adresse} hat einen Leser nicht abgewiesen.`).not.toBe(adresse); + expect(gelandet, `${adresse} weist ab, ohne zu sagen warum.`).toBe("/kein-zugriff"); + await expect(page.getByRole("heading", { name: "Dafür fehlt dir die Berechtigung" })).toBeVisible(); + // Und die Auskunft nennt die eigene Rolle, damit klar ist, woran es liegt. + // Im Absatz gesucht: „Leser“ steht auch in der Navigation und im Namen des + // Testkontos, und ein `getByText` ohne Rahmen trifft im Modus + // DIENSTLEISTUNG sechs Stellen. + await expect(page.getByRole("main").getByText("mit der Rolle")).toContainText("Leser"); + } + }); + + test("darf lesen, was zum Lesen da ist", async ({ page }) => { + /* + * Die Gegenprobe, und sie gehört dazu: würde hier etwas abgewiesen, wäre die + * Prüfung oben nicht der Nachweis eines Rechts, sondern der eines kaputten + * Kontos. Der Zustands-Endpunkt steht mit dabei, weil ein Route Handler die + * Hülle nicht erbt und sein Recht selbst prüfen muss (CLAUDE.md, „Rechte“). + */ + await alsLeserAnmelden(page); + + for (const adresse of ["/", "/laeufe", "/vorlagen", "/mitarbeiter", "/geraete", "/archiv", "/scannen"]) { + const antwort = await page.goto(adresse); + expect(antwort?.status(), `${adresse} antwortet einem Leser nicht mit 200.`).toBe(200); + expect(new URL(page.url()).pathname, `${adresse} hat einen Leser weitergeleitet.`).toBe(adresse); + } + + const zustand = await page.request.get(`/api/laeufe/${laufId}/zustand`); + expect(zustand.status()).toBe(200); + // Kein Befehl in der Antwort: sonst stünde dort ein Passwort, das die Seite + // bewusst nicht von selbst neu baut. + expect(await zustand.text()).not.toContain("Rename-Computer"); + }); +}); + +test.afterAll(async () => { + if (laufId) { + await prisma.kommentar.deleteMany({ where: { laufId } }); + await prisma.schrittergebnis.deleteMany({ where: { laufId } }); + await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } }); + await prisma.lauf.deleteMany({ where: { id: laufId } }); + } + if (leserId) { + /* + * `Protokoll.benutzerId` steht auf `Restrict`. Ein Leser schreibt nichts, was + * protokolliert wird — aber „sollte nichts geschrieben haben“ ist kein Grund, + * das Aufräumen an einem Fremdschlüssel scheitern zu lassen. + */ + await prisma.protokoll.deleteMany({ where: { benutzerId: leserId } }); + await prisma.benutzer.deleteMany({ where: { id: leserId } }); + } + await prisma.$disconnect(); +}); diff --git a/tests/e2e/scannen.spec.ts b/tests/e2e/scannen.spec.ts index e10c9c9..23ca28d 100644 --- a/tests/e2e/scannen.spec.ts +++ b/tests/e2e/scannen.spec.ts @@ -1,5 +1,6 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import "dotenv/config"; +import { anmelden, SEED_TAG } from "./helfer"; /** * Der Einstieg ueber den Aufkleber (#014). @@ -11,18 +12,12 @@ import "dotenv/config"; * bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein * Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser * keine Kamera freigibt. + * + * Der Code kommt aus `SEED_TAG` und nicht abgetippt aus dem Kopf: dass es ihn in + * dieser Datenbank gibt, prueft `tests/e2e/global-setup.ts` vor dem ersten + * Ablauf (#018). */ -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} - test.describe("Scannen", () => { test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => { await anmelden(page); @@ -36,10 +31,10 @@ test.describe("Scannen", () => { await page.goto("/scannen"); // Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an. - await page.getByLabel("Code eintippen").fill("lz-7k3f9q"); + await page.getByLabel("Code eintippen").fill(SEED_TAG.toLowerCase()); await page.getByRole("button", { name: "Öffnen" }).click(); - await page.waitForURL("**/t/LZ-7K3F9Q"); + await page.waitForURL(`**/t/${SEED_TAG}`); }); test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => { diff --git a/tests/e2e/stammdaten.spec.ts b/tests/e2e/stammdaten.spec.ts index 4dd28c5..0143cdf 100644 --- a/tests/e2e/stammdaten.spec.ts +++ b/tests/e2e/stammdaten.spec.ts @@ -1,6 +1,7 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; +import { anmelden, kundeWaehlen } from "./helfer"; /** * Personen und Abteilungen durch die Oberflaeche (#011). @@ -9,7 +10,10 @@ import { prisma } from "@/lib/db"; * wirklich in der Datenbank steht: durch Neuladen. Ein Screenshot sagt darueber * nichts (siehe CLAUDE.md, „Oberflaeche pruefen“). * - * Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`). + * Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein + * **gebauter** Stand antwortet, der Seed-Bestand steht, und der Server liest + * dieselbe Datenbank wie dieser Lauf. Alles, was die Ablaeufe hier darueber + * hinaus brauchen, legen sie selbst an. * * Aufgeraeumt wird am Ende ueber die Datenbank und nicht ueber die Oberflaeche: * die Anwendung kennt bewusst keinen Loeschweg fuer Personen und Abteilungen @@ -21,20 +25,14 @@ import { prisma } from "@/lib/db"; const STEMPEL = Date.now(); -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} +/** Nachnamen dieses Durchgangs. Beide Praefixe gehoeren dieser Datei allein. */ +const PRAEFIX_HAND = "ZZPruef"; +const PRAEFIX_QUELLE = "ZZQuelle"; test.describe("Personen", () => { test("legt an, findet über die Suche und deaktiviert", async ({ page }) => { test.slow(); - const nachname = `ZZPruef${STEMPEL}`; + const nachname = `${PRAEFIX_HAND}${STEMPEL}`; await anmelden(page); await page.goto("/mitarbeiter"); @@ -43,6 +41,7 @@ test.describe("Personen", () => { await page.getByLabel("Vorname").fill("Testa"); await page.getByLabel("Nachname").fill(nachname); await page.getByLabel("Mail-Adresse (optional)").fill(`testa.${STEMPEL}@firma.de`); + await kundeWaehlen(page); await page.getByRole("button", { name: "Person anlegen" }).click(); await expect(page.getByText(`„Testa ${nachname}“ wurde angelegt.`)).toBeVisible(); @@ -65,12 +64,51 @@ test.describe("Personen", () => { }); test("sperrt die Stammdaten einer Person aus dem Verzeichnis", async ({ page }) => { - // Vorausgesetzt wird die Beispielperson aus `pnpm beispiel:stammdaten`. + /* + * Die Person legt dieser Ablauf selbst an. + * + * Vorher hing der Test an „Katrin Winter“ aus `pnpm beispiel:stammdaten` und + * schaltete sich mit `test.skip` ab, wenn sie fehlte — gegen eine frisch + * eingerichtete Installation also immer. Ein Test, der sich stillschweigend + * selbst abschaltet, ist keiner (#018). + * + * Gesperrt wird an der **Herkunft**, nicht an der Quelle: `ABGLEICH` allein + * entscheidet. Die Beispielquelle aus dem Seed kommt trotzdem dran, damit die + * Zeile aussieht wie eine echte — sie gibt es nur im Modus INTERN, deshalb + * bleibt `quelleId` ohne sie leer, statt den Ablauf rot zu faerben. + */ test.slow(); + const nachname = `${PRAEFIX_QUELLE}${STEMPEL}`; + const quelle = await prisma.verzeichnisquelle.findFirst({ + where: { name: "Firmen-AD (Beispiel)" }, + select: { id: true }, + }); + const kunde = await prisma.kunde.findFirstOrThrow({ + where: { istEigeneOrganisation: true }, + select: { id: true }, + }); + await prisma.mitarbeiter.create({ + data: { + kundeId: kunde.id, + quelleId: quelle?.id ?? null, + externeId: quelle ? `e2e-${STEMPEL}` : null, + vorname: "Katrin", + nachname, + anzeigename: `Katrin ${nachname}`, + mail: `k.${nachname.toLowerCase()}@firma.example`, + upn: `k.${nachname.toLowerCase()}@firma.example`, + sam: `k${nachname.toLowerCase()}`, + abteilung: "Buchhaltung", + titel: "Sachbearbeiterin", + herkunft: "ABGLEICH", + aktiv: true, + }, + }); + await anmelden(page); - await page.goto("/mitarbeiter?suche=kwinter"); - const treffer = page.getByRole("link", { name: /Katrin Winter/ }); - test.skip((await treffer.count()) === 0, "Beispiel-Stammdaten fehlen (pnpm beispiel:stammdaten)."); + await page.goto(`/mitarbeiter?suche=${nachname}`); + const treffer = page.getByRole("link", { name: new RegExp(nachname) }); + await expect(treffer).toHaveCount(1); await treffer.click(); await page.waitForURL(/\/mitarbeiter\/[^/]+$/); @@ -94,6 +132,7 @@ test.describe("Abteilungen", () => { await formular.getByLabel("Name").fill(name); await formular.getByLabel("Ziel-OU (optional)").fill("OU=Test,DC=firma,DC=de"); await formular.getByLabel("Standardgruppen, mit Komma getrennt").fill("G-Test, G-Alle"); + await kundeWaehlen(page); await formular.getByRole("button", { name: "Abteilung anlegen" }).click(); await expect(page.getByText(`„${name}“ wurde angelegt.`)).toBeVisible(); @@ -105,6 +144,7 @@ test.describe("Abteilungen", () => { // Denselben Namen ein zweites Mal – mit lesbarer Meldung, nicht mit einem // Datenbankfehler. await formular.getByLabel("Name").fill(name); + await kundeWaehlen(page); await formular.getByRole("button", { name: "Abteilung anlegen" }).click(); await expect(page.getByText(/gibt es schon/)).toBeVisible(); @@ -116,7 +156,7 @@ test.describe("Abteilungen", () => { test.afterAll(async () => { const personen = await prisma.mitarbeiter.findMany({ - where: { nachname: { startsWith: "ZZPruef" } }, + where: { OR: [{ nachname: { startsWith: PRAEFIX_HAND } }, { nachname: { startsWith: PRAEFIX_QUELLE } }] }, select: { id: true }, }); const abteilungen = await prisma.abteilung.findMany({ diff --git a/tests/e2e/uebersicht-druck.spec.ts b/tests/e2e/uebersicht-druck.spec.ts index c869600..8e8b5a6 100644 --- a/tests/e2e/uebersicht-druck.spec.ts +++ b/tests/e2e/uebersicht-druck.spec.ts @@ -1,6 +1,7 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; +import { anmelden } from "./helfer"; /** * Uebersicht und Druckansicht am gebauten Stand (#015). @@ -9,17 +10,22 @@ import { prisma } from "@/lib/db"; * Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt * erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts` * und `tests/druckansicht.test.ts`. + * + * Voraussetzungen prueft `tests/e2e/global-setup.ts`. Jeden Lauf, den diese + * Ablaeufe brauchen, legen sie selbst an und raeumen ihn hinterher weg. Vorher + * holten sie sich mit `findFirstOrThrow` irgendeinen vorhandenen Lauf und + * erwarteten, dass „Zuletzt bearbeitet“ gefuellt ist — gegen eine frische + * Datenbank scheiterte beides, das eine mit „No record was found“, das andere + * mit einem Zeitablauf auf einen Verweis, den es nicht gab (#018). */ -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} +const STEMPEL = Date.now(); + +/** Fortlaufend, damit die Laeufe eines Durchgangs sich nicht verwechseln. */ +let laufende = 0; + +/** Name und Titel tragen diesen Praefix – nur daran raeumt der Nachlauf auf. */ +const PRAEFIX = "ZZ E2E Druck"; /** * Ein Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt. @@ -27,12 +33,16 @@ async function anmelden(seite: Page) { * Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg * zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv. */ -async function laufMitPasswortschritt(): Promise { - const stempel = Date.now(); - const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } }); - const vorlage = await prisma.vorlage.create({ - data: { name: `ZZ E2E Druck ${stempel}`, kategorie: "ZZ Test" }, +async function laufMitPasswortschritt(): Promise<{ id: string; titel: string }> { + laufende += 1; + const marke = `${PRAEFIX} ${STEMPEL}-${laufende}`; + // Ueber `istEigeneOrganisation` und nicht ueber den ersten Treffer: im Modus + // DIENSTLEISTUNG waere das ein beliebiger Demo-Kunde. + const kunde = await prisma.kunde.findFirstOrThrow({ + where: { istEigeneOrganisation: true }, + select: { id: true }, }); + const vorlage = await prisma.vorlage.create({ data: { name: marke, kategorie: "ZZ Test" } }); const version = await prisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); @@ -53,9 +63,9 @@ async function laufMitPasswortschritt(): Promise { }); const lauf = await prisma.lauf.create({ - data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: `ZZ E2E Druck ${stempel}` }, + data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: marke }, }); - return lauf.id; + return { id: lauf.id, titel: marke }; } test.describe("Übersicht", () => { @@ -66,12 +76,19 @@ test.describe("Übersicht", () => { }); test("führt von einer Zeile in den Lauf", async ({ page }) => { + /* + * Der Ablauf bringt die Zeile mit, auf die er klickt. „Zuletzt bearbeitet“ + * sortiert nach `geaendertAm` absteigend, der eben angelegte Lauf steht also + * oben — und weil der Klick auf **seinen** Titel geht, prueft der Test einen + * Weg und nicht die Datenlage. + */ + const lauf = await laufMitPasswortschritt(); await anmelden(page); const zuletzt = page .locator("section") .filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) }); - await zuletzt.getByRole("link").first().click(); - await page.waitForURL(/\/laeufe\/[^/]+$/); + await zuletzt.getByRole("link", { name: new RegExp(lauf.titel) }).click(); + await page.waitForURL(`**/laeufe/${lauf.id}`); }); test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { @@ -88,8 +105,10 @@ test.describe("Übersicht", () => { test.describe("Druckansicht", () => { test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => { test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); + // Der eigene Lauf statt `findFirstOrThrow`: gegen eine frische Datenbank gab + // es keinen, und die Meldung sprach von „No record was found“ (#018). + const lauf = await laufMitPasswortschritt(); await anmelden(page); - const lauf = await prisma.lauf.findFirstOrThrow({ select: { id: true } }); await page.goto(`/drucken/lauf/${lauf.id}`); // Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt. @@ -123,9 +142,9 @@ test.describe("Druckansicht", () => { * dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund * zu #015). */ - const laufId = await laufMitPasswortschritt(); + const lauf = await laufMitPasswortschritt(); await anmelden(page); - await page.goto(`/laeufe/${laufId}`); + await page.goto(`/laeufe/${lauf.id}`); await page.getByRole("link", { name: "Drucken" }).click(); await page.waitForURL(/\/drucken\/lauf\//); @@ -146,7 +165,7 @@ test.describe("Druckansicht", () => { test.afterAll(async () => { const laeufe = await prisma.lauf.findMany({ - where: { titel: { startsWith: "ZZ E2E Druck" } }, + where: { titel: { startsWith: PRAEFIX } }, select: { id: true }, }); const kennungen = laeufe.map((lauf) => lauf.id); @@ -157,6 +176,7 @@ test.afterAll(async () => { }); await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); } - await prisma.vorlage.deleteMany({ where: { name: { startsWith: "ZZ E2E Druck" } } }); + // Die Vorlagen zuletzt: an ihnen hingen gerade noch die Laeufe. + await prisma.vorlage.deleteMany({ where: { name: { startsWith: PRAEFIX } } }); await prisma.$disconnect(); }); diff --git a/tests/e2e/verzeichnis.spec.ts b/tests/e2e/verzeichnis.spec.ts index 150cd52..4534c1c 100644 --- a/tests/e2e/verzeichnis.spec.ts +++ b/tests/e2e/verzeichnis.spec.ts @@ -1,6 +1,10 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import "dotenv/config"; +import { verschluesseln } from "@/lib/geheimnisse"; +import { AD_ZUORDNUNG } from "@/lib/verzeichnis"; import { prisma } from "@/lib/db"; +import { STANDARD_FILTER } from "@/server/ldap/zuordnung"; +import { anmelden, betriebsmodus } from "./helfer"; /** * Admin → Verzeichnis durch die Oberflaeche (#016). @@ -10,73 +14,108 @@ import { prisma } from "@/lib/db"; * dass eine gute wirklich in der Datenbank landet. Was der Abgleich **tut**, * steht in `tests/abgleich.test.ts` gegen einen echten Server. * - * Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`). - * Im Entwicklungsmodus haengt React sich hier nicht an, und dann tut kein Knopf - * etwas (siehe CLAUDE.md, „Oberflaeche pruefen“). + * Voraussetzungen prueft `tests/e2e/global-setup.ts`. Die Quelle, an der hier + * gearbeitet wird, legt der Ablauf selbst an und loescht sie hinterher: vorher + * nahm er die erste vorhandene, schaltete sich mit `test.skip` ab, wenn es keine + * gab, und schrieb die Attributzuordnung einer fremden — im Zweifel produktiven + * — Quelle um (#018). Angefasst wird nur der Kasten mit dem eigenen Namen. + * + * Die Seite gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG leitet + * sie um, und dann schlaegt dieser Ablauf fehl. Das ist die richtige Antwort: + * die Ablaeufe pruefen den Modus aus der `.env`, und der ist INTERN. */ -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); +/* + * Das Verzeichnis gibt es **nur** im Modus INTERN (#008): im Modus + * DIENSTLEISTUNG leitet die Seite auf `/admin` um, und es gibt nichts zu + * pruefen. Uebersprungen mit Grund — ein Ablauf, der dort rot wuerde, sagte + * etwas ueber den Modus und nichts ueber das Verzeichnis. + */ +test.skip(betriebsmodus() === "DIENSTLEISTUNG", "Das Verzeichnis gibt es nur im Modus INTERN (#008)."); + +const STEMPEL = Date.now(); +const QUELLEN_NAME = `ZZ E2E Verzeichnis ${STEMPEL}`; + +/** Der Kasten dieser Quelle – alles andere auf der Seite bleibt unberührt. */ +function kasten(seite: Page): Locator { + return seite.locator("li").filter({ has: seite.getByRole("heading", { name: QUELLEN_NAME }) }); } +test.beforeAll(async () => { + const kunde = await prisma.kunde.findFirstOrThrow({ + where: { istEigeneOrganisation: true }, + select: { id: true }, + }); + await prisma.verzeichnisquelle.create({ + data: { + kundeId: kunde.id, + name: QUELLEN_NAME, + url: "ldaps://dc-e2e.firma.local:636", + bindDn: "CN=e2e,OU=Dienstkonten,DC=firma,DC=local", + // Der CHECK in der Datenbank verlangt das Formatpraefix; verschluesselt wird + // deshalb wirklich und nicht mit einer Zeichenkette getan. + bindPasswortVerschluesselt: verschluesseln(`e2e-${STEMPEL}`), + basisDn: "OU=Benutzer,DC=firma,DC=local", + filter: STANDARD_FILTER, + attributZuordnung: { ...AD_ZUORDNUNG }, + abgleichIntervallMinuten: 60, + // Kein Zeitplan: eine aktive Quelle auf einen Rechner, den es nicht gibt, + // liesse den Abgleich im Hintergrund in Fehler laufen. + aktiv: false, + }, + }); +}); + test.describe("Verzeichnis", () => { test("macht die Attributzuordnung bedienbar und prüft sie", async ({ page }) => { test.slow(); - const quelle = await prisma.verzeichnisquelle.findFirst({ orderBy: { name: "asc" } }); - test.skip(quelle === null, "Keine Verzeichnisquelle im Bestand."); - if (!quelle) return; - const vorher = quelle.attributZuordnung; - await anmelden(page); await page.goto("/admin/verzeichnis"); - await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click(); - await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click(); + const meine = kasten(page); + await expect(meine).toHaveCount(1); + await meine.locator("summary", { hasText: "Einstellungen bearbeiten" }).click(); + await meine.locator("summary", { hasText: "Attributzuordnung anpassen" }).click(); - const kennung = page.locator('input[name="attr.externeId"]').first(); + const kennung = meine.locator('input[name="attr.externeId"]'); await expect(kennung).toBeVisible(); await expect(kennung).toHaveValue("objectGUID"); // Eine Klammer darf nicht in eine LDAP-Anfrage wandern. - const titel = page.locator('input[name="attr.titel"]').first(); + const titel = meine.locator('input[name="attr.titel"]'); await titel.fill("(objectClass=*)"); - await page.getByRole("button", { name: "Speichern" }).first().click(); - await expect(page.locator('[role="alert"]').first()).toContainText("kein Attributname"); + await meine.getByRole("button", { name: "Speichern" }).click(); + await expect(meine.locator('[role="alert"]').first()).toContainText("kein Attributname"); // Und eine gute Eingabe steht danach wirklich in der Datenbank. await titel.fill("personalTitle"); - await page.getByRole("button", { name: "Speichern" }).first().click(); - await expect(page.locator('[role="status"]').last()).toContainText("gespeichert"); + await meine.getByRole("button", { name: "Speichern" }).click(); + await expect(meine.locator('[role="status"]').last()).toContainText("gespeichert"); - const nachher = await prisma.verzeichnisquelle.findUniqueOrThrow({ where: { id: quelle.id } }); + const nachher = await prisma.verzeichnisquelle.findFirstOrThrow({ where: { name: QUELLEN_NAME } }); expect((nachher.attributZuordnung as Record)["titel"]).toBe("personalTitle"); // Neu geladen steht sie auch im Formular – sonst waere sie nur gespeichert, // aber nicht bearbeitbar. await page.reload(); - await page.locator("summary", { hasText: "Einstellungen bearbeiten" }).first().click(); - await page.locator("summary", { hasText: "Attributzuordnung anpassen" }).first().click(); - await expect(page.locator('input[name="attr.titel"]').first()).toHaveValue("personalTitle"); - - await prisma.verzeichnisquelle.update({ - where: { id: quelle.id }, - data: { attributZuordnung: vorher === null ? {} : vorher }, - }); + const nochmal = kasten(page); + await nochmal.locator("summary", { hasText: "Einstellungen bearbeiten" }).click(); + await nochmal.locator("summary", { hasText: "Attributzuordnung anpassen" }).click(); + await expect(nochmal.locator('input[name="attr.titel"]')).toHaveValue("personalTitle"); }); test("zeigt das Abgleich-Log und trägt am Handy ohne horizontales Scrollen", async ({ page }) => { await anmelden(page); await page.goto("/admin/verzeichnis"); - const log = page.locator("summary", { hasText: "Abgleich-Log" }).first(); - if ((await log.count()) > 0) { - await log.click(); - await expect(page.locator("details", { has: log })).toBeVisible(); - } + const meine = kasten(page); + /* + * Ohne Bedingung: die eigene Quelle steht da, also steht auch ihr Log da. + * Vorher hing der Klick an einem `if (count > 0)` — fehlte der Aufklapper, + * bestand der Test, ohne etwas geprueft zu haben. + */ + const log = meine.locator("summary", { hasText: "Abgleich-Log" }); + await log.click(); + // Noch nie abgeglichen – und genau das sagt die Seite, statt leer zu bleiben. + await expect(meine.getByText(/Noch kein Lauf/)).toBeVisible(); const masse = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, @@ -85,3 +124,18 @@ test.describe("Verzeichnis", () => { expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); }); }); + +test.afterAll(async () => { + // An dieser Quelle haengt keine Person: sie war nie aktiv und hat nie + // abgeglichen. Haengte eine, verweigerte der Fremdschluessel das Loeschen. + const quellen = await prisma.verzeichnisquelle.findMany({ + where: { name: { startsWith: "ZZ E2E Verzeichnis" } }, + select: { id: true }, + }); + const kennungen = quellen.map((quelle) => quelle.id); + if (kennungen.length > 0) { + await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } }); + await prisma.verzeichnisquelle.deleteMany({ where: { id: { in: kennungen } } }); + } + await prisma.$disconnect(); +}); diff --git a/tests/e2e/vorlagen-editor.spec.ts b/tests/e2e/vorlagen-editor.spec.ts index ce23b53..374dba4 100644 --- a/tests/e2e/vorlagen-editor.spec.ts +++ b/tests/e2e/vorlagen-editor.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; +import { prisma } from "@/lib/db"; +import { anmelden } from "./helfer"; /** * Der Vorlagen-Editor durch die Oberflaeche (#010). @@ -10,31 +12,20 @@ import "dotenv/config"; * angehaengtem React gelingt — anlegen, tippen, sortieren, speichern — und * anschliessend, ob es wirklich in der Datenbank steht: durch Neuladen. * - * Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und - * `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev` - * schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md, - * „Oberflaeche pruefen“. + * Voraussetzungen prueft `tests/e2e/global-setup.ts` vor dem ersten Ablauf: ein + * **gebauter** Stand antwortet, der Seed-Bestand steht, und der Server liest + * dieselbe Datenbank wie dieser Lauf. Gegen `pnpm dev` schlaegt das hier fehl, + * und das ist richtig so — siehe CLAUDE.md, „Oberflaeche pruefen“. * - * Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht - * loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch - * nicht ueber einen Kaskadenloeschvorgang — der Trigger aus #009 verweigert das - * ausdruecklich, und genau das ist die Zusage, an der das ganze Archiv haengt. - * Wer diese Tests oft laufen laesst, braucht deshalb eine Wegwerf-Datenbank - * (kommt mit #018). + * Der Ablauf legt seine eigene Vorlage an, **archiviert** sie ueber die + * Oberflaeche — das ist der Weg, den die Anwendung kennt, geloescht wird dort + * nichts — und raeumt sie hinterher ueber die Datenbank weg. Ohne diesen + * Nachlauf blieb nach jedem Durchgang eine archivierte „ZZ E2E Editor“-Vorlage + * stehen; in der Entwicklungsdatenbank lagen davon zuletzt ueber hundert (#018). */ const NAME = `ZZ E2E Editor ${Date.now()}`; -async function anmelden(seite: Page) { - const email = process.env["SEED_ADMIN_EMAIL"]; - const passwort = process.env["SEED_ADMIN_PASSWORD"]; - if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen."); - await seite.goto("/anmelden"); - await seite.fill('input[name="email"]', email); - await seite.fill('input[name="passwort"]', passwort); - await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]); -} - /** Legt die Vorlage an und fuehrt in ihren Entwurf. Gibt die Adresse des Editors zurueck. */ async function entwurfAnlegen(seite: Page, name: string): Promise { await seite.goto("/vorlagen"); @@ -176,3 +167,33 @@ test.describe("Vorlagen-Editor", () => { await aufraeumen(page, name); }); }); + +test.afterAll(async () => { + /* + * Aufgeraeumt wird ueber die Datenbank, weil die Anwendung fuer eine Vorlage + * bewusst keinen Loeschweg kennt — archivieren ist das Gegenteil von + * Aufraeumen. Die veroeffentlichte Version steht dem nicht im Weg: der Trigger + * aus #009 schuetzt Abschnitte und Schritte einer veroeffentlichten Version, + * und beim Kaskadenloeschen ist die Version bereits fort, wenn sie fallen. + * + * An diesen Vorlagen haengt kein Lauf — dieser Ablauf startet keinen. Haengte + * einer, verweigerte der Fremdschluessel das Loeschen, und das waere richtig. + */ + const vorlagen = await prisma.vorlage.findMany({ + where: { name: { startsWith: "ZZ E2E Editor" } }, + select: { id: true, versionen: { select: { id: true } } }, + }); + if (vorlagen.length === 0) { + await prisma.$disconnect(); + return; + } + const kennungen = vorlagen.flatMap((vorlage) => [ + vorlage.id, + ...vorlage.versionen.map((version) => version.id), + ]); + // Das Protokoll haelt Anlegen und Veroeffentlichen fest; `entitaetId` ist kein + // Fremdschluessel, die Zeilen bleiben also sonst als Waisen stehen. + await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } }); + await prisma.vorlage.deleteMany({ where: { id: { in: vorlagen.map((zeile) => zeile.id) } } }); + await prisma.$disconnect(); +});