Files
werkjournal/docs/plans/auth-flow-refresh.md
mhoennigandClaude Opus 5 839bff9951
werkator/jvm build succeeded after 10:25
Open the invitation link as a fresh document in WJ-040 (0.1.107)
The trunk run failed in Firefox because sign-out already leaves the
browser on /login, so adding only a fragment stays in the same document
and submits the form rendered before the session changed — the app then
correctly shows its expired-form recovery page. A recipient opens the
link as a new document, so the step leaves the page first. Reloading
would not do: login-invitation.js strips the fragment from history at
once. The rough edge itself is recorded in the auth-flow plan, which
owns that workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:22:51 +02:00

19 KiB
Raw Permalink Blame History

Spezifikation: Auth-Flow-Refresh — Einladung und Anmeldung verständlich führen

Status: geplant, nicht begonnen. Werkbaum-Knoten #auth-flow-refresh unter #identity. Entwürfe: mockups/auth-flow.html (im Browser öffnen; Umschalter für Hell/Dunkel oben rechts). Voraussetzung: ui-refresh.md ist umgesetzt (Branch ui-refresh gemerged oder als Basis ausgecheckt). Die dort definierten Tokens, Karten, Buttons und Sheets werden hier verwendet.

1. Ziel

Zwei Abläufe sollen ohne Erklärung verständlich sein und auf dem Smartphone in wenigen Schritten gelingen:

  • Admin lädt jemanden ein: Adresse und Sprache eingeben, E-Mail abschicken, Status sehen. Der Link ist Nebensache und nur dann sichtbar, wenn der Versand nicht möglich war oder der Admin ihn ausdrücklich möchte.
  • Nutzer meldet sich an: Adresse eingeben, Code aus der E-Mail eintippen (oder Link in der E-Mail antippen), fertig. Wer eingeladen wurde, sieht auf einer eigenen Startansicht, dass die Einladung erkannt wurde und was als Nächstes passiert. Einrichtung/Wiederherstellung ist vom Normalweg getrennt.

Sicherheitsverhalten, Tokens, Fristen und Ratenbegrenzung bleiben unverändert.

2. Ausgangslage

  • Login-Seite: serverseitig gerendertes HTML in identity/LoginController.kt mit static/login.css und static/login-invitation.js. Ein Schritt „Code anfordern“ mit E-Mail-Feld und aufklappbarem „Einrichtung oder Einladungscode“ (Auswahlfeld kind mit Passwortfeld credential), Link „Ich habe bereits einen Code“, zweiter Schritt „Code eingeben“ (E-Mail erneut, Code), Gastzugang als drittes Formular, verstecktes Formular link-login für #token=-Links.
  • Einladungslinks: /login#invitation=<token>&email=<adresse>. Das Skript trägt Token und Adresse ein, zeigt den Hinweis „Du bist eingeladen…“ und versteckt die Aufklappoptionen. Anmeldelinks aus E-Mails: /login#token=<token>, automatischer Submit.
  • Codes: 10 Zeichen aus 23456789ABCDEFGHJKLMNPQRSTUVWXYZ, als XXXXX-XXXXX versendet; Eingabe ignoriert Bindestriche, Leerzeichen und Groß-/Kleinschreibung. Code und Link zwei Stunden gültig, einmalig. Sessionkontext für Einladung/Einrichtung zwei Stunden (login.access).
  • Ratenbegrenzung (EmailRequestLimits): 5 Anfragen je Adresse in 15 min, 20 je Client in 15 min, 100 global je Stunde. Gleiche öffentliche Antwort für unbekannte, gesperrte und gültige Adressen.
  • Admin-Seite identity/AdminView.kt (Route admin): Dialog „Neue Einladung“ mit E-Mail, Sprache, dann Link-Feld, „Link kopieren“, „E-Mail senden“; Liste der Konten (Sperren/Freigeben) und Einladungen (Zeitstempel als Instant-Text, Zustand, Widerrufen). Einladungen speichern keine Empfängeradresse.
  • Mails: mail/LoginMail.kt erzeugt Einladungs-, Code- und Löschbestätigungsmails in Deutsch/Englisch als Text.
  • Tests: IdentityCucumberTest (WJ-010 bis WJ-019, WJ-062), Browser-Szenarien in Chromium und Firefox, Controller-Tests für Login und Einladung (src/test/kotlin/.../identity).
  • Wahrgenommene Probleme: der Normalweg (bestehendes Konto) und die Sonderwege (Einladung, Einrichtung) teilen sich ein Formular mit Auswahlfeld und Passwortfeld; die Einladung wird nur durch einen Textabsatz erkennbar; der Code-Schritt verlangt die Adresse erneut; Fehlermeldung „Anmeldung nicht möglich“ erklärt nicht, was zu tun ist; im Admin-Dialog verändert derselbe Dialog nach „Erstellen“ seine Bedeutung; die Einladungsliste zeigt nicht, wer eingeladen wurde.

2a. Beobachtete Rauheit (2026-09-12)

Ein CI-Lauf auf main zeigte in Firefox: Wer angemeldet ist, sich abmeldet und anschließend im selben Tab einen Einladungslink /login#invitation=… öffnet, bleibt im selben Dokument — der Browser navigiert nur den Fragmentteil. Das Formular stammt damit noch aus dem Zustand vor der Abmeldung, und das Absenden endet auf der Seite „Please reopen the sign-in page". Die Anwendung fängt den Fall sauber ab, führend ist er nicht. Dieses Paket soll den Einstieg über einen Einladungslink auch aus einer bereits geöffneten Anmeldeseite heraus verlässlich machen, statt den Nutzer auf die Wiederholungsseite zu schicken. Der Abnahmeschritt verlässt die Seite seit 0.1.107 zuerst und lädt den Link dann als neues Dokument, so wie ein Empfänger ihn aus der E-Mail öffnet; ein Reload scheidet aus, weil login-invitation.js das Fragment sofort aus der History entfernt.

3. Nicht-Ziele

  • Keine neuen Anmeldeverfahren (kein Passwort, kein OIDC, kein Passkey).
  • Keine Änderung an Token-Formaten, Fristen, Ratenbegrenzung, Cookie-Handling, CSRF oder Session-Invalidierung.
  • Keine E-Mail-Adressprüfung über Bestätigungsketten hinaus; Adressbesitz wird weiterhin nur über den Code/Link nachgewiesen.
  • Keine Selbstregistrierung, keine Änderung der Gastregeln.

4. Verbindliche Vorgaben

4.1 Arbeitsweise

  • Branch auth-flow-refresh von ui-refresh (falls bereits in main, von main). #auth-flow-refresh im Werkbaum auf [~], Micro-Version vor jedem Push erhöhen.
  • Ergebnisdokument docs/implementation/auth-flow-refresh.md mit Entscheidungen, geänderten Szenarien und Abschnitt „Verification on “. Das bestehende invitation-login-ux.md bleibt als Historie und bekommt oben einen Verweis auf das neue Dokument.
  • Kleine Commits in der Reihenfolge aus Abschnitt 7; jeder Commit lässt ./gradlew test -Pproduction=true grün.
  • Keine echten E-Mails an fremde Adressen während der Umsetzung. Prüfungen laufen mit dem Test-LoginMail, das Nachrichten aufzeichnet.
  • Bei Fragen, die Sicherheitsinvarianten berühren, anhalten. Formulierungen und Reihenfolgen selbst entscheiden und dokumentieren.

4.2 Sicherheitsinvarianten (dürfen nicht verletzt werden)

  1. Eine Anfrage liefert nach außen dieselbe Antwort, ob die Adresse bekannt, gesperrt, unbekannt oder ratenbegrenzt ist (Weiterleitung auf den Code-Schritt mit Hinweis). Keine Adressaufzählung über Texte, Zeiten oder Statuscodes.
  2. Einladungs- und Anmeldetokens stehen nur im URL-Fragment und werden vor jeder weiteren Verarbeitung aus der History entfernt. Sie erreichen nie Access-Logs, Referer oder Query-Strings.
  3. Einladung: einmalig, sieben Tage, widerrufbar; wer den unbenutzten Link besitzt, kann registrieren (keine Empfängerbindung). Code/Link: zwei Stunden, gemeinsam einmalig.
  4. Ratenbegrenzung und Sessionkontext (zwei Stunden, Adresswechsel verwirft Kontext) unverändert; ein „Erneut senden“ ist eine normale Anfrage und zählt gegen die Limits.
  5. Nach erfolgreicher Anmeldung wird die HTTP-Session invalidiert und das Gerätecookie ausgestellt; Kontext- und Adressattribute überleben das nicht.
  6. Einrichtung/Wiederherstellung funktioniert weiterhin mit kind=bootstrap und Credential, auch wenn sie in der Oberfläche einen eigenen Einstieg bekommt.
  7. Der Admin sieht keine Tracking-Daten anderer; die Empfängeradresse einer Einladung ist die einzige neue Information, die er sieht, und er hat sie selbst eingegeben.
  8. CSRF-Token in allen Formularen; keine GET-Endpunkte mit Nebenwirkung.

4.3 Datenmodell

Neue Spalte recipient VARCHAR(254) (nullable) in account_invitation, gesetzt beim Erstellen über die Admin-Seite. Liquibase-SQL-Changeset im bestehenden db/changelog.sql, rein additiv, bestehende Zeilen bleiben NULL. Regeln aus docs/implementation/deployment.md gelten: Migrationstest mit befülltem Vorgängerstand (tools/test-migration-rollback bzw. bestehender Migrationstest erweitern), konsistente Sicherung vor dem Deployment erfolgt durch die Pipeline. Der Empfänger dient nur der Anzeige in der Einladungsliste und der Vorbelegung im Link; die Einladung bleibt nicht an ihn gebunden (Invariante 3). Beim Verbrauch der Einladung wird recipient auf NULL gesetzt, damit keine Adresse länger als nötig gespeichert bleibt; beim Löschen eines Kontos bleiben die bestehenden Löschregeln unverändert.

5. Ziel-Workflows

Die Entwürfe in mockups/auth-flow.html sind maßgeblich für Struktur und Wortlaut-Tonalität. Texte in Deutsch und Englisch, konsistent zu den E-Mails.

5.1 Admin: Einladung erstellen und versenden

Route admin, Seite „Konten“ mit zwei Abschnitten:

  • Konten: Liste der Konten als Zeilen (Name, Badges „Admin“ und „Gesperrt“, rechts leiser Button „Sperren“/„Freigeben“). Verhalten wie heute inklusive Konfliktbehandlung.
  • Einladungen: Kopfzeile mit Primär-Button „Einladen“. Liste der Einladungen als Zeilen: Empfänger (oder „ohne Adresse“ für Altbestand), Sprache als Pill, Zustand als Pill (Offen in --wj-accent-soft, Verwendet in --wj-panel-2, Abgelaufen/Widerrufen in --wj-clay-soft), Erstellt- und Gültig-bis-Datum lokal formatiert (Datum, Uhrzeit, keine Instant-Rohtexte). Offene Einladungen bekommen die Aktionen „Widerrufen“ und „Neu einladen“; „Neu einladen“ widerruft die alte und öffnet Schritt 1 mit vorbelegter Adresse und Sprache.

Einladen als Sheet (mobil) bzw. Dialog (Desktop) mit zwei klar getrennten Schritten:

  1. Schritt 1 „Wen möchtest du einladen?“: E-Mail-Feld (Pflicht, Validierung wie heute), Sprache als zwei Pills „Deutsch“/„English“ (vorbelegt aus der Admin-Sprache), Hinweis „Die Einladung gilt sieben Tage und kann einmal verwendet werden.“ Primär-Button „Einladung senden“, leise „Abbrechen“. Der Klick erzeugt die Einladung (mit recipient) und übergibt die E-Mail sofort an den Versand.
  2. Schritt 2 „Einladung unterwegs“ (bei erfolgreicher Übergabe): Häkchen-Icon, Text „Die E-Mail an max@example.org wurde zum Versand übergeben. Die Zustellung kann einige Minuten dauern.“ Darunter aufklappbar „Link anzeigen“ mit dem Link im Nur-Lesen-Feld und Button „Kopieren“ (Zwischenablage mit Fallback-Hinweis wie heute). Primär-Button „Fertig“ schließt; die Liste ist aktualisiert. Schritt 2 bei fehlgeschlagener Übergabe: Amber-Karte „Versand nicht möglich“, Text „Die Einladung wurde erstellt, aber die E-Mail konnte nicht übergeben werden. Sende sie erneut oder gib den Link selbst weiter.“ Buttons „Erneut senden“ (Primär) und „Link kopieren“; das Link-Feld ist aufgeklappt.

Bestehendes Verhalten bleibt: Vor dem Senden wird die Admin-Berechtigung erneut geprüft (admission.accounts()); Versandbestätigung verspricht keine Zustellung; der Link enthält Token und URL-kodierte Adresse im Fragment.

5.2 Nutzer: Einladung annehmen

Öffnen von /login#invitation=…&email=… zeigt statt des Standardformulars die Ansicht „Du bist eingeladen“:

  • Logo, Titel „Du bist eingeladen“, Text „Werkjournal ist eine private Zeit- und Habit-Erfassung. Um dein Konto anzulegen, bestätigen wir deine E-Mail-Adresse.“
  • Die Adresse aus dem Link als große Nur-Lesen-Zeile mit leisem Link „Andere Adresse“ (macht das Feld editierbar; Hinweis, dass die Einladung trotzdem gilt).
  • Primär-Button „Code an diese Adresse senden“.
  • Kein Auswahlfeld, kein Passwortfeld sichtbar; kind=invitation und credential werden als versteckte Felder gesetzt (weiterhin durch das Skript aus dem Fragment, das die History bereinigt).
  • Sekundär unten: „Ich habe schon einen Code“.
  • Ungültiges Fragment (Token-Format falsch): Standardansicht mit Hinweiszeile „Dieser Einladungslink ist unvollständig. Bitte den Link aus der E-Mail vollständig öffnen.“ Ob ein Token gültig ist, wird clientseitig nicht geprüft und auch nicht angezeigt (Invariante 1); eine abgelaufene Einladung fällt erst beim Code-Schritt auf, mit der allgemeinen Fehlermeldung aus 5.4 plus dem Zusatz „Falls deine Einladung älter als sieben Tage ist, bitte um eine neue.“ Dieser Zusatz erscheint immer dann, wenn die Session einen Einladungskontext hat, nicht abhängig vom Prüfergebnis.

Nach dem Senden folgt der Code-Schritt (5.4) mit der Zeile „Wir haben einen Code an max@example.org geschickt.“

5.3 Nutzer: Wiederanmeldung

Standardansicht /login ohne Fragment:

  • Logo, Titel „Anmelden“, Text „Wir schicken dir einen Einmalcode per E-Mail. Kein Passwort nötig.“
  • E-Mail-Feld (vorbelegt aus Session), Primär-Button „Code senden“.
  • Sekundär darunter: „Ich habe schon einen Code“ und in einer eigenen leisen Zeile am unteren Rand „Als Gast ansehen“ mit dem bestehenden Hinweis zum gemeinsamen Gastbereich. Der Gastzugang bleibt ein POST-Formular.
  • Ganz unten im Footerbereich der Karte ein kleiner Link „Erste Einrichtung oder Wiederherstellung“ → öffnet /login?setup.

5.4 Code-Schritt

/login?sent und /login?step=code:

  • Titel „Code eingeben“, Zeile „Wir haben einen Code an max@example.org geschickt.“ mit leisem Link „Adresse ändern“ (führt zu /login, Session-Adresse bleibt vorbelegt). Wenn keine Adresse in der Session ist (fremdes Gerät), wird ein E-Mail-Feld gezeigt; sonst ist die Adresse ein verstecktes Feld. Der Endpunkt /login/code verlangt weiterhin email und code.
  • Code-Feld: ein einzelnes Textfeld, inputmode="text", autocomplete="one-time-code", autocapitalize="characters", Schrift 24 px monospaced mit Buchstabenabstand, Platzhalter XXXXX-XXXXX, maximale Länge 32, Bindestriche und Leerzeichen erlaubt (Server normalisiert). Kein Segment-Input aus zehn Feldern, damit Einfügen aus der Zwischenablage und Passwortmanager funktionieren. Bei Eingabe von 10 gültigen Zeichen wird der Button aktiviert; kein automatischer Submit.
  • Primär-Button „Anmelden“. Darunter Hinweiszeile „Der Code gilt zwei Stunden. Die E-Mail kann einige Minuten brauchen; auch im Spamordner nachsehen.“
  • Sekundär: „Code erneut senden“ als Formular-Button (POST /login/start mit Session-Adresse und -Kontext, kind=login bzw. Kontext-Kind). Clientseitig für 60 Sekunden nach dem letzten Senden deaktiviert mit Countdown-Text; die Serverlimits bleiben die eigentliche Grenze. Nach Ablauf der Session-Adresse führt der Button auf /login.
  • Fehler (?denied): Fehlerkarte in --wj-crit-soft mit „Der Code ist ungültig oder abgelaufen.“ und der Handlung „Prüfe die Schreibweise oder fordere einen neuen Code an.“ Adresse und Code-Schritt bleiben erhalten; das Code-Feld ist geleert und fokussiert. Mit Einladungskontext zusätzlich der Satz aus 5.2.
  • Anmeldelink aus der E-Mail (#token=): wie heute automatischer POST; die Seite zeigt währenddessen „Anmeldung läuft …“ statt des Formulars.

5.5 Einrichtung und Wiederherstellung

/login?setup: Titel „Einrichtung“, Text „Für den ersten Administrator oder eine Wiederherstellung. Du brauchst den Einrichtungscode vom Server.“ Felder E-Mail und Einrichtungscode (type=password), Primär „Code senden“, versteckt kind=bootstrap. Link zurück „Zur Anmeldung“. Nach dem Senden derselbe Code-Schritt. Das bisherige Auswahlfeld kind entfällt in der Oberfläche; der Controller akzeptiert weiterhin login, invitation und bootstrap.

5.6 E-Mails

Texte in LoginMail.kt an die neuen Schritte angleichen, Struktur und Prüfungen unverändert:

  • Einladung: Betreff bleibt. Erster Satz nennt Werkjournal in einem Halbsatz; dann „Öffne diesen Link, um dein Konto anzulegen:“ Link; „Auf der Seite ist deine Adresse schon eingetragen. Tippe auf „Code senden“ und gib den Code aus der nächsten E-Mail ein.“ Fristen und „nicht weitergeben“ bleiben.
  • Anmeldecode: Code steht in einer eigenen Zeile, deutlich abgesetzt; darunter „Gib ihn auf dem Gerät ein, auf dem die Anmeldeseite geöffnet ist.“ Dann „Oder tippe auf diesen Link, um dich direkt auf diesem Gerät anzumelden:“ Link. Fristen und Einmaligkeit bleiben.
  • Beide Sprachen; keine HTML-Mails.

5.7 Nach der Anmeldung

Unverändert: Weiterleitung auf das Ziel aus LoginDestination (z. B. datiertes Arbeitsformular nach Push). Neu: beim allerersten Login eines frisch angelegten Kontos zeigt Today einmalig eine schließbare Willkommenskarte (Panel mit Akzentrand): „Willkommen bei Werkjournal. Lege unter Kunden & Projekte los oder erfasse gleich deinen ersten Tag.“ Die Karte erscheint, solange das Konto keinen Arbeitseintrag und keine geänderte Habit-Konfiguration hat, und lässt sich per Klick für dieses Gerät ausblenden (localStorage, Fallback: einfach anzeigen).

6. Technische Umsetzungshinweise

  • LoginController.page() in kleine Render-Funktionen je Ansicht aufteilen (Standard, Einladung, Code, Einrichtung, Link-Login). Ansichtswahl anhand von Query-Parametern und Session; das Einladungs-Fragment bleibt clientseitig, login-invitation.js schaltet die Ansicht um, indem es die Einladungsansicht sichtbar macht (alle Ansichten werden gerendert, nur eine ist sichtbar; IDs request-step, code-step, link-login, invitation-notice, access-options bleiben oder bekommen im Ergebnisdokument dokumentierte Nachfolger, Test-Schritte entsprechend anpassen).
  • Resend-Countdown und Code-Button-Aktivierung in login-invitation.js (kein weiteres Skript); Seite muss ohne JavaScript weiterhin anmeldbar sein (Buttons dann ohne Countdown aktiv).
  • AdmissionService.invite(language, recipient) erweitern; jOOQ-Codegen für die neue Spalte erneuern; AdminView in Listen- und Sheet-Komponenten trennen (InvitationSheet als eigene Klasse).
  • Formatierung von Zeitpunkten in AdminView über die Gerätezeitzone (currentZone()) und DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT).
  • Willkommenskarte in TodayView nur bei leerem Konto; Abfrage über bestehende Services ohne neue Tabellen.
  • Texte der Login-Seite bleiben im Controller mit label(de, en); neue Texte dort ergänzen. Admin-Texte ebenfalls dort, wie heute.

7. Umsetzungsreihenfolge

  1. Werkbaum [~], Branch, Version. Selektor-Inventar der Identity- und Browser-Tests für Login und Admin im Ergebnisdokument.
  2. Datenmodell: Changeset recipient, jOOQ, invite(language, recipient), Migrationstest mit Vorgängerstand.
  3. Admin-Seite: Listen mit Pills und lokal formatierten Daten, „Neu einladen“, Einladungs-Sheet in zwei Schritten mit Fehlerzweig.
  4. Login-Seite: Ansichten trennen (Standard, Einladung, Code, Einrichtung), Skript anpassen, Resend-Countdown, Fehlertexte.
  5. E-Mail-Texte.
  6. Willkommenskarte.
  7. Tests: Controller-Tests für alle Ansichten und Fehlerzweige; Gherkin-Szenarien ergänzen: „Accept an invitation from the emailed link on a phone-sized viewport“ (WJ-063, Browser, 390 px), „Re-invite replaces an open invitation“ (WJ-064, Service), „Resend keeps invitation context“ (WJ-065, Service; deckt den bestehenden Sessionkontext ab). Bestehende Szenarien anpassen, nicht entfernen.
  8. Abnahme, Ergebnisdokument, Werkbaum [x], README-Abschnitt „Konten und Gastzugang“ aktualisieren.

8. Abnahme

  • ./gradlew test -Pproduction=true und ./gradlew firefoxAcceptance ohne Fehler; Szenarienzahlen im Ergebnisdokument. Migrationstest mit befülltem Vorgängerstand bestanden.
  • Screenshots aus dem Produktions-JAR bei 390 × 844 und 1280 × 900, hell und dunkel: Login Standard, Login Einladung (mit Test-Fragment), Code-Schritt mit Fehler, Einrichtung, Admin-Liste, Einladungs-Sheet Schritt 1 und 2 (Erfolg und Fehlzweig).
  • Manuelle Kette im Chromium-Gerätemodus mit Test-LoginMail: Admin lädt ein → aufgezeichnete E-Mail öffnen → Einladungsansicht → Code senden → Code aus aufgezeichneter E-Mail eintippen → Today mit Willkommenskarte. Danach Abmelden, Wiederanmeldung mit Link aus der E-Mail.
  • Prüfung der Invarianten aus 4.2 im Ergebnisdokument einzeln abhaken, mit Verweis auf den jeweiligen Test.
  • Keine echte E-Mail an fremde Adressen; im Ergebnisdokument ausdrücklich festhalten.