14 Commits

Author SHA1 Message Date
schalli 4c93555a50 docs(quick-260917-h2s): Aktenstand — Desktop-Erkennung, Beta-Hinweis, deutscher Installer; Browser-Nachweise gsh/gyd/h2s
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m2s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 4m54s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:46:09 +02:00
schalli 2cd4adc85b feat(desktop): Windows-Installer auf Deutsch mit Tessera-Grafik und -Symbol; CHANGELOG, Handbuch
- bundle.windows.nsis in tauri.conf.json: Deutsch ohne Sprachauswahl,
  Tessera-Icon, Kopf-/Seitenbild, currentUser (kein Admin noetig)
- icons/nsis-header.bmp (150x57), icons/nsis-sidebar.bmp (164x314) ergaenzt
- CHANGELOG: vier neue Desktop-App-Stichpunkte (Beta-Hinweis, Installer,
  Download-Links, Kontextmenue)
- anleitung-anwender.md: Installationsassistent-Satz, Tray-Eintrag erklaert
- anleitung-entwicklung.md: Ort der nsis-Konfiguration, Pruefweg per CI
- Schema-Pruefung (node) und cargo check gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:39:25 +02:00
schalli d9b94bd259 feat(web): Desktop-Client per Cookie erkennen — Download-Links und Browser-Kontextmenü in der App aus
- withDesktopCookie in middleware.ts setzt tessera_desktop=1 auf JEDER
  Antwort (Fruehausstieg, Redirects, next()), wenn ?desktop=1 anliegt
- desktop-client.ts: isDesktopClient() liest das Cookie, useIsDesktopClient()
  kapselt es hydration-sicher per useEffect
- DesktopDownloadLinks fragt /desktop/latest im Desktop-Client gar nicht
  erst an und rendert nichts
- DesktopContextMenuGuard unterdrueckt das WebView2-Kontextmenue ausserhalb
  von Eingabefeldern/contenteditable, in layout.tsx eingebunden
- middleware.test.ts (neu), desktop-client.test.ts (neu),
  desktop-context-menu-guard.test.tsx (neu), Test 4 in
  desktop-download-links.test.tsx — alle 417 Web-Tests und type-check gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:38:13 +02:00
schalli 5bdabf558b feat(desktop): Client meldet sich per desktop=1, Beta-Hinweis nennt den Stand
- with_desktop_marker haengt desktop=1 an beide Navigationen (save_server_url,
  Startnavigation im setup); der Store-Wert server_url bleibt ohne Parameter
- update_labels liefert Menue-/Benachrichtigungstext: bei Versionswechsel wie
  bisher, bei gleicher Version (Beta-Kanal) nennt der Text den Commit-Stempel
- mod tests deckt beide Helfer ab (fmt/check/clippy/test --lib gruen)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:35:20 +02:00
schalli 98fad866bf docs(quick): Aktenstand 260917-gyd (Web-Robustheit) + Plan 260917-h2s (Desktop-Erkennung, Beta-Label, deutscher Installer)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:31:56 +02:00
schalli 2868ffee20 feat(quick-260917-gyd): Widgets-Seite uebersetzt, CHANGELOG ergaenzt
- settings/dashboard/page.tsx: Lade-/Leerhinweis ueber i18n (common.loading, settings.widgets.empty) statt hartkodiertem Englisch
- de.json/en.json: neuer Schluessel settings.widgets.empty (additiv)
- CHANGELOG.md: drei Stichpunkte unter Behoben (Ruecksprung nach Anmeldung, Abmeldung bei toter Sitzung, Uebersetzung Widgets-Seite)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:30:19 +02:00
schalli 474d17082b feat(quick-260917-gyd): Sitzungswaechter erkennt tote Sitzung, Header leitet ab
- auth-actions.ts: fetchSessionState() unterscheidet tote Sitzung (401/403/leere 200-Antwort, Cookie wird geloescht) von API-Ausfall (5xx/Netzwerkfehler/Nicht-JSON, unavailable ohne Redirect); fetchCurrentUser bleibt unveraendert
- header.tsx: Waechter im useEffect leitet bei toter Sitzung per Vollnavigation auf /login?next=… um, bleibt bei API-Ausfall still

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:28:38 +02:00
schalli 4b279eac70 feat(quick-260917-gyd): Ruecksprung nach Anmeldung auf urspruenglich angeforderte Seite
- safe-next.ts: buildNextParam()/sanitizeNextPath() als reine, getestete Funktionen (Open-Redirect-Schutz)
- middleware.ts: haengt next-Parameter an beide Login-Umleitungen (fehlendes Cookie, ungueltige Signatur)
- login/page.tsx: springt nach erfolgreicher Anmeldung auf den bereinigten next-Wert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:26:14 +02:00
schalli 4778824c73 docs(quick-260917-gyd): Plan — Ruecksprung nach Anmeldung, Sitzungswaechter bei toter API-Sitzung, Widgets-Seite uebersetzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:23:49 +02:00
schalli 7929f843ec docs(quick-260917-gsh): Aktenstand — Akzentfarbe als Hex-Code, Bildmarke in Akzentfarbe
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:19:03 +02:00
schalli db478e078a docs: CHANGELOG — Hex-Eingabe der Akzentfarbe, Bildmarke in Akzentfarbe
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:16:18 +02:00
schalli 1601d97f62 feat(brand): gedrehte Kachel der Bildmarke übernimmt die Akzentfarbe
- LogoMark: gedrehte Signalkachel per Inline-Style fill: var(--primary, BRAND_YELLOW) statt festem fill-Attribut
- folgt damit der persoenlichen Akzentfarbe (applyAccentColor in auth-store.ts) in Kopfzeile, Seitenleiste und leerem Dashboard; ohne Nutzer (Anmeldeseite) gilt der CSS-Standard Markengelb
- brand.ts: Kommentar zu BRAND_YELLOW als Rueckfallwert ergaenzt
- tessera-logo.test.tsx: Kacheltest auf Inline-Style-Fuellung umgeschrieben (jsdom haelt var() in style.fill)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:15:33 +02:00
schalli 795c6a492c feat(settings): Akzentfarbe zusätzlich als Hex-Code eingebbar
- normalizeHexColor() (apps/web/src/lib/color.ts) normalisiert Hex-Eingaben (fuehrendes # optional, 3-stellige Kurzform, Gross-/Kleinschreibung, Leerraum)
- Kontoformular: Hex-Textfeld neben dem Farbwaehler, beide bidirektional synchron, ersetzt den reinen Anzeige-Span
- Ungueltiger Text: aria-invalid + roter Rand + Fehlertext, "Farbe speichern" gesperrt; Zuruecksetzen setzt beides auf #ffed00
- i18n: settings.account.accentColorHex/accentColorHexInvalid in de.json und en.json
- Unit-Tests: color.test.ts (9 Faelle), account-settings-form.test.tsx (7 Faelle)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:14:43 +02:00
schalli f6eda20fcb docs(quick-260917-gsh): Plan — Akzentfarbe als Hex-Code eingebbar, Bildmarke uebernimmt Akzentfarbe 2026-09-17 12:12:18 +02:00
40 changed files with 2551 additions and 60 deletions
+6 -3
View File
@@ -7,7 +7,7 @@ status: verified
stopped_at: Completed 18-06-PLAN.md — Windows-Bedienprobe des Nutzers steht aus
last_updated: "2026-09-16T15:27:16.943Z"
last_activity: 2026-09-17
last_activity_desc: Quick 260917-eta — Tray „Beenden"/„Öffnen" der Desktop-App repariert (Windows-VM-Bedienprobe durch Claude); davor 260917-e15 Icon-Fix, Benchmark nach VM-Umbau
last_activity_desc: Quick 260917-gsh/gyd/h2s — alle sieben Nebenbefunde der Windows-Bedienprobe + Akzentfarbe per Hex + Logo in Akzentfarbe
state_head: a777814034ee5907cb4d151669447f015841082d
progress:
total_phases: 18
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — IN PROGRESS
Plan: 6 of 6 (18-02 abgeschlossen)
Status: 18-02 (CI-Job desktop, Cache-Uebergabe an publish, Release-Anhaenge) fertig; 18-03 (Web-Oberflaeche), 18-04 (Client-Updatepruefung), 18-05 (Windows-Cross-Bau + Pipeline-Beweis), 18-06 (Freigabe) stehen aus
Last activity: 2026-09-17 - Completed quick task 260917-eta: Tray „Beenden"/„Öffnen" der Desktop-App repariert (auf Windows-VM gefunden); zuvor 260917-e15 Icon-Fix, Benchmark nach VM-Umbau
Last activity: 2026-09-17 - Quick-Tasks 260917-gsh/gyd/h2s: Akzentfarbe per Hex + Logo in Akzentfarbe, Web-Robustheit (Ruecksprung, Sitzungswaechter, i18n), Desktop-Erkennung + Beta-Label + deutscher Installer — lokal im Browser bestaetigt, Push/CI/VM-Nachweis folgt
Progress: [███░░░░░░░] 33%
@@ -433,6 +433,9 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260917-fast | **Desktop-Client: Startseite + Bau-Parallelitaet (Schnellkorrektur nach Bedienprobe).** Fenster "main" hatte keine Startseite -> "asset not found: index.html" (Altlast Phase 6); `"url": "setup.html"` in tauri.conf.json, per `strings` im Release-Binary bewiesen. `CARGO_BUILD_JOBS=4` im CI-Job desktop (8 rustc-Prozesse brachten den gemeinsam genutzten Host mit 15 GB an die Grenze), Betriebshandbuch Kap. 10, Befund in 18-UAT.md. | 2026-09-17 | b6d9013 | — |
| 260917-e15 | **Desktop-Client-Icon: T statt "1".** Die fuenf Icon-Dateien in `apps/desktop/src-tauri/icons/` waren mit ImageMagicks internem MSVG-Renderer erzeugt, der `transform="rotate(12 51 21)"` nicht rendert -> gedrehte gelbe Kachel fehlte, App-Symbol sah aus wie eine "1". Satz mit `tauri icon` (resvg) aus `apps/web/src/app/icon.svg` neu erzeugt (nur die fuenf Dateien aus `bundle.icon`, kein icns/android/ios), Pixel-Gate an der Kachelmitte `FFED00FF`, icon.ico 16/24/32/48/64/256. Changelog. Nebenbefund: Erststart-Seite (Inline-SVG im WebView) war nie betroffen. | 2026-09-17 | 16564f4,6bb92dc | [260917-e15-desktop-client-icon-fehlende-gedrehte-ge](./quick/260917-e15-desktop-client-icon-fehlende-gedrehte-ge/) |
| 260917-eta | **Desktop-Client: Tray „Beenden" beendete die App nicht; „Öffnen"/Linksklick holten minimiertes Fenster nicht zurueck.** Auf der Windows-Test-VM reproduziert (tasklist: `tessera-desktop.exe` lief nach „Beenden" weiter; Autostart-Haken im selben Menue funktionierte -> Klick kam an). Ursache: `app.run`-Handler rief bei jedem `RunEvent::ExitRequested` `api.prevent_exit()` — auch fuer `app.exit(0)` aus dem Tray. Fix: Muster `ExitRequested { code: None, api, .. }` (Tauri 2.11.3: `code` None = Nutzer-Interaktion, Some = programmatisch). Dazu `w.unminimize()` vor `show()` in „open" und im Linksklick-Handler (nach Win+D bewirkte „Öffnen" nichts). cargo check/clippy 0 Warnungen, rustfmt (9cb9d2e). Changelog 2 Stichpunkte. | 2026-09-17 | 68a69c6,9ba7456,9cb9d2e | [260917-eta-desktop-client-tray-eintrag-beenden-been](./quick/260917-eta-desktop-client-tray-eintrag-beenden-been/) |
| 260917-gsh | **Akzentfarbe als Hex-Code eingebbar; Bildmarke uebernimmt die Akzentfarbe.** `normalizeHexColor()` in `lib/color.ts` (optionales `#`, 3-stellige Kurzform, Kleinschreibung; 9 Tests), Textfeld neben dem Farbwaehler mit Zwei-Wege-Sync, `aria-invalid` + Fehlertext + gesperrtes Speichern bei ungueltigem Wert (7 Komponententests), i18n `settings.account.accentColorHex/-Invalid`. Gedrehte Kachel in `LogoMark` per Inline-Style `fill: var(--primary, #ffed00)` — folgt `applyAccentColor`, Anmeldeseite bleibt gelb. Tests Web 365 -> 381 / 57 Dateien, tsc 0. | 2026-09-17 | 795c6a4,1601d97,db478e0 | [260917-gsh-akzentfarbe-in-einstellungen-konto-zusae](./quick/260917-gsh-akzentfarbe-in-einstellungen-konto-zusae/) |
| 260917-gyd | **Web-Robustheit: Ruecksprung nach Anmeldung, Sitzungswaechter, Widgets-Seite uebersetzt.** Middleware leitet auf `/login?next=<Pfad>` (Helfer `lib/safe-next.ts`: nur relative Pfade, kein `//`, kein `\\`, kein `/login`; Tests), Anmeldeseite springt nach Erfolg dorthin. Neue Server Action `fetchSessionState()` (authenticated/unauthenticated/unavailable): bei 401/403 oder 200 ohne Benutzer wird das Sitzungscookie geloescht und der Header leitet auf `/login?next=…` — 5xx/Netzwerkfehler bleiben still (kein Redirect bei API-Ausfall). Befund vom Testserver-DB-Reset: `/auth/me` liefert bei geloeschtem Benutzer 200 mit leerem Body. `settings/dashboard`: `common.loading` + `settings.widgets.empty` statt englischer Hartkodierung. Tests Web gruen, tsc 0. | 2026-09-17 | 4b279ea,474d170,2868ffe | [260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng](./quick/260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng/) |
| 260917-h2s | **Desktop-Client-Erkennung, Beta-Hinweis, deutscher Installer.** Rust: `with_desktop_marker()` haengt `desktop=1` an beide Navigationen zur Server-Adresse (Store bleibt sauber); `update_labels()` — bei gleicher Version nennt Tray/Benachrichtigung „Neuen Beta-Stand {commit}“ statt „Version X“ (5 Rust-Tests). Web: Middleware setzt Cookie `tessera_desktop=1` (`withDesktopCookie` um jede Rueckgabe), `lib/desktop-client.ts` (`isDesktopClient`/`useIsDesktopClient`), `DesktopDownloadLinks` rendert im Client nichts, `DesktopContextMenuGuard` im RootLayout blockt Rechtsklick ausser in Eingabefeldern. Installer: `bundle.windows.nsis` languages German, kein Sprachwahldialog, installerIcon icon.ico, Header/Sidebar-BMP (Markengelb + Tessera-Zeichen, resvg-Quelle), installMode currentUser; Handbuecher ergaenzt. Web-Tests 417 / 63 Dateien, tsc 0. Browser: Cookie, Link-Ausblendung, Kontextmenue, Ruecksprung, 401-Waechter lokal bestaetigt; Installer/Client-Cookie nach CI auf der Windows-VM. | 2026-09-17 | 5bdabf5,d9b94bd,2cd4adc | [260917-h2s-desktop-client-web-erkennt-den-client-do](./quick/260917-h2s-desktop-client-web-erkennt-den-client-do/) |
## Deferred Items
@@ -478,4 +481,4 @@ Last session: 2026-09-17T08:20:00Z
Resumed: 2026-09-17 — Sitzung ueber /gsd-resume-work fortgesetzt (HANDOFF.json abgearbeitet und entfernt).
Stopped at: Quick 260917-eta (Tray Beenden/Öffnen) gepusht (a0e4c21), CI 244 gruen, Fix auf der Windows-Test-VM 8233 mit Paket 1.1.0-beta.a0e4c21 BESTAETIGT (tasklist ohne tessera-desktop.exe nach Beenden; Öffnen nach Win+D holt Fenster zurueck); Icon-Fix ebenfalls bestaetigt. Nichts angefangen. Alpha-DB wurde vom User am 17.09. 10:37 neu angelegt (Admin-Passwort dort unbekannt). Nebenbefunde 1-7 siehe Memory project-resume
Resume file: None
Last activity: 2026-09-17 - Completed quick task 260917-eta: Tray „Beenden"/„Öffnen" der Desktop-App repariert (auf Windows-VM gefunden); zuvor 260917-e15 Icon-Fix, Benchmark nach VM-Umbau
Last activity: 2026-09-17 - Quick-Tasks 260917-gsh/gyd/h2s: Akzentfarbe per Hex + Logo in Akzentfarbe, Web-Robustheit (Ruecksprung, Sitzungswaechter, i18n), Desktop-Erkennung + Beta-Label + deutscher Installer — lokal im Browser bestaetigt, Push/CI/VM-Nachweis folgt
@@ -0,0 +1,220 @@
---
phase: quick-260917-gsh
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260917-GSH]
files_modified:
- apps/web/src/lib/color.ts
- apps/web/src/lib/color.test.ts
- apps/web/src/components/settings/account-settings-form.tsx
- apps/web/src/components/settings/account-settings-form.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/components/brand/tessera-logo.tsx
- apps/web/src/components/brand/tessera-logo.test.tsx
- apps/web/src/components/brand/brand.ts
- CHANGELOG.md
estimate:
tokens: 32000
raw_tokens: 32000
tasks: 3
confidence: low
must_haves:
truths:
- "Einstellungen → Konto zeigt neben dem Farbwaehler ein Textfeld (`<input type=\"text\">`, monospace, `maxLength={7}`, `aria-label` = settings.account.accentColorHex), vorbelegt mit dem gespeicherten Wert des Nutzers; der bisherige reine Anzeige-`<span>` (Z. 222 im Bestand) existiert nicht mehr."
- "Tippt der Nutzer in das Textfeld `FFED00`, `#FFED00` oder `#fe0`, wird der Wert per `normalizeHexColor()` (apps/web/src/lib/color.ts) auf `#ffed00` bzw. `#ffee00` normalisiert; `accentColor` und damit der Farbwaehler folgen sofort. Beim Verlassen des Feldes (onBlur) steht die kanonische Form im Textfeld."
- "Aendert der Nutzer den Farbwaehler, uebernimmt das Textfeld denselben Wert (`#rrggbb`, Kleinbuchstaben). Zuruecksetzen setzt Farbwaehler UND Textfeld auf `#ffed00`."
- "Ist der Text kein gueltiger Farbwert (`#ggg`, `#12345`, leer), traegt das Textfeld `aria-invalid=\"true\"` und die Klasse `border-destructive`, unter der Zeile steht settings.account.accentColorHexInvalid, und der Knopf „Farbe speichern“ ist `disabled`. Bei gueltigem Wert: `aria-invalid=\"false\"`, `border-input`, Knopf aktiv."
- "`normalizeHexColor(input: string): string | null` ist eine reine Funktion mit vitest-Test (apps/web/src/lib/color.test.ts): gueltig `#ffed00`→`#ffed00`, `FFED00`→`#ffed00`, `#fe0`→`#ffee00`, ` #FfEd00 `→`#ffed00`; ungueltig (`null`): `#ggg`, `#12345`, ``, `#`, `#1234567`."
- "de.json und en.json enthalten unter settings.account die neuen Schluessel `accentColorHex` und `accentColorHexInvalid` (deutsch in Sie-Form, englische Entsprechung); beide Dateien bleiben gueltiges JSON."
- "In `LogoMark` (tessera-logo.tsx) traegt die gedrehte Kachel (`transform=\"rotate(12 51 21)\"`) kein `fill`-Praesentationsattribut mehr, sondern den Inline-Style `fill: var(--primary, #ffed00)` (Vorlage-String mit BRAND_YELLOW als Rueckfall). Die vier Olivkacheln und die Grundplatte sind unveraendert. Damit nimmt die Bildmarke in Kopfzeile, Seitenleiste und leerem Dashboard die per auth-store.ts gesetzte Akzentfarbe an; auf der Anmeldeseite (kein Nutzer, `--primary` = CSS-Standard Markengelb) bleibt sie gelb."
- "tessera-logo.test.tsx prueft: fuenf Kacheln, genau eine mit Inline-Style-Fuellung `var(--primary, #ffed00)` (aus BRAND_YELLOW gebildet), diese ist die einzige gedrehte, keine Kachel traegt ein `fill`-Attribut gleich BRAND_YELLOW, vier Kacheln tragen `fill` = BRAND_OLIVE. jsdom 29.1.1 behaelt `var()`-Werte in `element.style.fill` (vom Planer geprueft)."
- "brand.ts dokumentiert BRAND_YELLOW als Standard-Gelb der Signalkachel und Rueckfall ohne Akzentfarbe; der Kommentar an der Kachel in tessera-logo.tsx erklaert, warum Inline-Style statt Praesentationsattribut."
- "`pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` enden gruen. Kein Docker-Build, kein `git push`, keine Dateien ausserhalb der autorisierten Liste (files_modified + .planning/)."
- "CHANGELOG.md, `## Unveröffentlicht`: je genau ein neuer Stichpunkt unter `### Neu` (Hex-Eingabe) und `### Geändert` (Bildmarke in Akzentfarbe), Stil wie im Bestand (typografische Anfuehrungszeichen, `→`, kein Punkt am Ende, kein Fliesstext)."
- "Drei Commits: `feat(settings): …` (Task 1), `feat(brand): …` (Task 2), `docs: …` (Task 3)."
artifacts:
- "apps/web/src/lib/color.ts — `normalizeHexColor` (neu)"
- "apps/web/src/lib/color.test.ts — Unit-Test der Normalisierung (neu)"
- "apps/web/src/components/settings/account-settings-form.tsx — Hex-Textfeld, Zustand `hexInput`, Sync mit Farbwaehler, Speichern-Sperre"
- "apps/web/src/components/settings/account-settings-form.test.tsx — Komponententest Sync/Sperre/Reset (neu)"
- "apps/web/src/messages/de.json, en.json — settings.account.accentColorHex, accentColorHexInvalid"
- "apps/web/src/components/brand/tessera-logo.tsx — gedrehte Kachel mit Inline-Style `var(--primary, …)`"
- "apps/web/src/components/brand/tessera-logo.test.tsx — angepasster Kacheltest"
- "apps/web/src/components/brand/brand.ts — Kommentar zu BRAND_YELLOW"
- "CHANGELOG.md — zwei Stichpunkte"
key_links:
- "auth-store.ts `applyAccentColor` (Z. 21-41) setzt `--primary` inline auf `document.documentElement`; `LogoMark` liest denselben Token per `var(--primary, …)`. Das ist die einzige Verdrahtung zwischen Akzentfarbe und Bildmarke — kein Prop, kein Store-Zugriff im Logo."
- "`<input type=\"color\">` akzeptiert nur `#rrggbb`; deshalb wird NUR der normalisierte Wert in `accentColor` geschrieben, der Rohtext lebt getrennt in `hexInput`. Ein ungueltiger Rohtext darf nie in `accentColor` landen, sonst faellt jsdom/Browser auf `#000000` zurueck."
- "`updateAccentColorAction` (auth-actions.ts Z. 220) leitet an `PATCH /users/me/accent-color` weiter, Server-Regex `/^#[0-9a-fA-F]{6}$/`; die Client-Normalisierung liefert immer diese Form, Speichern ist bei ungueltigem Text zusaetzlich gesperrt."
- "tessera-logo.test.tsx Z. 97-109 filtert Kacheln ueber `getAttribute('fill') === BRAND_YELLOW` — nach Task 2 sind das null Treffer, der Test MUSS umgeschrieben werden (Inline-Style pruefen), sonst rot."
---
<objective>
Zwei Ergaenzungen an der persoenlichen Akzentfarbe (Einstellungen → Konto):
1. **Hex-Eingabe.** Neben dem Farbwaehler ein Textfeld fuer den Hex-Code (`#rrggbb`), vorbelegt mit dem aktuellen Wert. Eingabe wird ueber eine reine Funktion `normalizeHexColor()` normalisiert (fuehrendes `#` optional, 3-stellige Kurzform wird expandiert, Ausgabe klein). Farbwaehler und Textfeld bleiben in beide Richtungen synchron; ungueltiger Text zeigt einen Fehlerzustand und sperrt „Farbe speichern“. Der reine Anzeige-`<span>` entfaellt.
2. **Bildmarke in Akzentfarbe.** Die gedrehte Signalkachel in `LogoMark` bekommt statt der festen gelben Fuellung den Inline-Style `fill: var(--primary, #ffed00)` — dadurch folgt die Bildmarke ueberall (Kopfzeile, Seitenleiste, leeres Dashboard) der per `applyAccentColor` gesetzten Akzentfarbe; vor der Anmeldung gilt der CSS-Standard (Markengelb).
Dazu Unit-Tests (Normalisierung, Formular-Sync, Logo) und zwei CHANGELOG-Stichpunkte. Browser-Nachweis macht der Orchestrator selbst — kein Docker-Build, kein Push.
Purpose: Nutzer koennen eine exakte Firmenfarbe eintippen statt sie im Farbwaehler zu treffen; die Bildmarke wirkt dann nicht mehr wie ein Fremdkoerper in der gewaehlten Farbe.
Output: `color.ts` + Test, angepasstes Kontoformular + Test, i18n-Schluessel de/en, angepasstes Logo + Test + Kommentar, CHANGELOG, drei Commits.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/account-settings-form.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/brand/tessera-logo.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/brand/tessera-logo.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/brand/brand.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/lib/stores/auth-store.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/desktop-app-settings.test.tsx
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Hex-Eingabe — Normalisierung, Formular-Sync, i18n, Tests</name>
<files>apps/web/src/lib/color.ts, apps/web/src/lib/color.test.ts, apps/web/src/components/settings/account-settings-form.tsx, apps/web/src/components/settings/account-settings-form.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
- apps/web/src/components/settings/account-settings-form.tsx (Z. 39-54 Zustand, Z. 115-140 Speichern/Zuruecksetzen, Z. 209-250 Markup)
- apps/web/src/components/settings/desktop-app-settings.test.tsx Z. 1-40 (Muster: de.json-gestuetzter next-intl-Mock, `vi.hoisted` + `vi.mock` fuer ein lib-Modul)
- apps/web/src/lib/app-version.test.ts Z. 1-10 (Kopfkommentar-Stil fuer lib-Tests)
- apps/web/src/messages/de.json Z. 146-151 und en.json Z. 146-151 (settings.account.accentColor*)
</read_first>
<behavior>
color.test.ts (`describe('normalizeHexColor')`):
- `'#ffed00'` → `'#ffed00'`
- `'FFED00'` → `'#ffed00'` (ohne `#`, Grossbuchstaben)
- `'#fe0'` → `'#ffee00'` (Kurzform expandiert)
- `' #FfEd00 '` → `'#ffed00'` (Leerraum getrimmt)
- `'#ggg'` → `null`; `'#12345'` → `null`; `''` → `null`; `'#'` → `null`; `'#1234567'` → `null`
account-settings-form.test.tsx (`describe('AccountSettingsForm — Akzentfarbe Hex-Eingabe')`):
- Nach dem Laden (fetchCurrentUser liefert accentColor `#123456`): Textfeld `getByRole('textbox', { name: 'Hex-Code' })` hat Wert `#123456`, `input[type="color"]` hat Wert `#123456`.
- `fireEvent.change(textfeld, 'FFED00')` → Farbwaehler-Wert `#ffed00`, Knopf „Farbe speichern“ nicht disabled, Textfeld `aria-invalid="false"`.
- `fireEvent.change(textfeld, '#ggg')` → Textfeld `aria-invalid="true"`, Klasse enthaelt `border-destructive`, Fehlertext (de.json settings.account.accentColorHexInvalid) sichtbar, Knopf „Farbe speichern“ disabled, Farbwaehler behaelt letzten gueltigen Wert.
- `fireEvent.change(farbwaehler, '#00ff00')` → Textfeld-Wert `#00ff00`.
- `fireEvent.blur(textfeld)` nach Eingabe `#fe0` → Textfeld-Wert `#ffee00`.
- Klick „Zurücksetzen“ → Textfeld und Farbwaehler `#ffed00`, `updateAccentColorAction` mit `null` aufgerufen.
- Klick „Farbe speichern“ bei gueltigem `#ffed00` → `updateAccentColorAction` mit `'#ffed00'` aufgerufen, Erfolgsmeldung (settings.account.accentColorSuccess) erscheint (`findByText`).
</behavior>
<action>
RED zuerst: beide Testdateien anlegen und rot sehen (`color.test.ts` scheitert mit fehlendem Modul, Formulartest mit fehlendem Textfeld), dann GREEN.
**1. `apps/web/src/lib/color.ts` (neu).** Exportiere `normalizeHexColor(input: string): string | null`: trimmen, ein optionales fuehrendes `#` abschneiden, dann pruefen — genau 3 oder genau 6 Hex-Zeichen (`/^[0-9a-f]{3}$|^[0-9a-f]{6}$/i`); bei 3 Zeichen jedes Zeichen verdoppeln; Ergebnis kleingeschrieben mit `#` davor zurueckgeben; alles andere `null`. Keine Abhaengigkeiten, kein `'use client'`. Deutscher Kopfkommentar (Zweck: Hex-Eingabe der Akzentfarbe, quick-260917-gsh; Server-Regex in `PATCH /users/me/accent-color` verlangt `#rrggbb`).
**2. `apps/web/src/lib/color.test.ts` (neu).** Faelle aus `<behavior>`; Kopfkommentar im Stil von app-version.test.ts.
**3. i18n.** In `apps/web/src/messages/de.json` und `en.json` unter `settings.account` direkt nach `accentColorError` (Z. 151) zwei Schluessel einfuegen: `accentColorHex` = „Hex-Code“ / „Hex code“; `accentColorHexInvalid` = „Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.“ / „Invalid color value. Please enter six hexadecimal characters, e.g. #ffed00.“ Kommasetzung im JSON beachten.
**4. `account-settings-form.tsx`.**
- Import `normalizeHexColor` aus `@/lib/color`.
- Neuer Zustand `const [hexInput, setHexInput] = useState<string>(DEFAULT_ACCENT);` neben `accentColor`; abgeleitet `const isHexValid = normalizeHexColor(hexInput) !== null;`. `accentColor` bleibt die einzige Quelle fuer Farbwaehler und Speichern und enthaelt IMMER einen gueltigen `#rrggbb`-Wert (ein ungueltiger Rohtext darf dort nie landen — `<input type="color">` faellt sonst auf `#000000`).
- `useEffect` (Z. 45-54): nach `setAccentColor(c)` zusaetzlich `setHexInput(c)` mit demselben Wert.
- Farbwaehler `onChange`: `setAccentColor(v)` UND `setHexInput(v)`.
- Textfeld `onChange`: `setHexInput(raw)`; `const n = normalizeHexColor(raw); if (n) setAccentColor(n);`. Textfeld `onBlur`: falls gueltig, `setHexInput(normalisiert)` (kanonische Form — Ermessensentscheidung des Planers, damit `FFED00` nicht dauerhaft in Grossbuchstaben stehen bleibt).
- `handleResetAccentColor`: zusaetzlich `setHexInput(DEFAULT_ACCENT)`.
- `handleSaveAccentColor`: am Anfang `if (!isHexValid) return;` als Sicherheitsnetz; sendet weiterhin `accentColor`.
- Markup (Z. 215-231): den reinen Anzeige-`<span>` mit `font-mono`, der nur den Wert als Text wiederholt (Z. 222), entfernen und an seiner Stelle das Textfeld setzen: `<input id="accentColorHex" type="text" inputMode="text" autoComplete="off" spellCheck={false} maxLength={7} placeholder={DEFAULT_ACCENT} value={hexInput} aria-label={t('account.accentColorHex')} aria-invalid={!isHexValid} …>`; Klassen `h-10 w-28 rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` plus bedingt `border-destructive` (ungueltig) bzw. `border-input` (gueltig). Direkt unter der Zeile (vor den Erfolgs-/Fehlermeldungen) bei `!isHexValid` ein `<p className="text-xs text-destructive mb-3">{t('account.accentColorHexInvalid')}</p>`.
- Knopf „Farbe speichern“: `disabled={isAccentPending || !isHexValid}`.
**5. `account-settings-form.test.tsx` (neu).** next-intl-Mock de.json-gestuetzt wie desktop-app-settings.test.tsx (die Namespaces `settings` und `auth` muessen beide aufloesen — der Mock ist namespace-generisch). `@/lib/auth-actions` per `vi.hoisted` + `vi.mock` komplett ersetzen: `fetchCurrentUser` → `mockResolvedValue({ id: 'u1', username: 'max', displayName: 'Max', role: 'USER', tenantId: 't1', isLocalUser: true, hasAvatar: false, accentColor: '#123456' })`; `updateAccentColorAction` → `mockResolvedValue({ success: true })`; `changePasswordAction`, `uploadAvatarAction`, `deleteAvatarAction` als `vi.fn()`. `useAuthStore` NICHT mocken (echter zustand-Store, `user` ist null, `setUser` wird nicht erreicht). Farbwaehler ueber `container.querySelector('input[type="color"]')` greifen (kein ARIA-Rollenname). Nach `render` mit `await screen.findByDisplayValue('#123456')` auf das Laden warten; nach Klicks auf Speichern/Zuruecksetzen mit `waitFor`/`findByText` warten (useTransition ist asynchron). `afterEach`: `cleanup()` + `vi.clearAllMocks()`.
Nicht anfassen: Passwort- und Avatar-Abschnitte, auth-actions.ts, auth-store.ts, API.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/color.test.ts src/components/settings/account-settings-form.test.tsx && pnpm --filter @tessera/web type-check && node -e "for (const l of ['de','en']) { const m = JSON.parse(require('fs').readFileSync('apps/web/src/messages/'+l+'.json','utf8')); for (const k of ['accentColorHex','accentColorHexInvalid']) { const v = m.settings.account[k]; if (typeof v !== 'string' || !v) { console.error('fehlt: '+l+' settings.account.'+k); process.exit(1); } } } console.log('i18n ok')" && ! grep -q '">{accentColor}</span>' apps/web/src/components/settings/account-settings-form.tsx && grep -c 'normalizeHexColor' apps/web/src/components/settings/account-settings-form.tsx | grep -qv '^0$' && echo TASK1-OK</automated>
</verify>
<done>Beide neuen Testdateien gruen (Normalisierung 9 Faelle, Formular 7 Faelle), Typpruefung gruen, beide Sprachdateien enthalten die zwei neuen Schluessel als nichtleere Strings, der Anzeige-`<span>` ist aus dem Formular verschwunden, das Formular importiert und nutzt `normalizeHexColor`. Commit `feat(settings): Akzentfarbe zusätzlich als Hex-Code eingebbar` (nur die sechs Dateien dieses Tasks).</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Bildmarke — gedrehte Kachel in Akzentfarbe, Test und Kommentare</name>
<files>apps/web/src/components/brand/tessera-logo.tsx, apps/web/src/components/brand/tessera-logo.test.tsx, apps/web/src/components/brand/brand.ts</files>
<behavior>
tessera-logo.test.tsx — der Test „renders exactly five tiles, exactly one in the brand yellow and exactly one rotated“ (Z. 97-109) wird ersetzt durch „renders exactly five tiles; only the rotated one is filled from the accent token with the brand yellow as fallback“:
- `mark.querySelectorAll('g rect')` hat Laenge 5.
- Genau eine Kachel hat `(tile as SVGRectElement).style.fill === \`var(--primary, ${BRAND_YELLOW})\`` (Erwartung aus der Konstante gebildet); diese Kachel hat das Attribut `transform`; keine andere Kachel hat `transform`.
- Keine Kachel hat `getAttribute('fill') === BRAND_YELLOW`.
- Genau vier Kacheln haben `getAttribute('fill') === BRAND_OLIVE` (Import aus `./brand` ergaenzen) und keinen `style`-Attributwert.
Alle uebrigen Tests der Datei bleiben unveraendert und gruen.
</behavior>
<action>
RED zuerst: Test wie in `<behavior>` umschreiben, rot sehen (Inline-Style fehlt), dann GREEN.
**`tessera-logo.tsx`, `LogoMark`, gedrehte Kachel (Z. 72-80):** Das `fill`-Praesentationsattribut dieser einen `<rect>` (aktuell an `BRAND_YELLOW` gebunden) entfernen und stattdessen `style={{ fill: \`var(--primary, ${BRAND_YELLOW})\` }}` setzen. Grund als deutscher Kommentar direkt ueber der `<rect>`: `var()` ist in SVG-Praesentationsattributen nicht zuverlaessig, im Inline-Style schon; `--primary` wird von `applyAccentColor` in `auth-store.ts` gesetzt, ohne Nutzer (Anmeldeseite) gilt der CSS-Standard aus `globals.css` (Markengelb), der Rueckfall in `var()` greift nur, wenn der Token gar nicht definiert ist. Der Import von `BRAND_YELLOW` bleibt (fuer den Rueckfall). Die vier Olivkacheln, die Grundplatte, `plateOutline`, Props und die `horizontal`-Variante nicht anfassen. Im JSDoc von `TesseraLogo` (Z. 88-93) einen Satz ergaenzen: die gedrehte Signalkachel folgt der persoenlichen Akzentfarbe (`--primary`).
**`brand.ts`:** Kommentar ueber `BRAND_YELLOW` (Z. 11) aendern zu: Standard-Gelb der gedrehten Signalkachel und Rueckfall, wenn keine Akzentfarbe (`--primary`) gesetzt ist; die Kachel selbst wird in `tessera-logo.tsx` per `var(--primary, BRAND_YELLOW)` gefuellt. Werte der drei Konstanten unveraendert (login/page.tsx und account-settings-form.tsx importieren sie weiterhin).
**`tessera-logo.test.tsx`:** Test Z. 97-109 gemaess `<behavior>` ersetzen, `BRAND_OLIVE` importieren. Hinweis: jsdom 29.1.1 (aktuelle Aufloesung unter apps/web) haelt `var(--primary, #ffed00)` in `element.style.fill` und im `style`-Attribut — vom Planer per JSDOM-Probe bestaetigt; `getAttribute('style')` waere die Alternative, `style.fill` ist die klarere Zusicherung.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/brand/tessera-logo.test.tsx && ! grep -q 'fill={BRAND_YELLOW}' apps/web/src/components/brand/tessera-logo.tsx && test "$(grep -cF 'var(--primary, ${BRAND_YELLOW})' apps/web/src/components/brand/tessera-logo.tsx)" = "1" && test "$(grep -c 'fill={BRAND_OLIVE}' apps/web/src/components/brand/tessera-logo.tsx)" = "4" && grep -q 'primary' apps/web/src/components/brand/brand.ts && pnpm --filter @tessera/web type-check && echo TASK2-OK</automated>
</verify>
<done>tessera-logo.test.tsx gruen (alle Tests inkl. des umgeschriebenen Kacheltests), in tessera-logo.tsx gibt es kein gelbes `fill`-Praesentationsattribut mehr, genau eine Vorlage-Fuellung `var(--primary, …)` und weiterhin vier Olivkacheln, brand.ts erwaehnt den `--primary`-Rueckfall, Typpruefung gruen. Commit `feat(brand): gedrehte Kachel der Bildmarke übernimmt die Akzentfarbe` (nur die drei Dateien dieses Tasks).</done>
</task>
<task type="auto">
<name>Task 3: CHANGELOG — zwei Stichpunkte, Gesamtlauf</name>
<files>CHANGELOG.md</files>
<action>
In `CHANGELOG.md` unter `## Unveröffentlicht`:
- `### Neu`, direkt nach dem Stichpunkt `- Favoriten-Widget: optionaler Titel (ohne Titel keine Kopfzeile)` (Z. 12): `- Einstellungen → Konto: Akzentfarbe zusätzlich als Hex-Code eingebbar (z. B. #ffed00)`
- `### Geändert`, direkt nach dem Stichpunkt `- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; …` (Z. 17): `- Tessera-Bildmarke: die gedrehte Kachel übernimmt die persönliche Akzentfarbe`
Stil wie im Bestand: echte Umlaute, `→`, kein Punkt am Ende, kein Fliesstext, keine weiteren Aenderungen an der Datei. Danach den vollstaendigen Web-Testlauf und die Typpruefung als Abschlussgate ausfuehren. Das `<verify>` VOR dem Commit laufen lassen — der Diff-Zaehler misst den Arbeitsbaum gegen HEAD (Tasks 1 und 2 sind zu diesem Zeitpunkt bereits committet, CHANGELOG.md noch nicht).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(sed -n '/^## Unveröffentlicht/,/^## 1\.1\.0/p' CHANGELOG.md | grep -c 'Akzentfarbe zusätzlich als Hex-Code')" = "1" && test "$(sed -n '/^## Unveröffentlicht/,/^## 1\.1\.0/p' CHANGELOG.md | grep -c 'gedrehte Kachel übernimmt die persönliche Akzentfarbe')" = "1" && NUMSTAT=$(git diff --numstat HEAD -- CHANGELOG.md) && test "$(printf '%s' "$NUMSTAT" | awk '{print $1"/"$2}')" = "2/0" && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check && echo TASK3-OK</automated>
</verify>
<done>Beide Stichpunkte stehen je genau einmal im Abschnitt „Unveröffentlicht“ (Neu bzw. Geändert), CHANGELOG-Diff = genau zwei eingefuegte Zeilen, kompletter Web-Testlauf (bisher 55 Dateien / 365 Tests plus die drei neuen bzw. geaenderten Dateien) und Typpruefung gruen. Commit `docs: CHANGELOG — Hex-Eingabe der Akzentfarbe, Bildmarke in Akzentfarbe` (nur CHANGELOG.md).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API (`PATCH /users/me/accent-color`) | Vom Nutzer getippter Hex-Text verlaesst den Client; Server prueft bereits `/^#[0-9a-fA-F]{6}$/` |
| Nutzertext → `<input type="color">` / `--primary` | Freitext darf nicht ungeprueft in den Farbwaehler-Wert oder in `style.setProperty('--primary', …)` gelangen |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-gsh-01 | Tampering | `normalizeHexColor` / Speichern-Pfad | low | mitigate | Nur der normalisierte `#rrggbb`-Wert erreicht `accentColor` und die Server-Action; Speichern bei ungueltigem Text gesperrt; Server-Regex bleibt die letzte Instanz (unveraendert) |
| T-gsh-02 | Tampering | `applyAccentColor` (`--primary` inline) | low | accept | Wert stammt aus der API-Antwort desselben Nutzers, ist serverseitig auf `#rrggbb` beschraenkt; kein neuer Pfad in diesem Task |
| T-gsh-03 | Information Disclosure | Fehlertext `accentColorHexInvalid` | low | accept | Statischer i18n-Text, gibt keine Eingabe wieder |
| T-gsh-SC | Tampering | npm/pnpm installs | low | accept | Keine Paketinstallationen in diesem Plan (nur bestehende Abhaengigkeiten: vitest, testing-library, jsdom) |
</threat_model>
<verification>
- `pnpm --filter @tessera/web exec vitest run` gruen (inkl. `src/lib/color.test.ts`, `src/components/settings/account-settings-form.test.tsx`, `src/components/brand/tessera-logo.test.tsx`).
- `pnpm --filter @tessera/web type-check` gruen.
- `git status --porcelain` nach den drei Commits: nur `.planning/`-Dateien (SUMMARY) offen; keine Datei ausserhalb von `files_modified` veraendert.
- Browser-Nachweis (Orchestrator, nicht Teil dieses Plans): Einstellungen → Konto, `FFED00` bzw. `#fe0` tippen → Farbwaehler folgt; `#ggg` → roter Rand, Speichern gesperrt; Farbe speichern → Kachel der Bildmarke in Kopfzeile/Seitenleiste wechselt sofort in die gewaehlte Farbe; Zuruecksetzen → gelb; Anmeldeseite nach Abmelden → Kachel gelb.
</verification>
<success_criteria>
- Hex-Textfeld vorhanden, synchron mit dem Farbwaehler in beide Richtungen, Fehlerzustand + Speichersperre bei ungueltigem Wert, Reset setzt beides zurueck.
- `normalizeHexColor` als reine Funktion mit den neun Testfaellen aus Task 1.
- Bildmarke: gedrehte Kachel per Inline-Style an `--primary` gebunden, Rueckfall Markengelb; Logo-Tests angepasst und gruen; Kommentare in tessera-logo.tsx und brand.ts aktualisiert.
- Zwei CHANGELOG-Stichpunkte, i18n de/en vollstaendig, drei Commits, alle Gates gruen.
</success_criteria>
<output>
Create `.planning/quick/260917-gsh-akzentfarbe-in-einstellungen-konto-zusae/260917-gsh-SUMMARY.md` when done
</output>
@@ -0,0 +1,150 @@
---
phase: quick-260917-gsh
plan: 01
subsystem: ui
tags: [react, nextjs, tailwind, vitest, next-intl, svg, css-custom-properties]
requires: []
provides:
- "normalizeHexColor() (apps/web/src/lib/color.ts) — reine Funktion, normalisiert Hex-Farbeingaben auf #rrggbb"
- "Einstellungen → Konto: Hex-Textfeld neben dem Akzentfarb-Waehler, bidirektional synchron"
- "LogoMark: gedrehte Signalkachel folgt der persoenlichen Akzentfarbe (--primary) statt fester gelber Fuellung"
affects: [settings, brand, i18n]
actuals:
tokens: 5987
tasks: 3
commits: 3
plan_head_before: f6eda20
tech-stack:
added: []
patterns:
- "SVG-Praesentationsattribute (fill=...) loesen var() nicht zuverlaessig auf; Inline-Style (style={{ fill: 'var(--token, fallback)' }}) tut es"
key-files:
created:
- apps/web/src/lib/color.ts
- apps/web/src/lib/color.test.ts
- apps/web/src/components/settings/account-settings-form.test.tsx
modified:
- apps/web/src/components/settings/account-settings-form.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/components/brand/tessera-logo.tsx
- apps/web/src/components/brand/tessera-logo.test.tsx
- apps/web/src/components/brand/brand.ts
- CHANGELOG.md
key-decisions:
- "onBlur des Hex-Textfelds normalisiert auf die kanonische Form (Kleinbuchstaben, expandierte Kurzform) statt den Rohtext stehen zu lassen — damit bleibt z. B. FFED00 nicht dauerhaft in Grossbuchstaben im Feld."
- "accentColor (Farbwaehler-Zustand) bleibt die einzige Quelle, die je an <input type=\"color\"> und die Speichern-Action geht; der Rohtext lebt getrennt in hexInput, damit ein ungueltiger Zwischenstand nie <input type=\"color\"> auf #000000 zurueckfallen laesst."
- "Bildmarke: Inline-Style statt fill-Attribut, weil jsdom/Browser var() in SVG-Praesentationsattributen nicht zuverlaessig aufloesen; BRAND_YELLOW bleibt als textueller Rueckfallwert im Style-String erhalten."
patterns-established:
- "Formular-Sync zwischen einer strukturierten Eingabe (color-Picker) und einer Freitext-Alternative: getrennter Rohtext-Zustand + abgeleitete Gueltigkeit, kanonischer Wert wird nur bei Gueltigkeit in den strukturierten Zustand uebernommen."
requirements-completed: [QUICK-260917-GSH]
coverage:
- id: D1
description: "Hex-Textfeld neben dem Farbwaehler, vorbelegt, normalisiert Eingaben, synchron mit dem Farbwaehler in beide Richtungen, Fehlerzustand + Speichersperre bei ungueltigem Wert, Reset setzt beides zurueck"
requirement: "QUICK-260917-GSH"
verification:
- kind: unit
ref: "apps/web/src/components/settings/account-settings-form.test.tsx#AccountSettingsForm — Akzentfarbe Hex-Eingabe (7 Tests)"
status: pass
human_judgment: true
rationale: "Visuelle/funktionale Bedienprobe im Browser (Farbwaehler-Reaktion, roter Rand, Speichern-Sperre) ist laut Plan Aufgabe des Orchestrators, nicht Teil dieses Plans."
- id: D2
description: "normalizeHexColor() als reine Funktion mit neun Testfaellen (gueltig/ungueltig, Kurzform, Leerraum, Gross-/Kleinschreibung)"
requirement: "QUICK-260917-GSH"
verification:
- kind: unit
ref: "apps/web/src/lib/color.test.ts#normalizeHexColor (9 Tests)"
status: pass
human_judgment: false
- id: D3
description: "Gedrehte Signalkachel der Bildmarke folgt der Akzentfarbe (--primary) mit Markengelb als Rueckfall; vier Olivkacheln und Grundplatte unveraendert"
requirement: "QUICK-260917-GSH"
verification:
- kind: unit
ref: "apps/web/src/components/brand/tessera-logo.test.tsx#renders exactly five tiles; only the rotated one is filled from the accent token with the brand yellow as fallback"
status: pass
human_judgment: true
rationale: "Sichtbarer Farbwechsel in Kopfzeile/Seitenleiste/Anmeldeseite ist eine visuelle Bedienprobe im Browser — laut Plan Aufgabe des Orchestrators, nicht Teil dieses Plans."
duration: ~20min
completed: 2026-09-17
status: complete
---
# Quick Task 260917-gsh: Akzentfarbe — Hex-Eingabe und Bildmarke in Akzentfarbe Summary
**Hex-Textfeld neben dem Akzentfarb-Waehler (normalizeHexColor() als reine Funktion) plus gedrehte Logokachel, die per `var(--primary, BRAND_YELLOW)` der persoenlichen Akzentfarbe folgt**
## Performance
- **Duration:** ~20 min
- **Completed:** 2026-09-17T10:16:32Z
- **Tasks:** 3/3
- **Files modified:** 10 (3 neu, 7 geaendert)
## Accomplishments
- `normalizeHexColor()` (apps/web/src/lib/color.ts) normalisiert Hex-Eingaben (fuehrendes `#` optional, 3-stellige Kurzform expandiert, Leerraum getrimmt, kleingeschrieben) — 9 Testfaelle gruen
- Kontoformular: Hex-Textfeld ersetzt den reinen Anzeige-`<span>`, bidirektional synchron mit dem Farbwaehler, Fehlerzustand (`aria-invalid`, roter Rand, Fehlertext) sperrt „Farbe speichern"
- `LogoMark` (Bildmarke): gedrehte Signalkachel per Inline-Style `fill: var(--primary, #ffed00)` statt festem `fill`-Attribut — folgt jetzt der Akzentfarbe in Kopfzeile, Seitenleiste und leerem Dashboard, Markengelb bleibt Rueckfall vor der Anmeldung
- i18n de/en vollstaendig (`settings.account.accentColorHex`, `accentColorHexInvalid`), CHANGELOG mit zwei Stichpunkten
## Task Commits
Each task was committed atomically:
1. **Task 1: Hex-Eingabe — Normalisierung, Formular-Sync, i18n, Tests** - `795c6a4` (feat)
2. **Task 2: Bildmarke — gedrehte Kachel in Akzentfarbe, Test und Kommentare** - `1601d97` (feat)
3. **Task 3: CHANGELOG — zwei Stichpunkte, Gesamtlauf** - `db478e0` (docs)
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet.
_Task 1 (`type="tracer" tdd="true"`) und Task 2 (`tdd="true"`) folgten RED→GREEN: Testdateien zuerst angelegt und rot gesehen (fehlendes Modul bzw. fehlender Inline-Style), dann die Implementierung ergaenzt._
## Files Created/Modified
- `apps/web/src/lib/color.ts` - `normalizeHexColor(input): string | null`, reine Funktion, keine Abhaengigkeiten
- `apps/web/src/lib/color.test.ts` - 9 Testfaelle (gueltig/ungueltig, Kurzform, Leerraum, Gross-/Kleinschreibung)
- `apps/web/src/components/settings/account-settings-form.tsx` - Hex-Textfeld, `hexInput`-Zustand, Sync mit Farbwaehler, Speichern-Sperre bei ungueltigem Text
- `apps/web/src/components/settings/account-settings-form.test.tsx` - 7 Testfaelle (Vorbelegung, Sync in beide Richtungen, Fehlerzustand, Blur-Normalisierung, Reset, Speichern)
- `apps/web/src/messages/de.json`, `en.json` - `settings.account.accentColorHex`, `accentColorHexInvalid`
- `apps/web/src/components/brand/tessera-logo.tsx` - gedrehte Kachel mit `style={{ fill: 'var(--primary, ...)' }}`, JSDoc-Ergaenzung, deutscher Kommentar zur Begruendung
- `apps/web/src/components/brand/tessera-logo.test.tsx` - Kacheltest umgeschrieben (Inline-Style-Fuellung statt `fill`-Attribut)
- `apps/web/src/components/brand/brand.ts` - Kommentar zu `BRAND_YELLOW` als Rueckfallwert
- `CHANGELOG.md` - je ein Stichpunkt unter „Neu" und „Geändert"
## Decisions Made
- onBlur des Hex-Textfelds normalisiert auf die kanonische Form (Kleinbuchstaben, expandierte Kurzform) — Ermessensentscheidung des Planers, uebernommen wie im Plan vorgesehen.
- `accentColor` bleibt die einzige Quelle fuer Farbwaehler und Speichern-Action; der Rohtext lebt getrennt in `hexInput`, damit ein ungueltiger Zwischenstand `<input type="color">` nie auf `#000000` zurueckfallen laesst.
- Inline-Style statt `fill`-Praesentationsattribut fuer die Logokachel, weil `var()` in SVG-Praesentationsattributen nicht zuverlaessig aufgeloest wird.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Browser-Nachweis (Orchestrator, nicht Teil dieses Plans): Einstellungen → Konto, `FFED00`/`#fe0` tippen → Farbwaehler folgt; `#ggg` → roter Rand, Speichern gesperrt; Farbe speichern → Kachel der Bildmarke wechselt sofort; Zuruecksetzen → gelb; Anmeldeseite nach Abmelden → Kachel gelb.
- Kein Blocker fuer weitere Arbeit — Formular- und Bildmarken-Tests laufen unveraendert weiter mit dem restlichen Web-Testlauf (381/381 gruen, Typpruefung sauber).
---
*Quick Task: 260917-gsh*
*Completed: 2026-09-17*
## Self-Check: PASSED
All 11 claimed files found on disk; all 3 task commits (795c6a4, 1601d97, db478e0) found in git history.
@@ -0,0 +1,249 @@
---
phase: quick-260917-gyd
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260917-GYD]
files_modified:
- apps/web/src/lib/safe-next.ts
- apps/web/src/lib/safe-next.test.ts
- apps/web/src/middleware.ts
- apps/web/src/app/(auth)/login/page.tsx
- apps/web/src/lib/auth-actions.ts
- apps/web/src/lib/auth-actions.test.ts
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/layout/header.test.tsx
- apps/web/src/app/(portal)/settings/dashboard/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- CHANGELOG.md
estimate:
tokens: 38000
raw_tokens: 38000
tasks: 3
confidence: low
must_haves:
truths:
- "Ruft ein nicht angemeldeter Browser eine Portalseite auf (z. B. `/settings/general/desktop`, auch mit Query), leitet die Middleware auf `/login?next=/settings/general/desktop` um (Pfad + Query, `_rsc`-Parameter entfernt). Fuer `/` und fuer `/login…` wird KEIN `next` angehaengt. Der Zweig „Signatur ungueltig“ loescht weiterhin das Cookie."
- "Nach erfolgreicher Anmeldung springt die Anmeldeseite auf den `next`-Wert, wenn er ein sicherer relativer Pfad ist; sonst (fehlend, `//host`, `/\\host`, `https://…`, `javascript:…`, ohne fuehrenden `/`, Steuerzeichen, `/login…`) auf `/`. Die Pruefung ist die reine Funktion `sanitizeNextPath()` in apps/web/src/lib/safe-next.ts mit vitest-Test."
- "Antwortet die API auf `GET /auth/me` bei vorhandenem Sitzungscookie mit 401, 403 oder 200 ohne Benutzerobjekt (leerer Body / `null` — so antwortet NestJS, wenn `AuthService.getMe` bei geloeschtem Benutzer `null` liefert), loescht die Server Action `fetchSessionState()` das Cookie `session` und liefert `{ status: 'unauthenticated' }`; der Header leitet dann per Vollnavigation auf `/login?next=<aktuelle Seite>` (bzw. `/login` auf der Startseite) um. Kein „?“-Avatar, kein „Keine Module“ mehr bei toter Sitzung."
- "Bei Netzwerkfehler, 5xx oder sonstigen Antworten liefert `fetchSessionState()` `{ status: 'unavailable' }`, das Cookie bleibt, es gibt KEINEN Redirect (wie bisher stilles Verhalten) — kein Abmelde-Karussell bei API-Ausfall."
- "`fetchCurrentUser()` behaelt Signatur (`Promise<AuthUser | null>`) und Verhalten — die anderen Aufrufer (change-password/page.tsx, account-settings-form.tsx) und der bestehende Mock in account-settings-form.test.tsx bleiben unberuehrt."
- "Einstellungen → Widgets zeigt beim Laden `common.loading` („Laden...“ / „Loading...“, wiederverwendet) und bei leerer Liste `settings.widgets.empty` („Es sind noch keine Widgets auf dem Dashboard platziert.“ / englische Entsprechung); kein hartkodierter englischer Text mehr in der Seite."
- "de.json und en.json enthalten unter `settings` das neue Objekt `widgets` mit `empty`; beide Dateien bleiben gueltiges JSON, `umlaut-guard.spec.ts` bleibt gruen (echte Umlaute in de.json)."
- "CHANGELOG.md, `## Unveröffentlicht` → `### Behoben`: drei neue Stichpunkte (Ruecksprung, Abmeldung bei toter Sitzung, Uebersetzung Widgets-Seite), Stil wie im Bestand (typografische Anfuehrungszeichen, kein Punkt am Ende, echte Umlaute)."
- "`pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` enden gruen. Kein Docker-Build, kein `git push`, keine Dateien ausserhalb von files_modified + .planning/."
artifacts:
- "apps/web/src/lib/safe-next.ts — `buildNextParam(pathname, search)` und `sanitizeNextPath(raw)` (neu, reine Funktionen, Edge-tauglich)"
- "apps/web/src/lib/safe-next.test.ts — Unit-Tests beider Funktionen (neu)"
- "apps/web/src/middleware.ts — lokaler Helfer fuer die Login-Umleitung mit `next`-Parameter an beiden Umleitungsstellen"
- "apps/web/src/app/(auth)/login/page.tsx — Ruecksprung auf den bereinigten `next`-Wert nach erfolgreichem Login"
- "apps/web/src/lib/auth-actions.ts — Typ `SessionState` + Server Action `fetchSessionState()` (neu, additiv)"
- "apps/web/src/lib/auth-actions.test.ts — Klassifikation 200/401/403/200-leer/5xx/Netzwerkfehler/kein Cookie (neu)"
- "apps/web/src/components/layout/header.tsx — Waechter im useEffect: authenticated → setUser, unauthenticated → Redirect, unavailable → still"
- "apps/web/src/components/layout/header.test.tsx — Komponententest der drei Ausgaenge (neu)"
- "apps/web/src/app/(portal)/settings/dashboard/page.tsx — i18n statt Festtext"
- "apps/web/src/messages/de.json, en.json — settings.widgets.empty"
- "CHANGELOG.md — drei Stichpunkte unter Behoben"
key_links:
- "middleware.ts (Edge) und header.tsx (Client) nutzen dieselbe `buildNextParam()`; login/page.tsx nutzt `sanitizeNextPath()` — safe-next.ts darf deshalb weder Node- noch DOM-APIs anfassen."
- "Header ist der EINZIGE Sitzungswaechter: er wird genau einmal je Portalseite gerendert (app-shell.tsx Z. 31 → (portal)/layout.tsx); das (auth)-Layout hat keinen Header, die Login-Seite ist oeffentlich (middleware.ts `publicRoutes`) — kein Doppel-Redirect, keine Schleife. Sidebar (`/modules/active`) und Widget-Seite (`fetchWidgets`) brauchen keinen eigenen Umbau."
- "API-Verhalten, an dem die Klassifikation haengt: `JwtStrategy.validate` prueft NICHT gegen die DB (apps/api/src/auth/strategies/jwt.strategy.ts), `AuthService.getMe` (auth.service.ts Z. 310-330) liefert bei fehlendem Benutzer `null` → NestJS sendet 200 mit leerem Body. Deshalb ist „200 ohne Benutzerobjekt“ zwingend als tote Sitzung zu werten, nicht nur 401/403. `ForcePasswordChangeInterceptor` laesst `/auth/me` immer durch, ein 403 auf `/auth/me` ist also nie der Passwortwechsel-Zwang."
- "`cookieStore.delete('session')` ist nur in Server Actions/Route Handlers erlaubt — die Loeschung gehoert in `fetchSessionState()` (auth-actions.ts, 'use server'), nicht in den Header. Die Vollnavigation (`window.location.href`) nach der Action stellt sicher, dass Stores und Moduldaten des Clients verworfen werden."
- "login/page.tsx liest `next` bewusst erst beim Absenden aus `window.location.search` statt per `useSearchParams()` — der Hook verlangt in Next 15 eine Suspense-Grenze, sonst bricht `next build` fuer die statisch vorgerenderte Login-Seite ab."
---
<objective>
Drei Befunde der Web-App aus der Windows-Test-VM beheben (Quick 260917-gyd):
1. **Ruecksprung nach Anmeldung:** Die Middleware haengt den urspruenglich angeforderten Pfad als `next`-Parameter an die Login-URL, die Anmeldeseite springt nach Erfolg dorthin — nur fuer sichere relative Pfade (Open-Redirect-Schutz als reine, getestete Funktion).
2. **Tote Sitzung erkennen:** Ist das Sitzungscookie zwar signaturgueltig, die API antwortet aber mit 401/403 oder ohne Benutzerobjekt (Benutzer nach Neuanlage der Datenbank nicht mehr vorhanden), loescht eine neue Server Action das Cookie und der Header leitet zur Anmeldeseite (mit `next` auf die aktuelle Seite). Netzwerkfehler/5xx bleiben still (kein Karussell).
3. **Uebersetzung:** Einstellungen → Widgets zeigt Lade- und Leerhinweis ueber i18n statt hartkodiertem Englisch.
Purpose: Der Link „Update herunterladen“ aus der Desktop-App (`{server}/settings/general/desktop`) fuehrt nach der Anmeldung tatsaechlich zur Desktop-Seite; ein halb angemeldeter Zustand („?“-Avatar, „Keine Module“, auch nach F5) kann nicht mehr entstehen; die Widgets-Seite ist durchgaengig deutsch.
Output: safe-next.ts (+Test), angepasste middleware.ts und login/page.tsx, `fetchSessionState()` in auth-actions.ts (+Test), Waechter in header.tsx (+Test), i18n-Schluessel, uebersetzte Widgets-Seite, CHANGELOG-Eintraege.
**Nebenlaeufigkeit:** Quick-Task 260917-gsh bearbeitet parallel account-settings-form.tsx, tessera-logo.tsx, lib/color.ts, de.json/en.json und CHANGELOG.md. Dieser Plan fasst davon nur de.json/en.json/CHANGELOG.md an — ausschliesslich additiv (neue Schluessel, neue Zeilen), nie als Umbau oder Neuschreiben der Datei. account-settings-form.tsx und tessera-logo.tsx sind NICHT autorisiert; der Header-Test mockt tessera-logo, damit keine Kopplung entsteht.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@apps/web/src/middleware.ts
@apps/web/src/app/(auth)/login/page.tsx
@apps/web/src/lib/auth-actions.ts
@apps/web/src/components/layout/header.tsx
@apps/web/src/components/layout/app-shell.tsx
@apps/web/src/lib/stores/auth-store.ts
@apps/web/src/app/(portal)/settings/dashboard/page.tsx
@apps/web/src/components/settings/account-settings-form.test.tsx
@apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx
@apps/web/src/lib/desktop.test.ts
@apps/web/src/messages/umlaut-guard.spec.ts
@apps/api/src/auth/auth.service.ts
@apps/api/src/auth/strategies/jwt.strategy.ts
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Ruecksprung — `next`-Parameter in Middleware und Anmeldeseite mit getesteter Pfadpruefung</name>
<files>apps/web/src/lib/safe-next.ts, apps/web/src/lib/safe-next.test.ts, apps/web/src/middleware.ts, apps/web/src/app/(auth)/login/page.tsx</files>
<read_first>
- apps/web/src/middleware.ts (Z. 42-47 fehlendes Cookie, Z. 63-68 ungueltige Signatur, Z. 71-73 matcher)
- apps/web/src/app/(auth)/login/page.tsx (Z. 30-37 Erfolgszweig nach `login(formData)`)
- apps/web/src/lib/desktop.test.ts (Testdatei-Stil: deutscher Kopfkommentar, nummerierte Testfaelle)
</read_first>
<behavior>
safe-next.test.ts (vitest, keine DOM-Abhaengigkeit):
- `buildNextParam('/settings/general/desktop', '')` → `'/settings/general/desktop'`
- `buildNextParam('/modules/tender-radar', '?tab=alerts&_rsc=1abc')` → `'/modules/tender-radar?tab=alerts'` (`_rsc` entfernt, andere Parameter bleiben)
- `buildNextParam('/modules/tender-radar', '?_rsc=1abc')` → `'/modules/tender-radar'` (leere Query ohne `?`)
- `buildNextParam('/', '')` → `null`; `buildNextParam('/login', '?next=%2Fx')` → `null`; `buildNextParam('/login/', '')` → `null`
- `sanitizeNextPath('/settings/general/desktop')` → unveraendert; `sanitizeNextPath('/modules/x?tab=1')` → unveraendert (Query bleibt)
- `sanitizeNextPath(null)`, `(undefined)`, `('')`, `(42)` → `'/'`
- `sanitizeNextPath('//evil.example')` → `'/'`; `('/\\evil.example')` → `'/'`; `('https://evil.example/x')` → `'/'`; `('javascript:alert(1)')` → `'/'`
- `sanitizeNextPath('settings')` (ohne fuehrenden Slash) → `'/'`; `('/foo\nbar')` → `'/'`; `('/a b')` → `'/'`; `('/x'.padEnd(3000, 'y'))` → `'/'`
- `sanitizeNextPath('/login')` → `'/'`; `('/login?next=/x')` → `'/'`; `('/login/')` → `'/'`; aber `('/loginhistory')` bleibt erlaubt (nur exakt `/login` bzw. Praefix `/login/`)
</behavior>
<action>
**RED zuerst:** safe-next.test.ts mit den Faellen aus `<behavior>` anlegen, laufen lassen (rot, Modul fehlt), dann implementieren.
**1. `apps/web/src/lib/safe-next.ts` (neu)** — reine Funktionen ohne Node-/DOM-APIs, weil die Datei sowohl von der Edge-Middleware als auch vom Client importiert wird. Deutscher Kopfkommentar (ae/oe/ue wie im Bestand) mit Herkunft „quick-260917-gyd“ und dem Grund: Open-Redirect-Schutz fuer den Rueckkehrparameter.
- `export function buildNextParam(pathname: string, search: string): string | null` — erzeugt den Rueckkehrwert aus Pfad und Query: Query per `URLSearchParams` parsen, den Parameter `_rsc` entfernen (Next.js haengt ihn an RSC-Navigationsanfragen; er hat in der Login-URL nichts verloren), verbleibende Query nur mit `?` anhaengen, wenn sie nicht leer ist. Liefert `null`, wenn `pathname` gleich `/` ist oder gleich `/login` bzw. mit `/login/` beginnt (kein Ruecksprung auf die Anmeldung selbst); der Aufrufer setzt dann keinen Parameter.
- `export function sanitizeNextPath(raw: unknown): string` — liefert `raw` unveraendert zurueck, wenn ALLE Bedingungen gelten, sonst `'/'`: `typeof raw === 'string'` und nicht leer; hoechstens 2048 Zeichen; erstes Zeichen `/`, zweites Zeichen weder `/` noch `\` (protokoll-relative Adressen wie `//host` und die Backslash-Variante, die Browser als Slash lesen); kein Backslash, kein Whitespace, keine Steuerzeichen (Zeichenklasse aus Whitespace, Backslash, den Codepunkten 0 bis 31 und 127 — als Regex-Literal mit Unicode-Escapes schreiben) irgendwo im Wert; Pfadteil (alles vor dem ersten `?` oder `#`) ist weder exakt `/login` noch beginnt er mit `/login/`. Schema-Adressen (`https://…`, `javascript:…`) scheitern automatisch an der Regel „erstes Zeichen `/`“ — das im Kommentar festhalten, damit niemand eine zusaetzliche Schema-Liste pflegt.
**2. `apps/web/src/middleware.ts`** — Import `buildNextParam` aus `@/lib/safe-next`. Lokale Funktion `redirectToLogin(req: NextRequest): NextResponse` anlegen: `const url = new URL('/login', req.nextUrl)`, `const next = buildNextParam(req.nextUrl.pathname, req.nextUrl.search)`, bei nicht-null `url.searchParams.set('next', next)`, dann `NextResponse.redirect(url)`. Beide bestehenden Umleitungen (Z. 45-47 fehlendes Cookie; Z. 63-68 ungueltige Signatur) auf den Helfer umstellen; im zweiten Zweig bleibt `response.cookies.delete('session')` erhalten. Der `mustChangePassword`-Zweig (Z. 54-60) bleibt unveraendert. Kurzer deutscher Kommentar am Helfer: Pfad + Query wandern als `next` mit, damit die Anmeldeseite zurueckspringen kann (Ausloeser: Link „Update herunterladen“ der Desktop-App).
**3. `apps/web/src/app/(auth)/login/page.tsx`** — Import `sanitizeNextPath` aus `@/lib/safe-next`. Im Erfolgszweig (Z. 32-33) die feste Zuweisung auf die Startseite ersetzen durch: `next`-Wert per `new URLSearchParams(window.location.search).get('next')` lesen, durch `sanitizeNextPath()` schicken, Ergebnis an `window.location.href` zuweisen (Vollnavigation wie bisher, damit die Middleware das frische Cookie sieht). Deutscher Kommentar mit der Begruendung, warum NICHT `useSearchParams()`: der Hook braucht in Next 15 eine Suspense-Grenze, sonst bricht `next build` fuer die statisch vorgerenderte Seite ab; das Lesen erst beim Absenden umgeht das ohne Umbau. Den unbenutzten `useRouter`-Import nicht anfassen (nicht Gegenstand dieses Tasks, Biome ist kein Gate).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/safe-next.test.ts && grep -q "searchParams.set('next'" apps/web/src/middleware.ts && grep -q "from '@/lib/safe-next'" apps/web/src/middleware.ts && grep -q "sanitizeNextPath" "apps/web/src/app/(auth)/login/page.tsx" && ! grep -q "window.location.href = '/'" "apps/web/src/app/(auth)/login/page.tsx" && ! grep -q "redirect(new URL('/login', req.nextUrl))" apps/web/src/middleware.ts</automated>
</verify>
<done>safe-next.test.ts gruen (alle Faelle aus `<behavior>`); Middleware setzt `next` an beiden Umleitungsstellen ueber den gemeinsamen Helfer, Cookie-Loeschung im Signatur-Zweig erhalten; Anmeldeseite springt nach Erfolg auf den bereinigten `next`-Wert, sonst auf `/`.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Sitzungswaechter — `fetchSessionState()` unterscheidet tote Sitzung von API-Ausfall, Header leitet ab</name>
<files>apps/web/src/lib/auth-actions.ts, apps/web/src/lib/auth-actions.test.ts, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/header.test.tsx</files>
<read_first>
- apps/web/src/lib/auth-actions.ts (Z. 1-24 Konstanten/Typen, Z. 239-268 `fetchCurrentUser` als Vorlage fuer Cookie-Header und `cache: 'no-store'`)
- apps/web/src/components/layout/header.tsx (Z. 1-12 Imports, Z. 25-42 useEffect, Z. 62-64 Avatar-Initiale „?“)
- apps/web/src/components/layout/app-shell.tsx (Header genau einmal, Z. 31)
- apps/web/src/components/settings/account-settings-form.test.tsx (Z. 10-41: next-intl-Mock auf de.json-Basis und `vi.hoisted` + `vi.mock('@/lib/auth-actions')`-Muster)
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx (Z. 72-95: Mock von `next/headers` `cookies()` und `vi.stubGlobal('fetch', …)`)
- apps/api/src/auth/auth.service.ts Z. 310-330 (`getMe` liefert `null` bei fehlendem Benutzer) und apps/api/src/auth/strategies/jwt.strategy.ts (keine DB-Pruefung im `validate`)
</read_first>
<behavior>
auth-actions.test.ts (`fetchSessionState`; `next/headers` gemockt: `cookies()` → Promise eines Objekts `{ get, set, delete }` aus `vi.hoisted`; `next/navigation` gemockt mit `redirect: vi.fn()`; `fetch` per `vi.stubGlobal`):
- Test 1: kein Cookie → `{ status: 'unauthenticated' }`, `fetch` nicht aufgerufen
- Test 2: Cookie + Antwort `{ ok: true, status: 200, text: () => '{"id":"u1","username":"schalli",…}' }` → `{ status: 'authenticated', user }` mit `user.username === 'schalli'`, `cookieStore.delete` NICHT aufgerufen; `fetch` bekam Header `Cookie: session=<wert>`
- Test 3: Status 401 → `{ status: 'unauthenticated' }`, `cookieStore.delete('session')` genau einmal
- Test 4: Status 403 → wie Test 3
- Test 5: Status 200 mit leerem Body (`text: () => ''`) → `{ status: 'unauthenticated' }` + Cookie geloescht; ebenso Body `'null'`
- Test 6: Status 500 → `{ status: 'unavailable' }`, `cookieStore.delete` NICHT aufgerufen
- Test 7: `fetch` wirft (`TypeError: fetch failed`) → `{ status: 'unavailable' }`, Cookie bleibt
- Test 8: Status 200 mit Body, der kein JSON ist (`'<html>'`) → `{ status: 'unavailable' }`, Cookie bleibt
header.test.tsx (Mocks: `next-intl` nach dem de.json-Muster; `next/navigation` mit `usePathname: () => '/settings/general/desktop'`; `@/lib/auth-actions` mit `fetchSessionState`/`logout` aus `vi.hoisted`; `@/components/bug-report/bug-report-button`, `@/components/theme-toggle`, `@/components/brand/tessera-logo` jeweils als leere Komponente; `vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' })` vor `render`; `afterEach`: `cleanup()`, `vi.unstubAllGlobals()`, `useAuthStore.setState({ user: null })`, `vi.clearAllMocks()`):
- Test 1 (authenticated): Store enthaelt danach den Benutzer (`useAuthStore.getState().user?.username === 'schalli'`), `window.location.href` bleibt `''`
- Test 2 (unauthenticated auf Unterseite): `window.location.href === '/login?next=%2Fsettings%2Fgeneral%2Fdesktop'`
- Test 3 (unauthenticated auf `/`, Stub mit `pathname: '/'`): `window.location.href === '/login'`
- Test 4 (unavailable): `window.location.href` bleibt `''`, Store-User bleibt `null`, der Avatar-Knopf (`aria-label` = header.userMenu aus de.json) zeigt `?`
</behavior>
<action>
**RED zuerst:** beide Testdateien anlegen, laufen lassen (rot), dann implementieren.
**1. `apps/web/src/lib/auth-actions.ts`** — additiv, KEINE Aenderung an bestehenden Exporten (Signatur und Verhalten von `fetchCurrentUser` bleiben exakt, denn change-password/page.tsx und account-settings-form.tsx — letztere gerade in Bearbeitung durch 260917-gsh, nicht autorisiert — verlassen sich darauf; der Mock in account-settings-form.test.tsx listet die Exporte namentlich). Neu:
- `export type SessionState = { status: 'authenticated'; user: AuthUser } | { status: 'unauthenticated' } | { status: 'unavailable' }` (Typ-Export ist in einer 'use server'-Datei erlaubt, nur Laufzeit-Exporte muessen async Funktionen sein).
- `export async function fetchSessionState(): Promise<SessionState>` — Ablauf: Cookie `session` lesen; fehlt es → `unauthenticated` ohne fetch. Sonst `GET ${API_URL}/auth/me` mit `Cookie: session=<wert>` und `cache: 'no-store'` (wie `fetchCurrentUser`). Klassifikation: Status 401 oder 403 → `cookieStore.delete('session')` → `unauthenticated`. Status 200 → Body per `response.text()` lesen; ist er nach `trim()` leer oder gleich `null` → Benutzer existiert nicht mehr (Grund im Kommentar: `AuthService.getMe` liefert `null`, NestJS antwortet dann 200 ohne Body — exakt der Fall „Datenbank neu angelegt, Signatur noch gueltig“ vom Testserver) → Cookie loeschen → `unauthenticated`; sonst `JSON.parse` → bei Objekt mit `id` → `authenticated` mit `user`; Parse-Fehler → `unavailable`. Jeder andere Status (5xx, 404, 3xx …) und ein werfendes `fetch` → `unavailable`, Cookie bleibt. Deutscher Kommentar ueber der Funktion: die Unterscheidung ist load-bearing — nur eine nachweislich tote Sitzung darf abmelden, ein API-Ausfall darf keine Abmelde-Schleife ausloesen. `cookieStore.delete` ist in Server Actions erlaubt (das ist der Grund, warum die Loeschung hier und nicht im Header passiert).
- Duplikation der wenigen fetch-Zeilen gegenueber `fetchCurrentUser` ist akzeptiert; wer will, zieht einen NICHT exportierten Helfer heraus — `fetchCurrentUser` darf dabei sein Verhalten nicht aendern (insbesondere: es loescht weiterhin nie das Cookie).
**2. `apps/web/src/components/layout/header.tsx`** — Import auf `fetchSessionState, logout` umstellen (`fetchCurrentUser` hier nicht mehr importieren), `buildNextParam` aus `@/lib/safe-next` importieren (Task 1). useEffect (Z. 25-42) umbauen: `useRef(false)` als Redirect-Sperre (StrictMode-Doppeleffekt und Effekt-Wiederholungen duerfen nicht zweimal navigieren). Bei `status === 'authenticated'` → `setUser(...)` mit denselben Feldern wie bisher (id, username, displayName, role, tenantId, hasAvatar, accentColor). Bei `status === 'unauthenticated'` → Sperre setzen, `const next = buildNextParam(window.location.pathname, window.location.search)`, Ziel `'/login?next=' + encodeURIComponent(next)` bzw. `'/login'` wenn `next` null ist, per `window.location.href` zuweisen (Vollnavigation, kein `router.push`: alle Client-Stores und Moduldaten muessen verworfen werden, und die Middleware soll die Anfrage frisch sehen). Bei `status === 'unavailable'` → nichts tun (bisheriges stilles Verhalten, Avatar zeigt „?“, kein Redirect). Deutscher Kommentar am Effekt: Der Header ist der einzige Waechter, weil er auf jeder Portalseite genau einmal gerendert wird (AppShell im (portal)-Layout; das (auth)-Layout hat keinen Header, `/login` ist oeffentlich) — Seitenleiste und Widget-Aufrufe brauchen deshalb keinen eigenen Umbau, und ein Doppel-Redirect ist ausgeschlossen. `useEffect`-Abhaengigkeiten `[user, setUser]` beibehalten.
**3. Tests** wie in `<behavior>`. Fuer auth-actions.test.ts das Muster aus module-access.test.tsx (Z. 72-95) uebernehmen; die 'use server'-Direktive ist unter vitest wirkungslos. Fuer header.test.tsx den Store direkt ueber `useAuthStore.setState({ user: null })` zuruecksetzen, nicht mocken. Falls `vi.stubGlobal('location', …)` unter jsdom 29 wider Erwarten fehlschlaegt („Cannot redefine property“): stattdessen `Object.defineProperty(window, 'location', { value: {…}, writable: true, configurable: true })` und in `afterEach` den Originalwert zuruecksetzen. Falls `next/link` beim Rendern ohne Router-Kontext stoert: `vi.mock('next/link', …)` auf ein einfaches `<a>` mit `href`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib/auth-actions.test.ts src/components/layout/header.test.tsx && grep -q "export async function fetchSessionState" apps/web/src/lib/auth-actions.ts && grep -q "export async function fetchCurrentUser(): Promise<AuthUser | null>" apps/web/src/lib/auth-actions.ts && grep -q "fetchSessionState" apps/web/src/components/layout/header.tsx && grep -q "buildNextParam" apps/web/src/components/layout/header.tsx && ! grep -q "fetchCurrentUser" apps/web/src/components/layout/header.tsx</automated>
</verify>
<done>Beide Testdateien gruen (8 + 4 Faelle); `fetchSessionState()` loescht das Cookie nur bei 401/403/leerer 200-Antwort und meldet 5xx/Netzwerkfehler als `unavailable`; der Header leitet bei toter Sitzung auf `/login?next=…` um und bleibt bei API-Ausfall still; `fetchCurrentUser` unveraendert, account-settings-form.test.tsx weiterhin gruen.</done>
</task>
<task type="auto">
<name>Task 3: Widgets-Seite uebersetzen, CHANGELOG ergaenzen, Gesamtlauf</name>
<files>apps/web/src/app/(portal)/settings/dashboard/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md</files>
<precondition>`git status --porcelain -- apps/web/src/messages/de.json apps/web/src/messages/en.json CHANGELOG.md` ist leer — die Aenderungen des parallelen Quick-Tasks 260917-gsh an diesen drei Dateien sind committet, sonst wuerden fremde Aenderungen in diesen Task-Commit rutschen.</precondition>
<read_first>
- apps/web/src/app/(portal)/settings/dashboard/page.tsx (Z. 13 `useTranslations('settings')`, Z. 34-40 Lade-/Leerzweig)
- apps/web/src/messages/de.json: Namespace `common` (Z. 1-20, enthaelt bereits `loading`) und Namespace `settings` (Schluessel `categoryWidgets`)
- apps/web/src/messages/umlaut-guard.spec.ts (Kopfkommentar: de.json braucht echte Umlaute, Ersatzschreibungen schlagen fehl)
- CHANGELOG.md Z. 1-35 (`## Unveröffentlicht` → `### Behoben`, Stil der Stichpunkte)
</read_first>
<action>
**1. i18n-Schluessel (additiv):** In de.json UND en.json innerhalb des Namespace `settings` direkt hinter `"categoryWidgets"` ein neues Objekt `"widgets"` mit dem Schluessel `"empty"` einfuegen — per gezieltem Edit an dieser Stelle, niemals die Datei neu schreiben (260917-gsh hat parallel Schluessel unter `settings.account` ergaenzt; die Einfuegung relativ zu `categoryWidgets` bleibt davon unberuehrt). Texte: de „Es sind noch keine Widgets auf dem Dashboard platziert.“ (Sie-Form, echte Umlaute — hier kommen keine vor), en „No widgets have been placed on the dashboard yet.“ Fuer den Ladehinweis KEIN neuer Schluessel: `common.loading` existiert bereits in beiden Sprachdateien (deutsch „Laden...“) und wird wiederverwendet — Konsistenz mit dem Rest der App.
**2. `apps/web/src/app/(portal)/settings/dashboard/page.tsx`:** zusaetzlich `const tCommon = useTranslations('common')` neben dem bestehenden `t`; die beiden hartkodierten englischen Texte (Ladehinweis Z. 35, Leerhinweis Z. 37-39) durch `tCommon('loading')` bzw. `t('widgets.empty')` ersetzen. Markup, Klassen und Logik sonst unveraendert.
**3. CHANGELOG.md:** unter `## Unveröffentlicht` → `### Behoben` am ENDE der Liste drei Stichpunkte anhaengen (falls 260917-gsh dort inzwischen Zeilen ergaenzt hat: dahinter), Stil wie im Bestand (typografische Anfuehrungszeichen „…“, `→`, kein Punkt am Ende, echte Umlaute):
- Anmeldung: nach der Anmeldung geht es zur ursprünglich aufgerufenen Seite weiter statt immer zum Dashboard (z. B. beim Link „Update herunterladen“ aus der Desktop-App)
- Anmeldung: eine nicht mehr gültige Sitzung (z. B. nach Neuanlage der Datenbank) zeigte ein leeres Portal mit „?“-Avatar und „Keine Module“ – jetzt Abmeldung und Anmeldeseite
- Einstellungen → Widgets: Lade- und Leerhinweis waren nur auf Englisch
**4. Gesamtlauf:** `pnpm --filter @tessera/web exec vitest run` (alle Tests, inkl. umlaut-guard.spec.ts und account-settings-form.test.tsx) und `pnpm --filter @tessera/web type-check` muessen gruen sein. Kein Docker-Build, kein `git push`; der Browser-Nachweis erfolgt durch den Orchestrator.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');if(typeof de.settings.widgets.empty!=='string'||typeof en.settings.widgets.empty!=='string'||typeof de.common.loading!=='string')process.exit(1)" && grep -q "t('widgets.empty')" "apps/web/src/app/(portal)/settings/dashboard/page.tsx" && grep -q "tCommon('loading')" "apps/web/src/app/(portal)/settings/dashboard/page.tsx" && ! grep -q "No widgets placed" "apps/web/src/app/(portal)/settings/dashboard/page.tsx" && ! grep -q "Loading\.\.\." "apps/web/src/app/(portal)/settings/dashboard/page.tsx" && grep -q "ursprünglich aufgerufenen Seite" CHANGELOG.md && grep -q "nicht mehr gültige Sitzung" CHANGELOG.md && grep -q "Einstellungen → Widgets: Lade- und Leerhinweis" CHANGELOG.md && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check</automated>
</verify>
<done>Widgets-Seite zeigt beide Hinweise ueber i18n (de/en), `settings.widgets.empty` in beiden Sprachdateien, drei CHANGELOG-Stichpunkte unter Behoben; gesamte Web-Testsuite und Typpruefung gruen.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Web-Middleware/Anmeldeseite | `next`-Parameter ist Nutzereingabe (URL), kann von Dritten in Links praepariert werden |
| Web-Server-Action → API (`/auth/me`) | Antwortstatus/Body steuern Cookie-Loeschung und Redirect |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-gyd-01 | Tampering (Open Redirect / Phishing) | `sanitizeNextPath` in login/page.tsx | high | mitigate | Nur relative Pfade: erstes Zeichen `/`, zweites weder `/` noch `\`, kein Backslash/Whitespace/Steuerzeichen, Laengenlimit, kein `/login`; alles andere → `/`. Reine Funktion mit Negativfaellen im Test (Task 1). |
| T-gyd-02 | Denial of Service (Abmelde-Schleife) | `fetchSessionState` + Header-Waechter | medium | mitigate | Nur 401/403/leere 200-Antwort loesen Cookie-Loeschung und Redirect aus; 5xx, Netzwerkfehler, Nicht-JSON → `unavailable` ohne Redirect (Tests 6-8 in Task 2). Redirect-Sperre per `useRef` gegen Doppelnavigation. |
| T-gyd-03 | Information Disclosure | `next` in der Login-URL (Pfad + Query der angeforderten Seite) | low | accept | Same-Origin-Pfad, der ohnehin in Browserverlauf/Serverlog steht; `_rsc` wird entfernt; keine Geheimnisse in Portal-Queries. |
| T-gyd-04 | Spoofing (falsche Abmeldung durch fremde 403) | Klassifikation 403 auf `/auth/me` | low | accept | `ForcePasswordChangeInterceptor` laesst `/auth/me` immer durch; ein 403 dort stammt nur vom `TenantGuard` (kein Mandantenkontext) — auch das ist eine unbrauchbare Sitzung. |
| T-gyd-SC | Tampering | npm/pip/cargo installs | low | accept | Keine Paketinstallation in diesem Plan; keine neuen Abhaengigkeiten. |
</threat_model>
<verification>
- `pnpm --filter @tessera/web exec vitest run src/lib/safe-next.test.ts src/lib/auth-actions.test.ts src/components/layout/header.test.tsx` gruen (neue Tests).
- `pnpm --filter @tessera/web exec vitest run` gruen (Gesamtsuite inkl. umlaut-guard.spec.ts, account-settings-form.test.tsx).
- `pnpm --filter @tessera/web type-check` sauber.
- Grep-Gates: `searchParams.set('next'` in middleware.ts; `sanitizeNextPath` in login/page.tsx; `fetchSessionState` in header.tsx, `fetchCurrentUser` dort nicht mehr; `fetchCurrentUser`-Signatur in auth-actions.ts unveraendert; keine hartkodierten englischen Hinweise mehr in settings/dashboard/page.tsx.
- Browser-Nachweis (durch den Orchestrator, nicht Teil dieses Plans): ohne Sitzung `/settings/general/desktop` aufrufen → Login-URL traegt `next`, nach Anmeldung landet man auf der Desktop-Seite; Sitzungscookie mit nicht mehr existierender Benutzer-ID → Umleitung zur Anmeldeseite statt „?“-Avatar; Einstellungen → Widgets zeigt deutsche Hinweise.
</verification>
<success_criteria>
- Middleware haengt `next` (Pfad + Query ohne `_rsc`) an beide Login-Umleitungen; nicht fuer `/` und `/login…`.
- Anmeldeseite springt nach Erfolg auf den bereinigten `next`-Wert; unsichere Werte fallen auf `/` zurueck (getestet).
- Tote Sitzung (401/403/leere 200) → Cookie serverseitig geloescht, Vollnavigation auf `/login?next=…`; API-Ausfall → stilles Verhalten wie bisher.
- `fetchCurrentUser` unveraendert; keine Datei ausserhalb von files_modified + .planning/ angefasst (insbesondere nicht account-settings-form.tsx, tessera-logo.tsx).
- Widgets-Seite vollstaendig uebersetzt, i18n-Schluessel additiv, CHANGELOG mit drei Stichpunkten unter Behoben.
- Gesamte Web-Testsuite und Typpruefung gruen; kein Docker-Build, kein Push.
</success_criteria>
<output>
Create `.planning/quick/260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng/260917-gyd-SUMMARY.md` when done
</output>
@@ -0,0 +1,185 @@
---
phase: quick-260917-gyd
plan: 01
subsystem: auth
tags: [next.js, middleware, jwt, i18n, vitest]
requires: []
provides:
- "safe-next.ts: buildNextParam()/sanitizeNextPath() als reine, getestete Funktionen fuer den Ruecksprung-Parameter (Open-Redirect-Schutz)"
- "fetchSessionState() in auth-actions.ts: klassifiziert die Sitzung (authenticated/unauthenticated/unavailable) und loescht das Cookie nur bei nachweislich toter Sitzung"
- "Header-Waechter erkennt tote Sitzung und leitet auf /login?next=… um, bleibt bei API-Ausfall still"
- "Einstellungen → Widgets vollstaendig uebersetzt (i18n statt hartkodiertem Englisch)"
affects: [web-auth, web-settings]
actuals:
tokens: 6602
tasks: 3
commits: 3
plan_head_before: 4778824c73caafbf859c71c5c5a1c40401732296
tech-stack:
added: []
patterns:
- "safe-next.ts: reine Funktionen ohne Node-/DOM-APIs, damit ein Modul sowohl von der Edge-Middleware als auch vom Client importiert werden kann"
- "fetchSessionState() klassifiziert API-Antworten in drei Zustaende (authenticated/unauthenticated/unavailable) statt eines binaeren Erfolg/Misserfolg, um Abmelde-Schleifen bei API-Ausfaellen zu vermeiden"
key-files:
created:
- apps/web/src/lib/safe-next.ts
- apps/web/src/lib/safe-next.test.ts
- apps/web/src/lib/auth-actions.test.ts
- apps/web/src/components/layout/header.test.tsx
modified:
- apps/web/src/middleware.ts
- apps/web/src/app/(auth)/login/page.tsx
- apps/web/src/lib/auth-actions.ts
- apps/web/src/components/layout/header.tsx
- apps/web/src/app/(portal)/settings/dashboard/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- CHANGELOG.md
key-decisions:
- "fetchCurrentUser() bleibt unveraendert (Signatur und Verhalten) — change-password/page.tsx, account-settings-form.tsx und deren Test-Mock haengen davon ab; fetchSessionState() ist additiv daneben entstanden, mit akzeptierter kleiner Code-Duplikation der fetch-Zeilen"
- "Cookie-Loeschung bei toter Sitzung passiert ausschliesslich in der Server Action fetchSessionState() (cookieStore.delete() ist nur dort erlaubt), der Header liest nur den klassifizierten Zustand und navigiert"
- "login/page.tsx liest next erst beim Absenden aus window.location.search statt per useSearchParams(), weil der Hook in Next 15 eine Suspense-Grenze braucht und sonst next build fuer die statisch vorgerenderte Login-Seite abbricht"
patterns-established:
- "Sitzungswaechter-Muster: dreiwertige Klassifikation (authenticated/unauthenticated/unavailable) statt AuthUser | null, damit ein API-Ausfall nicht als tote Sitzung fehlinterpretiert wird"
requirements-completed: [QUICK-260917-GYD]
coverage:
- id: D1
description: "Middleware haengt next-Parameter (Pfad + Query ohne _rsc) an beide Login-Umleitungen an; nicht fuer / und /login…"
requirement: QUICK-260917-GYD
verification:
- kind: unit
ref: "apps/web/src/lib/safe-next.test.ts (9 Faelle)"
status: pass
human_judgment: false
- id: D2
description: "Anmeldeseite springt nach Erfolg auf den bereinigten next-Wert; unsichere Werte fallen auf / zurueck"
requirement: QUICK-260917-GYD
verification:
- kind: unit
ref: "apps/web/src/lib/safe-next.test.ts (sanitizeNextPath-Faelle)"
status: pass
human_judgment: false
- id: D3
description: "fetchSessionState() loescht das Cookie nur bei 401/403/leerer 200-Antwort, meldet 5xx/Netzwerkfehler als unavailable ohne Cookie-Loeschung"
requirement: QUICK-260917-GYD
verification:
- kind: unit
ref: "apps/web/src/lib/auth-actions.test.ts (8 Faelle)"
status: pass
human_judgment: false
- id: D4
description: "Header leitet bei toter Sitzung per Vollnavigation auf /login?next=… um und bleibt bei API-Ausfall still (Avatar zeigt weiterhin ?)"
requirement: QUICK-260917-GYD
verification:
- kind: unit
ref: "apps/web/src/components/layout/header.test.tsx (4 Faelle)"
status: pass
human_judgment: false
- id: D5
description: "Einstellungen → Widgets zeigt Lade- und Leerhinweis ueber i18n (de/en) statt hartkodiertem Englisch"
requirement: QUICK-260917-GYD
verification:
- kind: other
ref: "grep-Gates auf settings/dashboard/page.tsx + de.json/en.json settings.widgets.empty (Task-3-verify)"
status: pass
human_judgment: false
- id: D6
description: "Browser-Nachweis: ohne Sitzung /settings/general/desktop aufrufen → Login-URL traegt next, nach Anmeldung landet man auf der Desktop-Seite; Sitzungscookie mit nicht mehr existierender Benutzer-ID → Umleitung zur Anmeldeseite statt ?-Avatar"
verification: []
human_judgment: true
rationale: "Erfordert einen echten Browser-Lauf gegen die laufende API/DB (Orchestrator-Aufgabe, nicht Teil dieses Ausfuehrungsplans laut <verification>-Sektion des Plans)"
duration: 7min
completed: 2026-09-17
status: complete
---
# Quick Task 260917-gyd: Ruecksprung nach Anmeldung, Sitzungswaechter bei toter API-Sitzung, Widgets-Seite uebersetzt Summary
**`next`-Parameter fuer den Ruecksprung nach Anmeldung (safe-next.ts, Open-Redirect-getestet), `fetchSessionState()` unterscheidet tote Sitzung von API-Ausfall und leitet den Header ab, Einstellungen → Widgets durchgaengig deutsch.**
## Performance
- **Duration:** 7 min
- **Started:** 2026-09-17T10:24:00Z
- **Completed:** 2026-09-17T10:30:49Z
- **Tasks:** 3
- **Files modified:** 12
## Accomplishments
- Middleware und Anmeldeseite tragen jetzt einen getesteten `next`-Ruecksprung durch — der Desktop-App-Link „Update herunterladen" landet nach der Anmeldung wieder auf der urspruenglich angeforderten Seite
- `fetchSessionState()` erkennt eine tote Sitzung (401/403/leere 200-Antwort) zuverlaessig und trennt sie von einem stillen API-Ausfall (5xx/Netzwerkfehler) — der Header meldet sich bei toter Sitzung ab statt ein halb angemeldetes „?"-Avatar-Portal zu zeigen
- Einstellungen → Widgets zeigt Lade- und Leerhinweis vollstaendig ueber i18n
## Task Commits
Each task was committed atomically:
1. **Task 1: Ruecksprung — `next`-Parameter in Middleware und Anmeldeseite** - `4b279ea` (feat)
2. **Task 2: Sitzungswaechter — `fetchSessionState()` unterscheidet tote Sitzung von API-Ausfall** - `474d170` (feat)
3. **Task 3: Widgets-Seite uebersetzen, CHANGELOG ergaenzen, Gesamtlauf** - `2868ffe` (feat)
_Alle drei Tasks folgten TDD (RED zuerst bei Task 1 und 2, Task 3 ohne `tdd="true"`)._
## Files Created/Modified
- `apps/web/src/lib/safe-next.ts` - `buildNextParam()`/`sanitizeNextPath()`, reine Funktionen, Open-Redirect-Schutz
- `apps/web/src/lib/safe-next.test.ts` - 9 Testfaelle beider Funktionen
- `apps/web/src/middleware.ts` - beide Login-Umleitungen ueber gemeinsamen `redirectToLogin()`-Helfer mit `next`
- `apps/web/src/app/(auth)/login/page.tsx` - springt nach Erfolg auf `sanitizeNextPath(next)`
- `apps/web/src/lib/auth-actions.ts` - `fetchSessionState()` (neu, additiv), `fetchCurrentUser()` unveraendert
- `apps/web/src/lib/auth-actions.test.ts` - 8 Klassifikations-Testfaelle
- `apps/web/src/components/layout/header.tsx` - Waechter im useEffect ersetzt `fetchCurrentUser` durch `fetchSessionState`
- `apps/web/src/components/layout/header.test.tsx` - 4 Testfaelle (authenticated/unauthenticated auf Unterseite/auf `/`/unavailable)
- `apps/web/src/app/(portal)/settings/dashboard/page.tsx` - `tCommon('loading')` und `t('widgets.empty')` statt Festtext
- `apps/web/src/messages/de.json`, `en.json` - `settings.widgets.empty` (additiv)
- `CHANGELOG.md` - drei Stichpunkte unter „Behoben"
## Decisions Made
- `fetchCurrentUser()` unangetastet gelassen (Signatur- und Verhaltensgarantie fuer change-password/page.tsx und account-settings-form.tsx), `fetchSessionState()` daneben additiv mit kleiner Code-Duplikation der fetch-Aufrufzeilen
- Cookie-Loeschung ausschliesslich in der Server Action, nicht im Client-Header
- `next`-Wert wird in login/page.tsx erst beim Absenden aus `window.location.search` gelesen (kein `useSearchParams()`, wegen fehlender Suspense-Grenze bei der statisch vorgerenderten Anmeldeseite)
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
- `bug-report-button.test.tsx` (nicht Teil dieses Plans) schlug einmal im Gesamtlauf flakey fehl (Checkbox-Timing) und lief beim naechsten Lauf sowie isoliert gruen — ausserhalb des Scopes dieses Plans, nicht auto-gefixt, kein Deviation-Eintrag noetig.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Browser-Nachweis (ohne Sitzung `/settings/general/desktop` aufrufen, tote Sitzung simulieren, Widgets-Seite pruefen) steht laut Plan beim Orchestrator aus, nicht Teil dieser Ausfuehrung.
- Keine Blocker fuer Folgearbeiten; `fetchCurrentUser()` bleibt fuer bestehende Aufrufer nutzbar.
---
*Phase: quick-260917-gyd*
*Completed: 2026-09-17*
## Self-Check: PASSED
- FOUND: apps/web/src/lib/safe-next.ts
- FOUND: apps/web/src/lib/safe-next.test.ts
- FOUND: apps/web/src/middleware.ts
- FOUND: apps/web/src/app/(auth)/login/page.tsx
- FOUND: apps/web/src/lib/auth-actions.ts
- FOUND: apps/web/src/lib/auth-actions.test.ts
- FOUND: apps/web/src/components/layout/header.tsx
- FOUND: apps/web/src/components/layout/header.test.tsx
- FOUND: apps/web/src/app/(portal)/settings/dashboard/page.tsx
- FOUND: apps/web/src/messages/de.json
- FOUND: apps/web/src/messages/en.json
- FOUND: CHANGELOG.md
- FOUND commit: 4b279ea
- FOUND commit: 474d170
- FOUND commit: 2868ffe
@@ -0,0 +1,240 @@
---
phase: quick-260917-h2s
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260917-H2S]
files_modified:
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src-tauri/icons/nsis-header.bmp
- apps/desktop/src-tauri/icons/nsis-sidebar.bmp
- apps/web/src/middleware.ts
- apps/web/src/middleware.test.ts
- apps/web/src/lib/desktop-client.ts
- apps/web/src/lib/desktop-client.test.ts
- apps/web/src/components/desktop/desktop-download-links.tsx
- apps/web/src/components/desktop/desktop-download-links.test.tsx
- apps/web/src/components/desktop/desktop-context-menu-guard.tsx
- apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx
- apps/web/src/app/layout.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
estimate:
tokens: 48000
raw_tokens: 48000
tasks: 3
confidence: low
must_haves:
truths:
- "lib.rs: eine reine Funktion `with_desktop_marker(url: &tauri::Url) -> tauri::Url` haengt `desktop=1` als Query-Paar an einen Klon an (via `query_pairs_mut().append_pair`); BEIDE Navigationen zur Server-Adresse (`save_server_url` und die Startnavigation im `setup`) uebergeben `with_desktop_marker(&parsed)` an `window.navigate`. Der Wert `server_url` im Store bleibt ohne Parameter (weiterhin `parsed.as_str()`)."
- "lib.rs: eine reine Funktion `update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String)` liefert (Menuetext, Benachrichtigungstext): bei `version_changed` `Version {v} herunterladen` / `Neue Version {v} verfügbar – Download über das Symbol im Infobereich.`; sonst `Neuen Beta-Stand herunterladen` / `Neuer Beta-Stand {commit} verfügbar – Download über das Symbol im Infobereich.` (echte Umlaute wie im Bestand). Die Versionspruefung im `setup` nutzt genau diese Funktion; `is_newer` bleibt wie bisher (Versionsvergleich ODER Beta-Commit-Vergleich)."
- "lib.rs traegt ein `#[cfg(test)] mod tests` mit Tests fuer `with_desktop_marker` (Adresse ohne Pfad, Adresse mit vorhandenem Query, Original unveraendert) und `update_labels` (beide Zweige); `cargo fmt --check`, `cargo check`, `cargo clippy` und `cargo test --lib` in apps/desktop/src-tauri sind gruen."
- "middleware.ts: Helfer `withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse` setzt bei `req.nextUrl.searchParams.get('desktop') === '1'` das Cookie `tessera_desktop=1` (path `/`, maxAge 31536000, sameSite `lax`, httpOnly `false`, secure nur wenn `req.nextUrl.protocol === 'https:'`) auf die uebergebene Antwort und gibt sie zurueck; ohne Parameter Rueckgabe unveraendert. JEDE `return`-Anweisung der `middleware`-Funktion (Fruehausstieg oeffentliche Routen, Statics, Redirect ohne Session, Redirect Passwortwechsel, `NextResponse.next()` nach gueltigem JWT, Redirect bei ungueltigem JWT — und alle, die Plan 260917-gyd zwischenzeitlich ergaenzt hat) laeuft durch `withDesktopCookie(req, …)`."
- "middleware.test.ts (`// @vitest-environment node`) belegt: `/login?desktop=1` → Set-Cookie mit `tessera_desktop=1`, `Path=/`, `Max-Age=31536000`, `SameSite=lax`, ohne `Secure`, ohne `HttpOnly`; `/login` ohne Parameter → kein Set-Cookie; `/dashboard?desktop=1` ohne Session → Status 307, `location` enthaelt `/login`, Set-Cookie vorhanden; `https://…/login?desktop=1` → `Secure` gesetzt; `/dashboard?desktop=1` mit gueltigem HS256-JWT (jose `SignJWT`, `vi.stubEnv('JWT_SECRET', …)`) → Set-Cookie vorhanden und Header `x-middleware-next` = `1`."
- "apps/web/src/lib/desktop-client.ts exportiert `DESKTOP_COOKIE_NAME = 'tessera_desktop'`, `isDesktopClient(): boolean` (liest `document.cookie`, `typeof document === 'undefined'` → false, true genau wenn ein Eintrag `tessera_desktop=1` existiert) und den Hook `useIsDesktopClient(): boolean` (useState(false) + useEffect, damit SSR und erster Client-Render uebereinstimmen). desktop-client.test.ts prueft: ohne Cookie false, mit `tessera_desktop=1` true, mit `tessera_desktop=0` false, `document` undefiniert (vi.stubGlobal) → false, Hook via `renderHook` liefert nach dem Effekt true."
- "DesktopDownloadLinks rendert im Desktop-Client nichts: `useIsDesktopClient()` → `null`, und der Ladeeffekt bricht bei `isDesktopClient()` vor dem Aufruf von `loadDesktopLatest` ab. Neuer Test 4 (Cookie gesetzt, `loadDesktopLatest` haette beide Pakete geliefert): `loadDesktopLatest` wird NICHT aufgerufen, `container.firstChild` ist null. Cookie wird in `afterEach` geloescht; Tests 1-3 bleiben unveraendert gruen."
- "DesktopContextMenuGuard (`'use client'`, rendert `null`): bei `useIsDesktopClient()` true registriert ein Effekt einen `contextmenu`-Listener auf `document`, der `preventDefault()` ruft — AUSSER das Ziel liegt in `input`, `textarea`, `select` oder einem contenteditable-Bereich (`closest('input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"], [contenteditable=\"plaintext-only\"]')` oder `isContentEditable === true`). Aufraeumfunktion entfernt den Listener. Im RootLayout (`apps/web/src/app/layout.tsx`) eingebunden. Test (jsdom, MouseEvent `contextmenu` bubbles+cancelable): mit Cookie auf `div` → `dispatchEvent` false, auf `input`/`textarea`/Kind eines `[contenteditable=\"true\"]` → true; ohne Cookie auf `div` → true; nach `unmount()` auf `div` → true."
- "tauri.conf.json enthaelt `bundle.windows.nsis` mit exakt: `languages: [\"German\"]`, `displayLanguageSelector: false`, `installerIcon: \"icons/icon.ico\"`, `headerImage: \"icons/nsis-header.bmp\"`, `sidebarImage: \"icons/nsis-sidebar.bmp\"`, `installMode: \"currentUser\"`; alle Schluessel sind in `NsisConfig.properties` des lokalen CLI-Schemas (apps/desktop/node_modules/@tauri-apps/cli/config.schema.json) enthalten; die beiden BMP-Dateien liegen in apps/desktop/src-tauri/icons/ (BMP3, 150x57 bzw. 164x314); `cargo check` in src-tauri bleibt gruen (tauri-codegen parst die Konfiguration mit `deny_unknown_fields`)."
- "CHANGELOG.md `## Unveröffentlicht`: zwei Stichpunkte unter `### Geändert` (Beta-Hinweis nennt den Stand; Installer auf Deutsch mit Tessera-Grafik und -Symbol) und zwei unter `### Behoben` (Download-Links in der App ausgeblendet; Browser-Kontextmenue in der App ausgeblendet, in Eingabefeldern erhalten), Stil wie Bestand (Praefix `Desktop-App:`, typografische Anfuehrungszeichen, kein Punkt am Ende). Nur zusaetzliche Zeilen — vorhandene Zeilen (auch neue aus 260917-gyd) bleiben stehen."
- "`pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` enden gruen. Kein Docker-Build, kein `tauri build`, kein `git push`, keine Dateien ausserhalb von files_modified + .planning/."
- "Drei Commits: `feat(desktop): …` (Task 1), `feat(web): …` (Task 2), `feat(desktop): …`/`docs: …` (Task 3)."
artifacts:
- "apps/desktop/src-tauri/src/lib.rs — `with_desktop_marker`, `update_labels`, `mod tests`"
- "apps/desktop/src-tauri/tauri.conf.json — Block `bundle.windows.nsis`"
- "apps/desktop/src-tauri/icons/nsis-header.bmp, nsis-sidebar.bmp — neu (aus dem Scratchpad kopiert)"
- "apps/web/src/middleware.ts — `withDesktopCookie`"
- "apps/web/src/middleware.test.ts — neu (oder erweitert, falls 260917-gyd sie angelegt hat)"
- "apps/web/src/lib/desktop-client.ts + .test.ts — neu"
- "apps/web/src/components/desktop/desktop-download-links.tsx + .test.tsx — Client-Waechter, Test 4"
- "apps/web/src/components/desktop/desktop-context-menu-guard.tsx + .test.tsx — neu"
- "apps/web/src/app/layout.tsx — Guard eingebunden"
- "CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md — Stichpunkte/Saetze"
key_links:
- "Die Kette Client → Web ist: Rust haengt `desktop=1` an die ERSTE Navigation → Middleware setzt das Cookie auf die Antwort dieser Anfrage (auch wenn sie ein 307 nach /login ist — WebView2 uebernimmt Set-Cookie auf Redirects) → alle Folgeseiten sehen `tessera_desktop=1` in `document.cookie`. Faellt eines der drei Glieder aus, greift nichts; deshalb setzt die Middleware das Cookie auf JEDER Rueckgabe, nicht nur auf `next()`."
- "Der Tray-Eintrag „Update herunterladen“ oeffnet die Einstellungsseite im SYSTEM-Browser (opener), nicht im Client — dort muessen die Download-Links sichtbar bleiben. Deshalb bekommt diese URL KEIN `desktop=1`, und der Browser des Nutzers bekommt das Cookie nie."
- "`isDesktopClient()` liest das Cookie synchron; wuerde die Komponente es beim ersten Render nutzen, unterschieden sich Server-HTML (kein document) und Client-HTML → Hydration-Fehler. Darum der Hook mit useEffect; der Ladeeffekt darf `isDesktopClient()` dagegen direkt aufrufen (Effekte laufen nur im Client)."
- "Middleware-Datei wird VOR diesem Plan durch 260917-gyd geaendert (`next`-Rueckkehrparameter). Die Helferfunktion ist additiv: sie umschliesst Rueckgabewerte, aendert keine Redirect-Ziele. Der Test prueft `location` nur auf `enthaelt /login`, nicht auf exakte Gleichheit."
- "jsdom implementiert `HTMLElement.isContentEditable` nicht — die Ausnahme fuer contenteditable MUSS ueber `closest('[contenteditable…]')` laufen, sonst ist sie im Test unsichtbar und im Browser trotzdem aktiv (oder umgekehrt)."
- "tauri-codegen (`tauri::generate_context!()` in `run()`) parst tauri.conf.json beim `cargo check` mit `deny_unknown_fields`; ein Tippfehler im nsis-Block faellt lokal auf, obwohl kein Installer gebaut wird. Ob die BMPs korrekt eingebunden sind, zeigt erst der CI-Bau — Nachweis durch den Orchestrator auf der Windows-VM."
---
<objective>
Vier Befunde aus der Bedienprobe des Desktop-Clients auf der Windows-VM schliessen:
1. **Client-Erkennung.** Der Rust-Client haengt bei beiden Navigationen zur Server-Adresse `desktop=1` an; die Next.js-Middleware setzt daraufhin das Cookie `tessera_desktop=1` (ein Jahr, lax, nicht httpOnly). Web-Helfer `isDesktopClient()` + Hook `useIsDesktopClient()`.
2. **Download-Links** auf der Anmeldeseite erscheinen im Client nicht mehr (und der Client fragt `/desktop/latest` gar nicht erst an).
3. **Kontextmenue.** Ein kleiner Client-Waechter im RootLayout unterdrueckt das WebView2-Browser-Kontextmenue, laesst es in Eingabefeldern (input/textarea/select/contenteditable) aber zu.
4. **Beta-Label.** Gleiche Versionsnummer, anderer Commit → Tray „Neuen Beta-Stand herunterladen“ und Benachrichtigung „Neuer Beta-Stand {commit} verfügbar …“ statt der verwirrenden gleichen Version.
5. **Installer.** `bundle.windows.nsis` in tauri.conf.json: Deutsch ohne Sprachauswahl, Tessera-Symbol, Kopf- und Seitenbild (BMPs fertig im Scratchpad), currentUser.
Die Reihenfolge der Tasks folgt der Vorgabe des Orchestrators (Rust → Web → Installer/Doku). Die einzige lokal Ende-zu-Ende pruefbare Kette (Anfrage mit `desktop=1` → Cookie auf der Antwort → Hook → Komponente rendert nichts) liegt komplett in Task 2 — deshalb traegt Task 2 die Tracer-Rolle; Task 1 liefert den Einstiegspunkt (Rust) mit Unit-Test. Der Beweis ueber die WebView2-Grenze (Cookie im echten Client, deutscher Installer, Grafik, Symbol) erfolgt durch den Orchestrator nach dem CI-Bau auf der Windows-VM.
Purpose: Der Client soll sich wie eine App anfuehlen (keine Browser-Reste, keine sinnlosen Download-Angebote), der Beta-Update-Hinweis soll verstaendlich sein, und der Installer soll zur Marke und zur Sprache der Anwender passen.
Output: lib.rs mit zwei reinen Helfern + Tests; Middleware-Cookie + Test; desktop-client.ts + Test; angepasste DesktopDownloadLinks + Test; DesktopContextMenuGuard + Test; layout.tsx; nsis-Block + zwei BMPs; CHANGELOG + zwei Handbuchsaetze; drei Commits.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/src/lib.rs
@/home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri/tauri.conf.json
@/home/vicolab/projects/tessera-ctl/apps/web/src/middleware.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/desktop/desktop-download-links.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/desktop/desktop-download-links.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/app/layout.tsx
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Rust — `desktop=1` an beide Navigationen, Beta-Label per `update_labels`, Unit-Tests</name>
<files>apps/desktop/src-tauri/src/lib.rs</files>
<read_first>
- apps/desktop/src-tauri/src/lib.rs Z. 24-31 (Stil eines dokumentierten reinen Helfers: `api_url`), Z. 66-80 (`save_server_url`: `parsed` → `normalized` in den Store, danach Navigation), Z. 96-107 (Startnavigation im `setup`), Z. 197-231 (Versionspruefung: `is_newer`, Benachrichtigung, `update_item.set_text`)
- apps/desktop/src-tauri/build.rs Z. 1-16 (warum `APP_COMMIT` existiert — Beta-Kanal vergibt jedem Commit dieselbe Version)
</read_first>
<behavior>
`#[cfg(test)] mod tests` in lib.rs:
- `with_desktop_marker` auf `https://tessera.example.com` → `https://tessera.example.com/?desktop=1`
- `with_desktop_marker` auf `https://host/app?x=1` → `https://host/app?x=1&desktop=1`
- Das uebergebene Original hat nach dem Aufruf weiterhin `query() == None` (Klon, kein In-Place)
- `update_labels(true, "1.2.0", "abc1234")` → `("Version 1.2.0 herunterladen", "Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich.")`
- `update_labels(false, "1.1.0", "abc1234")` → `("Neuen Beta-Stand herunterladen", "Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich.")`
</behavior>
<action>
Zuerst die Tests aus `<behavior>` als `mod tests` ans Dateiende schreiben und `cargo test --lib` rot sehen (Funktionen existieren noch nicht). Dann:
1. Neben `api_url` eine reine Funktion `with_desktop_marker(url: &tauri::Url) -> tauri::Url` anlegen: Klon des Urls, `query_pairs_mut().append_pair("desktop", "1")`, Klon zurueckgeben. Deutscher Doc-Kommentar mit ae/oe/ue (Stil wie bei `api_url`): Warum der Parameter nur an die Navigation geht und nie in den Store (`server_url` bleibt die reine Adresse; die Middleware setzt daraus das Cookie `tessera_desktop`, siehe apps/web/src/middleware.ts), und dass die Tray-URL „Update herunterladen“ ihn bewusst NICHT bekommt (oeffnet im System-Browser, dort muessen die Download-Links sichtbar bleiben).
2. In `save_server_url` und in der Startnavigation des `setup` das Argument beider `window.navigate`-Aufrufe auf `with_desktop_marker(&parsed)` aendern. `normalized` (Store-Wert) bleibt `parsed.as_str()` — vor dem Anhaengen gebildet, also ohne Parameter.
3. Reine Funktion `update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String)` (Rueckgabe: Menuetext, Benachrichtigungstext) mit den beiden Zweigen aus `<behavior>`; echte Umlaute und Gedankenstrich exakt wie der bestehende Benachrichtigungstext. Doc-Kommentar: Beta-Kanal vergibt jedem Commit dieselbe X.Y.Z (D-07), darum nennt der zweite Zweig den Commit-Stempel statt der Version.
4. In der Versionspruefung: `let version_changed = info.version != app_version;` und `is_newer` daraus plus dem bestehenden Beta-Commit-Vergleich bilden (Logik unveraendert). Innerhalb von `if is_newer` ein `let (menu_text, body) = update_labels(version_changed, &info.version, &info.commit);` und beide bisherigen `format!`-Aufrufe (Benachrichtigungs-`body` und `update_item.set_text`) durch `body` bzw. `menu_text` ersetzen. Der Kommentarblock ueber `is_newer` bleibt, ein Satz ergaenzt, dass die Texte aus `update_labels` kommen.
5. `cargo fmt` anwenden (Bestand ist rustfmt-konform).
Nicht anfassen: Tray-Menue, Autostart, Fensterverhalten, `check_server`, build.rs.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo check && cargo clippy && cargo test --lib && test "$(grep -c 'with_desktop_marker(&parsed)' src/lib.rs)" = "2" && grep -q 'update_labels(version_changed' src/lib.rs</automated>
</verify>
<done>Beide Navigationen tragen `desktop=1`, der Store-Wert nicht; Beta-Bau mit gleicher Version zeigt „Neuen Beta-Stand herunterladen“ / „Neuer Beta-Stand {commit} verfügbar …“, Versionswechsel weiterhin „Version {v} herunterladen“; fmt/check/clippy/test gruen; Commit `feat(desktop): Client meldet sich per desktop=1, Beta-Hinweis nennt den Stand`.</done>
</task>
<task type="tracer" tdd="true">
<name>Task 2: Web — Middleware-Cookie, `desktop-client.ts`, DesktopDownloadLinks im Client aus, Kontextmenue-Waechter, Tests</name>
<files>apps/web/src/middleware.ts, apps/web/src/middleware.test.ts, apps/web/src/lib/desktop-client.ts, apps/web/src/lib/desktop-client.test.ts, apps/web/src/components/desktop/desktop-download-links.tsx, apps/web/src/components/desktop/desktop-download-links.test.tsx, apps/web/src/components/desktop/desktop-context-menu-guard.tsx, apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx, apps/web/src/app/layout.tsx</files>
<read_first>
- apps/web/src/middleware.ts — FRISCH lesen (Plan 260917-gyd hat sie vor diesem Plan geaendert: `next`-Rueckkehrparameter). Alle `return`-Anweisungen der `middleware`-Funktion zaehlen, wie sie JETZT sind.
- `ls apps/web/src/middleware.test.ts` — existiert sie bereits (aus 260917-gyd), wird sie um einen eigenen `describe`-Block erweitert statt neu angelegt.
- apps/web/src/components/desktop/desktop-download-links.test.tsx Z. 1-45 (next-intl-Mock, `vi.hoisted`-Mock fuer `@/lib/desktop`, `afterEach` mit cleanup)
- apps/web/src/lib/desktop.test.ts Z. 1-15 (Kopfkommentar-Stil fuer lib-Tests)
- apps/web/src/app/layout.tsx (Server Component; Client-Komponente darf importiert und gerendert werden)
</read_first>
<behavior>
middleware.test.ts (`// @vitest-environment node` als erste Zeile; `NextRequest` aus `next/server`, `SignJWT` aus `jose`; `vi.stubEnv('JWT_SECRET', 'test-secret')` in `beforeEach`, `vi.unstubAllEnvs()` in `afterEach`):
- Test 1: `new NextRequest('http://localhost:3000/login?desktop=1')` → `res.headers.get('set-cookie')` enthaelt `tessera_desktop=1`, `Path=/`, `Max-Age=31536000`, `SameSite=lax`; enthaelt NICHT `Secure`, NICHT `HttpOnly`.
- Test 2: `http://localhost:3000/login` ohne Parameter → `set-cookie` ist null.
- Test 3: `http://localhost:3000/dashboard?desktop=1` ohne Session-Cookie → `res.status` 307, `res.headers.get('location')` enthaelt `/login`, `set-cookie` enthaelt `tessera_desktop=1`.
- Test 4: `https://tessera.example.com/login?desktop=1` → `set-cookie` enthaelt `Secure`.
- Test 5: gueltiges JWT (`new SignJWT({ sub: 'u1' }).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime('5m').sign(new TextEncoder().encode('test-secret'))`) als Header `cookie: session=<token>` auf `http://localhost:3000/dashboard?desktop=1` → `set-cookie` enthaelt `tessera_desktop=1` und `res.headers.get('x-middleware-next')` ist `'1'`.
desktop-client.test.ts (jsdom; Cookie in `afterEach` per `document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'` loeschen):
- ohne Cookie → `isDesktopClient()` false
- `document.cookie = 'tessera_desktop=1; path=/'` → true
- `tessera_desktop=0` → false
- `vi.stubGlobal('document', undefined)` → false (danach `vi.unstubAllGlobals()`)
- Hook: Cookie gesetzt, `renderHook(() => useIsDesktopClient())` → `await waitFor(() => expect(result.current).toBe(true))`; ohne Cookie bleibt `result.current` false
desktop-download-links.test.tsx, neuer Test 4 („im Desktop-Client“): Cookie gesetzt, `loadDesktopLatest.mockResolvedValue({... files: { windows, linux } })`, `render`, `await act(async () => {})` → `expect(loadDesktopLatest).not.toHaveBeenCalled()`, `expect(container.firstChild).toBeNull()`. `afterEach` loescht das Cookie (Tests 1-3 laufen ohne Cookie weiter).
desktop-context-menu-guard.test.tsx (jsdom; Helfer `fire(el) = el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))` — Rueckgabe false bedeutet preventDefault):
- mit Cookie, `render(<DesktopContextMenuGuard />)`, `await act(async () => {})`: `fire(div)` → false; `fire(input)` → true; `fire(textarea)` → true; `fire(select)` → true; `fire(span in div[contenteditable="true"])` → true
- ohne Cookie: `fire(div)` → true
- mit Cookie, nach `unmount()`: `fire(div)` → true
</behavior>
<action>
Tests aus `<behavior>` zuerst schreiben, rot sehen, dann implementieren:
1. **middleware.ts** — Konstante `DESKTOP_COOKIE = 'tessera_desktop'` und Helfer `withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse` oberhalb von `middleware` anlegen: wenn `req.nextUrl.searchParams.get('desktop') === '1'`, `res.cookies.set(DESKTOP_COOKIE, '1', { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax', httpOnly: false, secure: req.nextUrl.protocol === 'https:' })`; immer `res` zurueckgeben. Kurzer Kommentar: Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs, `with_desktop_marker`) haengt den Parameter nur an seine erste Navigation; das Cookie muss deshalb auf JEDER Antwort landen, auch auf dem Fruehausstieg fuer oeffentliche Routen und auf Redirects — sonst geht die Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen); der Wert ist kein Geheimnis. Danach JEDE `return`-Anweisung innerhalb von `middleware` (einschliesslich solcher, die 260917-gyd ergaenzt hat) in `withDesktopCookie(req, …)` einhuellen; beim Zweig mit `response.cookies.delete('session')` die bestehende Variable durchreichen. Keine Redirect-Ziele, keine Reihenfolge aendern, kein React-Import in der Middleware. Die Middleware-Datei bleibt ansonsten unberuehrt.
2. **apps/web/src/lib/desktop-client.ts** — exportiert `DESKTOP_COOKIE_NAME = 'tessera_desktop'`, `isDesktopClient()` (bei `typeof document === 'undefined'` false; sonst `document.cookie.split(';').some((c) => c.trim() === `${DESKTOP_COOKIE_NAME}=1`)`) und `useIsDesktopClient()` (`useState(false)`, `useEffect(() => { setIsDesktop(isDesktopClient()); }, [])`, Rueckgabe des Zustands). Kopfkommentar wie in `desktop.ts`: Gegenstueck zur Middleware; Hook statt Direktaufruf beim Render, weil Server-HTML und erster Client-Render sonst auseinanderlaufen (Hydration).
3. **desktop-download-links.tsx** — `const isDesktop = useIsDesktopClient();` nach `useState`; im Ladeeffekt als erste Zeile `if (isDesktopClient()) return;` (kein Request aus dem Client); nach allen Hooks `if (isDesktop) return null;` vor der bestehenden `!info`-Pruefung. Doc-Kommentar der Komponente um einen Satz ergaenzen (im Desktop-Client entfaellt der Block, Kennung ueber Cookie).
4. **desktop-context-menu-guard.tsx** — `'use client'`; `export function DesktopContextMenuGuard()`: `const isDesktop = useIsDesktopClient();` `useEffect` mit Abhaengigkeit `[isDesktop]`: wenn false, nichts; sonst Handler `(event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (target.closest(EDITABLE_SELECTOR) || (target as HTMLElement).isContentEditable === true) return; event.preventDefault(); }` mit `EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]'`; `document.addEventListener('contextmenu', handler)`; Aufraeumfunktion entfernt ihn. Rueckgabe `null`. Kommentar: WebView2 zeigt sonst Zurueck/Aktualisieren/Speichern unter/Drucken; in Eingabefeldern bleibt Kopieren/Einfuegen erreichbar; jsdom kennt `isContentEditable` nicht, daher zusaetzlich der Selektor.
5. **layout.tsx** — `import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';` und `<DesktopContextMenuGuard />` unmittelbar vor `{children}` innerhalb von `NextIntlClientProvider` rendern.
6. Keine neuen i18n-Schluessel (nichts wird angezeigt). de.json/en.json nicht anfassen.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/middleware.test.ts src/lib/desktop-client.test.ts src/components/desktop && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check && grep -q "withDesktopCookie(req" apps/web/src/middleware.ts && grep -q "DesktopContextMenuGuard" apps/web/src/app/layout.tsx</automated>
</verify>
<done>Anfrage mit `?desktop=1` bekommt auf jeder Antwortart das Cookie; `isDesktopClient()`/`useIsDesktopClient()` lesen es; DesktopDownloadLinks rendert im Client nichts und laedt nichts; Rechtsklick ausserhalb von Eingabefeldern ist im Client unterdrueckt; alle Web-Tests (bisher 381 + neue) und type-check gruen; Commit `feat(web): Desktop-Client per Cookie erkennen — Download-Links und Browser-Kontextmenü in der App aus`.</done>
</task>
<task type="auto">
<name>Task 3: NSIS-Installer auf Deutsch mit Tessera-Grafik, CHANGELOG, Handbuchsaetze</name>
<files>apps/desktop/src-tauri/tauri.conf.json, apps/desktop/src-tauri/icons/nsis-header.bmp, apps/desktop/src-tauri/icons/nsis-sidebar.bmp, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md</files>
<read_first>
- apps/desktop/src-tauri/tauri.conf.json (Block `bundle`, es gibt noch keinen `windows`-Schluessel)
- CHANGELOG.md Z. 1-35 (`## Unveröffentlicht`, Stil der Stichpunkte mit Praefix `Desktop-App:`) — FRISCH lesen, 260917-gyd hat Zeilen ergaenzt
- docs/anleitung-anwender.md Z. 176-197 („Installation unter Windows“, Tray-Menue-Liste)
- docs/anleitung-entwicklung.md Z. 162-166 (Absatz „Der Windows-Installer wird nur im CI gebaut“)
</read_first>
<action>
1. Die zwei fertigen Bilder kopieren: `cp /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/36238f40-3162-4b4a-9c11-56905a933eef/scratchpad/nsis-img/nsis-header.bmp /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/36238f40-3162-4b4a-9c11-56905a933eef/scratchpad/nsis-img/nsis-sidebar.bmp apps/desktop/src-tauri/icons/` (nur die beiden .bmp, nicht die -preview.png). Sollte der Scratchpad-Ordner fehlen, STOPP und an den Orchestrator melden — nicht selbst neue Bilder erzeugen.
2. In tauri.conf.json unter `bundle` (nach `icon`) den Block `"windows": { "nsis": { "languages": ["German"], "displayLanguageSelector": false, "installerIcon": "icons/icon.ico", "headerImage": "icons/nsis-header.bmp", "sidebarImage": "icons/nsis-sidebar.bmp", "installMode": "currentUser" } }` ergaenzen (Pfade relativ zu src-tauri wie die `icon`-Liste; 2-Leerzeichen-Einrueckung wie im Bestand). Keine weiteren Schluessel (kein `template`, kein `installerHooks`).
3. CHANGELOG.md, `## Unveröffentlicht`, jeweils als NEUE Zeilen am Ende der Liste: unter `### Geändert` „Desktop-App: Hinweis auf einen neuen Beta-Stand nennt den Stand (Commit-Kürzel) statt der unveränderten Versionsnummer“ und „Desktop-App: Windows-Installer auf Deutsch mit Tessera-Grafik und -Symbol“; unter `### Behoben` „Desktop-App: Download-Links auf der Anmeldeseite werden in der App nicht mehr angeboten“ und „Desktop-App: Rechtsklick zeigte das Browser-Kontextmenü (Zurück, Aktualisieren, Drucken …) – in der App ausgeblendet, in Eingabefeldern bleibt es erhalten“. Bestehende Zeilen (auch neue aus 260917-gyd) bleiben unveraendert.
4. docs/anleitung-anwender.md: Im Absatz „Installation unter Windows“ (Z. 178) nach dem Satz zu „Trotzdem ausführen“ ergaenzen: „Der Installationsassistent führt auf Deutsch durch die Installation; sie erfolgt für den angemeldeten Benutzer und benötigt keine Administratorrechte.“ In der Tray-Menue-Liste (Z. 193) hinter **Update herunterladen** ergaenzen: „ — wird aktiv, sobald eine neue Version vorliegt (auf dem Beta-Kanal: „Neuen Beta-Stand herunterladen“)“. Sie-Form, typografische Anfuehrungszeichen wie im Bestand.
5. docs/anleitung-entwicklung.md: Im Absatz Z. 162-166 einen Satz anfuegen: Sprache, Symbol und Bilder des Installers stehen in `apps/desktop/src-tauri/tauri.conf.json` unter `bundle.windows.nsis` (Deutsch ohne Sprachauswahl, `icons/nsis-header.bmp` 150×57 und `icons/nsis-sidebar.bmp` 164×314 als 24-Bit-BMP); Aenderungen daran lassen sich nur ueber den CI-Bau auf einem Windows-Rechner pruefen, lokal validiert `cargo check` lediglich die Schluessel.
6. Gate laut `<verify>`: Schema-Pruefung per node gegen `NsisConfig.properties` und `NSISInstallerMode` des lokalen CLI-Schemas, Existenz und Format der BMPs, `cargo check` (tauri-codegen parst die Konfiguration mit `deny_unknown_fields`). Kein `tauri build`, kein Docker.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e 'const fs=require("fs"),p=require("path");const s=require("./apps/desktop/node_modules/@tauri-apps/cli/config.schema.json");const c=JSON.parse(fs.readFileSync("apps/desktop/src-tauri/tauri.conf.json","utf8"));const n=c.bundle.windows.nsis;const allowed=Object.keys(s.definitions.NsisConfig.properties);const bad=Object.keys(n).filter(k=>!allowed.includes(k));if(bad.length){console.error("unbekannte Schluessel:",bad);process.exit(1)}const modes=s.definitions.NSISInstallerMode.oneOf.map(o=>o.enum[0]);if(!modes.includes(n.installMode)){console.error("installMode ungueltig");process.exit(1)}if(JSON.stringify(n.languages)!==JSON.stringify(["German"])||n.displayLanguageSelector!==false){console.error("languages/displayLanguageSelector");process.exit(1)}for(const k of["installerIcon","headerImage","sidebarImage"]){const f=p.join("apps/desktop/src-tauri",n[k]);if(!fs.existsSync(f)){console.error("fehlt:",f);process.exit(1)}}console.log("nsis-Block OK")' && magick identify -format '%f %m %wx%h\n' apps/desktop/src-tauri/icons/nsis-header.bmp apps/desktop/src-tauri/icons/nsis-sidebar.bmp | grep -q 'nsis-header.bmp BMP3 150x57' && magick identify -format '%f %m %wx%h\n' apps/desktop/src-tauri/icons/nsis-sidebar.bmp | grep -q 'BMP3 164x314' && (cd apps/desktop/src-tauri && cargo check) && test "$(grep -c '^- Desktop-App: ' CHANGELOG.md)" -ge 7 && grep -q 'bundle.windows.nsis' docs/anleitung-entwicklung.md && grep -q 'Installationsassistent' docs/anleitung-anwender.md</automated>
</verify>
<done>nsis-Block mit den sechs Schluesseln steht in tauri.conf.json und besteht Schema- und codegen-Pruefung; beide BMPs liegen in icons/; CHANGELOG traegt vier neue Stichpunkte; beide Handbuecher nennen den deutschen Installer bzw. den Ort der Konfiguration; Commit `feat(desktop): Windows-Installer auf Deutsch mit Tessera-Grafik und -Symbol; CHANGELOG, Handbuch`. Der eigentliche Nachweis (deutscher Dialog, Kopf-/Seitenbild, Symbol im Downloads-Fenster, Cookie im echten Client) folgt durch den Orchestrator nach dem CI-Bau auf der Windows-VM — im SUMMARY als offen fuehren.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser/WebView → Middleware | Query-Parameter `desktop=1` und Cookie `tessera_desktop` sind frei setzbar (jeder Browser kann sie senden) |
| Rust-Client → gespeicherte Server-Adresse | Nutzer-Eingabe wird als URL geparst und um ein Query-Paar erweitert |
| Installer-Konfiguration → NSIS-Bundler im CI | BMP/ICO-Dateien aus dem Repo werden in den Installer eingebettet |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-H2S-01 | Spoofing | `withDesktopCookie` / `isDesktopClient` | low | accept | Das Cookie steuert ausschliesslich Kosmetik (Download-Links, Kontextmenue). Kein Auth-, Rechte- oder Datenpfad haengt daran; ein manuell gesetztes Cookie im Browser blendet nur Links aus. Middleware-Reihenfolge (Session-Pruefung, Redirects) bleibt unveraendert. |
| T-H2S-02 | Information Disclosure | Cookie `tessera_desktop` (httpOnly false) | low | accept | Wert ist die Konstante `1`, kein Geheimnis; `sameSite: lax`, `secure` bei https. Bewusst per JavaScript lesbar, weil der Web-Helfer es braucht. |
| T-H2S-03 | Tampering | `with_desktop_marker` (Rust) | low | mitigate | `query_pairs_mut().append_pair` kodiert korrekt; die Nutzer-URL wurde zuvor von `tauri::Url::parse` validiert (Schema http/https in `check_server`). Der Store haelt weiterhin die unveraenderte Adresse. Unit-Test belegt Klon statt In-Place. |
| T-H2S-04 | Denial of Service | `DesktopContextMenuGuard` | low | mitigate | Listener nur im Desktop-Client aktiv; Ausnahme fuer Eingabefelder/contenteditable per Selektor UND `isContentEditable`, damit Kopieren/Einfuegen erreichbar bleibt; Aufraeumfunktion entfernt den Listener (Test). |
| T-H2S-05 | Tampering | `icons/nsis-*.bmp`, `bundle.windows.nsis` | low | accept | Bilder stammen aus dem eigenen, per resvg gerenderten Repo-Icon; Format per `magick identify` belegt; Schluessel gegen das lokale CLI-Schema und per `cargo check` (deny_unknown_fields) geprueft. |
| T-H2S-SC | Tampering | npm/pip/cargo installs | low | accept | Keine neue Abhaengigkeit in diesem Plan (kein `pnpm add`, kein `cargo add`); package-legitimacy gate entfaellt. |
</threat_model>
<verification>
- Rust: `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` in apps/desktop/src-tauri gruen; beide `window.navigate`-Aufrufe nutzen `with_desktop_marker(&parsed)`.
- Web: `pnpm --filter @tessera/web exec vitest run` (alle Dateien, bisher 57/381 plus die neuen) und `pnpm --filter @tessera/web type-check` gruen.
- Middleware-Test belegt das Cookie auf Fruehausstieg, Redirect ohne Session, `next()` nach gueltigem JWT, `Secure` nur bei https.
- tauri.conf.json: nsis-Block besteht Schema-Pruefung (node) und `cargo check`; BMPs vorhanden mit BMP3 150x57 / 164x314.
- CHANGELOG: vier neue `Desktop-App:`-Stichpunkte; Handbuecher ergaenzt.
- Offen (nicht lokal pruefbar, Orchestrator auf der Windows-VM nach CI-Bau): Cookie im echten Client gesetzt, keine Download-Links auf der Anmeldeseite im Client, kein Browser-Kontextmenue, Beta-Label mit Commit, deutscher Installer mit Tessera-Grafik und -Symbol.
</verification>
<success_criteria>
- Alle `must_haves.truths` erfuellt; drei Commits ohne Push, ohne Docker-Build, ohne `tauri build`.
- Keine Datei ausserhalb von `files_modified` + `.planning/` veraendert (`git status` vor dem letzten Commit gegenpruefen).
- SUMMARY nennt die offenen VM-Nachweise ausdruecklich.
</success_criteria>
<output>
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-h2s-desktop-client-web-erkennt-den-client-do/260917-h2s-SUMMARY.md` when done
</output>
@@ -0,0 +1,109 @@
---
phase: quick-260917-h2s
plan: 01
subsystem: desktop-client, web-middleware
tags: [desktop, tauri, nextjs, middleware, ux]
status: complete
dependency-graph:
requires: []
provides:
- "with_desktop_marker / update_labels (apps/desktop/src-tauri/src/lib.rs)"
- "withDesktopCookie (apps/web/src/middleware.ts)"
- "isDesktopClient / useIsDesktopClient (apps/web/src/lib/desktop-client.ts)"
- "DesktopContextMenuGuard (apps/web/src/components/desktop/desktop-context-menu-guard.tsx)"
affects:
- "apps/web/src/components/desktop/desktop-download-links.tsx"
- "apps/web/src/app/layout.tsx"
- "apps/desktop/src-tauri/tauri.conf.json"
tech-stack:
added: []
patterns:
- "Cookie-basierte Client-Erkennung: Query-Parameter auf der ersten Navigation -> Middleware setzt Cookie auf jeder Antwort -> Hook liest es hydration-sicher"
key-files:
created:
- apps/web/src/lib/desktop-client.ts
- apps/web/src/lib/desktop-client.test.ts
- apps/web/src/middleware.test.ts
- apps/web/src/components/desktop/desktop-context-menu-guard.tsx
- apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx
modified:
- apps/desktop/src-tauri/src/lib.rs
- apps/desktop/src-tauri/tauri.conf.json
- apps/desktop/src-tauri/icons/nsis-header.bmp
- apps/desktop/src-tauri/icons/nsis-sidebar.bmp
- apps/web/src/middleware.ts
- apps/web/src/components/desktop/desktop-download-links.tsx
- apps/web/src/components/desktop/desktop-download-links.test.tsx
- apps/web/src/app/layout.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
decisions:
- "Beim Handbuchsatz zu Trotzdem ausführen wurde Der Grund dafür zu Der Grund für die Windows-Meldung umformuliert, weil der neu eingefuegte Satz sonst den Bezug des Pronomens verschoben haette (redaktionelle Praezisierung, kein inhaltlicher Unterschied zum Plan)."
metrics:
duration: 24min
completed: 2026-09-17
actuals:
tokens: 39000
tasks: 3
commits: 3
plan_head_before: 98fad86
---
# Phase quick-260917-h2s Plan 01: Desktop-Client-Erkennung, Beta-Label, deutscher Installer Summary
Der Desktop-Client meldet sich jetzt beim Web per Cookie an, wodurch Download-Links und das Browser-Kontextmenü in der App verschwinden; der Beta-Update-Hinweis nennt bei gleicher Version den Commit-Stand statt der verwirrenden gleichen Versionsnummer, und der Windows-Installer läuft auf Deutsch mit Tessera-Grafik und -Symbol.
## Was wurde gebaut
**Task 1 — Rust (`apps/desktop/src-tauri/src/lib.rs`, Commit `5bdabf5`):**
- `with_desktop_marker(url)` hängt `desktop=1` als Query-Paar an einen Klon der Adresse an; beide `window.navigate`-Aufrufe (`save_server_url`, Startnavigation im `setup`) nutzen sie. Der im Store gespeicherte `server_url`-Wert bleibt unverändert (Parameter geht nur in die Navigation).
- `update_labels(version_changed, version, commit)` liefert Menü-/Benachrichtigungstext: bei Versionswechsel wie bisher „Version {v} herunterladen"; bei gleicher Version (Beta-Kanal, neuer Commit) „Neuen Beta-Stand herunterladen" / „Neuer Beta-Stand {commit} verfügbar …".
- `mod tests` deckt beide Helfer ab (5 Tests); `cargo fmt --check`, `cargo check`, `cargo clippy`, `cargo test --lib` grün.
**Task 2 — Web, Tracer (Commit `d9b94bd`):**
- `withDesktopCookie` in `middleware.ts` setzt `tessera_desktop=1` (Path `/`, ein Jahr, `SameSite=lax`, ohne `HttpOnly`, `Secure` nur bei https) auf **jede** Antwort der `middleware`-Funktion, sobald `?desktop=1` anliegt — auch auf dem Frühausstieg für öffentliche Routen und auf Redirects. Die von Plan 260917-gyd zwischenzeitlich ergänzten Rückgaben (u. a. der `next`-Redirect) sind mit eingeschlossen.
- `apps/web/src/lib/desktop-client.ts`: `isDesktopClient()` liest das Cookie synchron (SSR-sicher: `false` ohne `document`); `useIsDesktopClient()` kapselt es per `useEffect`, damit Server- und erster Client-Render übereinstimmen.
- `DesktopDownloadLinks` bricht den Ladeeffekt im Desktop-Client vor dem Request ab und rendert nichts.
- `DesktopContextMenuGuard` (neu, in `layout.tsx` eingebunden) unterdrückt das WebView2-Kontextmenü außerhalb von Eingabefeldern/contenteditable-Bereichen.
- Tracer-Gate: Die komplette Kette (Anfrage mit `?desktop=1` → Cookie auf der Antwort → Hook → Komponente rendert nichts) wurde Ende-zu-Ende durch die volle Testsuite (`pnpm --filter @tessera/web exec vitest run`, 417/417 grün) und `type-check` bestätigt, bevor Task 3 begann.
**Task 3 — Installer, CHANGELOG, Handbuch (Commit `2cd4adc`):**
- `bundle.windows.nsis` in `tauri.conf.json`: `languages: ["German"]`, `displayLanguageSelector: false`, `installerIcon`, `headerImage`, `sidebarImage`, `installMode: "currentUser"`.
- Beide BMPs aus dem Scratchpad nach `icons/` kopiert (`nsis-header.bmp` 150×57, `nsis-sidebar.bmp` 164×314, beide `BMP3`).
- CHANGELOG: vier neue `Desktop-App:`-Stichpunkte (zwei unter „Geändert", zwei unter „Behoben").
- `docs/anleitung-anwender.md`: Satz zum deutschen Installationsassistenten (kein Admin nötig); Tray-Eintrag „Update herunterladen" erklärt (Beta-Text).
- `docs/anleitung-entwicklung.md`: Ort der nsis-Konfiguration und Hinweis, dass nur der CI-Bau auf Windows den echten Nachweis liefert.
## Deviations from Plan
### Auto-fixed Issues
None — plan executed exactly as written. Eine redaktionelle Umformulierung (siehe `decisions` oben) war nötig, damit ein Pronomenbezug im Handbuchsatz nicht verrutscht; inhaltlich deckt sich der Text mit der Plan-Vorgabe.
## Offene Nachweise (nicht lokal prüfbar)
Wie im Plan vorgesehen, bleibt der Beweis über die WebView2-Grenze durch den Orchestrator nach dem CI-Bau auf der Windows-VM offen:
- Cookie `tessera_desktop=1` im echten Client gesetzt (Bedienprobe)
- Keine Download-Links auf der Anmeldeseite im Client
- Kein Browser-Kontextmenü im Client, aber in Eingabefeldern erhalten
- Beta-Label „Neuen Beta-Stand herunterladen" / Benachrichtigung mit Commit-Kürzel
- Deutscher Installer mit Tessera-Kopf-/Seitenbild und -Symbol
## Verification
- `cargo fmt --check && cargo check && cargo clippy && cargo test --lib` (apps/desktop/src-tauri): grün, 5/5 Tests
- `pnpm --filter @tessera/web exec vitest run`: 417/417 Tests grün (63 Dateien)
- `pnpm --filter @tessera/web type-check`: grün
- nsis-Schema-Prüfung (node gegen `NsisConfig.properties`/`NSISInstallerMode`): OK
- `magick identify`: `nsis-header.bmp BMP3 150x57`, `nsis-sidebar.bmp BMP3 164x314`
- `git diff --name-only 98fad86 HEAD` deckt sich exakt mit `files_modified` aus dem Plan-Frontmatter; kein Docker-Build, kein `tauri build`, kein `git push`.
## Self-Check: PASSED
- FOUND: apps/desktop/src-tauri/src/lib.rs (with_desktop_marker, update_labels, mod tests)
- FOUND: apps/web/src/middleware.ts (withDesktopCookie)
- FOUND: apps/web/src/lib/desktop-client.ts
- FOUND: apps/web/src/components/desktop/desktop-context-menu-guard.tsx
- FOUND: apps/desktop/src-tauri/icons/nsis-header.bmp, nsis-sidebar.bmp
- FOUND commit 5bdabf5, d9b94bd, 2cd4adc in `git log --oneline`
+9
View File
@@ -10,11 +10,15 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Kalender-Widget: Monatsübersicht mit Terminanzahl je Tag, Termine beim Überfahren, darunter „Nächste Termine”
- Kalender-Widget: Einstellungen für Monatsansicht, Anzahl und Zeitraum der Termine
- Favoriten-Widget: optionaler Titel (ohne Titel keine Kopfzeile)
- Einstellungen → Konto: Akzentfarbe zusätzlich als Hex-Code eingebbar (z. B. #ffed00)
### Geändert
- Kalenderquellen: Adressfeld zeigt je Typ ein Beispiel; bei Exchange EWS Hinweis auf `/EWS/Exchange.asmx`
- Kalender-Widget: Plakette am Tag in der Farbe des Kalenders; mehrere Kalender am selben Tag zeigen je einen kleinen Kreis
- Tessera-Bildmarke: die gedrehte Kachel übernimmt die persönliche Akzentfarbe
- Desktop-App: Hinweis auf einen neuen Beta-Stand nennt den Stand (Commit-Kürzel) statt der unveränderten Versionsnummer
- Desktop-App: Windows-Installer auf Deutsch mit Tessera-Grafik und -Symbol
### Entfernt
@@ -28,6 +32,11 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Desktop-App: Symbol zeigte eine „1“ statt des Tessera-T – die gedrehte gelbe Kachel fehlte
- Desktop-App: „Beenden“ im Infobereich-Menü beendete die App nicht
- Desktop-App: „Öffnen“ im Infobereich-Menü und Klick auf das Symbol holten ein minimiertes Fenster nicht zurück
- Anmeldung: nach der Anmeldung geht es zur ursprünglich aufgerufenen Seite weiter statt immer zum Dashboard (z. B. beim Link „Update herunterladen“ aus der Desktop-App)
- Anmeldung: eine nicht mehr gültige Sitzung (z. B. nach Neuanlage der Datenbank) zeigte ein leeres Portal mit „?“-Avatar und „Keine Module“ – jetzt Abmeldung und Anmeldeseite
- Einstellungen → Widgets: Lade- und Leerhinweis waren nur auf Englisch
- Desktop-App: Download-Links auf der Anmeldeseite werden in der App nicht mehr angeboten
- Desktop-App: Rechtsklick zeigte das Browser-Kontextmenü (Zurück, Aktualisieren, Drucken …) – in der App ausgeblendet, in Eingabefeldern bleibt es erhalten
## 1.1.0 – 2026-09-16
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+105 -13
View File
@@ -30,6 +30,47 @@ fn api_url(server: &str, path: &str) -> String {
format!("{}/api-proxy{}", server.trim_end_matches('/'), path)
}
/// Haengt das Query-Paar `desktop=1` an einen Klon der Adresse an und gibt
/// den Klon zurueck -- das Original bleibt unveraendert. Der Parameter geht
/// NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse); daraus liest die
/// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Die
/// Tray-URL "Update herunterladen" bekommt den Parameter bewusst NICHT --
/// sie oeffnet im System-Browser, dort muessen die Download-Links sichtbar
/// bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
let mut marked = url.clone();
marked.query_pairs_mut().append_pair("desktop", "1");
marked
}
/// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der
/// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version (D-07,
/// desktop-collect.sh) -- hat sich nur der Commit geaendert, waere der reine
/// Versionstext ("Version 1.1.0 herunterladen") verwirrend, weil der Client
/// bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version.
fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String) {
if version_changed {
(
format!("Version {} herunterladen", version),
format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
version
),
)
} else {
(
"Neuen Beta-Stand herunterladen".to_string(),
format!(
"Neuer Beta-Stand {} verfügbar – Download über das Symbol im Infobereich.",
commit
),
)
}
}
#[tauri::command]
async fn check_server(url: String) -> Result<String, String> {
let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?;
@@ -73,7 +114,7 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
store.save().map_err(|e| e.to_string())?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.navigate(parsed);
let _ = window.navigate(with_desktop_marker(&parsed));
}
Ok(())
@@ -84,7 +125,10 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![check_server, save_server_url])
.setup(|app| {
@@ -99,7 +143,7 @@ pub fn run() {
url_for_check = Some(url_str.to_string());
if let Some(window) = app.get_webview_window("main") {
if let Ok(parsed) = url_str.parse::<tauri::Url>() {
let _ = window.navigate(parsed);
let _ = window.navigate(with_desktop_marker(&parsed));
}
}
}
@@ -151,8 +195,10 @@ pub fn run() {
}
"update" => {
if let Some(server) = &server_for_menu {
let target =
format!("{}/settings/general/desktop", server.trim_end_matches('/'));
let target = format!(
"{}/settings/general/desktop",
server.trim_end_matches('/')
);
let _ = app.opener().open_url(target, None::<&str>);
}
}
@@ -208,21 +254,21 @@ pub fn run() {
// (D-07, desktop-collect.sh) -- ohne den Commit-Vergleich saehe
// ein Beta-Client zwischen zwei Freigabe-Tags nie einen neueren
// Bau (WR-02, Code-Review Phase 18). Fuer den Live-Kanal bleibt
// es beim reinen Versionsvergleich.
let is_newer = info.version != app_version
// es beim reinen Versionsvergleich. Die Texte fuer Menue und
// Benachrichtigung liefert `update_labels`.
let version_changed = info.version != app_version;
let is_newer = version_changed
|| (info.channel == "beta" && info.commit != app_commit);
if is_newer {
let (menu_text, body) =
update_labels(version_changed, &info.version, &info.commit);
let _ = app_handle
.notification()
.builder()
.title("Tessera-Update")
.body(format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
info.version
))
.body(body)
.show();
let _ = update_item
.set_text(format!("Version {} herunterladen", info.version));
let _ = update_item.set_text(menu_text);
let _ = update_item.set_enabled(true);
}
}
@@ -255,3 +301,49 @@ pub fn run() {
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_desktop_marker_ohne_pfad_haengt_query_an() {
let url = tauri::Url::parse("https://tessera.example.com").unwrap();
let marked = with_desktop_marker(&url);
assert_eq!(marked.as_str(), "https://tessera.example.com/?desktop=1");
}
#[test]
fn with_desktop_marker_mit_vorhandenem_query_haengt_an() {
let url = tauri::Url::parse("https://host/app?x=1").unwrap();
let marked = with_desktop_marker(&url);
assert_eq!(marked.as_str(), "https://host/app?x=1&desktop=1");
}
#[test]
fn with_desktop_marker_laesst_original_unveraendert() {
let url = tauri::Url::parse("https://host/app").unwrap();
let _ = with_desktop_marker(&url);
assert_eq!(url.query(), None);
}
#[test]
fn update_labels_bei_versionswechsel() {
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
assert_eq!(menu_text, "Version 1.2.0 herunterladen");
assert_eq!(
body,
"Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich."
);
}
#[test]
fn update_labels_bei_gleicher_version_beta_commit() {
let (menu_text, body) = update_labels(false, "1.1.0", "abc1234");
assert_eq!(menu_text, "Neuen Beta-Stand herunterladen");
assert_eq!(
body,
"Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich."
);
}
}
+11 -1
View File
@@ -37,6 +37,16 @@
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico"
]
],
"windows": {
"nsis": {
"languages": ["German"],
"displayLanguageSelector": false,
"installerIcon": "icons/icon.ico",
"headerImage": "icons/nsis-header.bmp",
"sidebarImage": "icons/nsis-sidebar.bmp",
"installMode": "currentUser"
}
}
}
}
+7 -1
View File
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { login } from '@/lib/auth-actions';
import { sanitizeNextPath } from '@/lib/safe-next';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { TesseraLogo } from '@/components/brand/tessera-logo';
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
@@ -30,7 +31,12 @@ export default function LoginPage() {
startTransition(async () => {
const result = await login(formData);
if (result.success) {
window.location.href = '/';
// `next` erst beim Absenden aus window.location.search lesen statt
// per useSearchParams(): der Hook verlangt in Next 15 eine
// Suspense-Grenze, sonst bricht `next build` fuer die statisch
// vorgerenderte Anmeldeseite ab (quick-260917-gyd).
const next = new URLSearchParams(window.location.search).get('next');
window.location.href = sanitizeNextPath(next);
} else {
setError(result.error ?? 'invalidCredentials');
}
@@ -11,6 +11,7 @@ import { fetchWidgets } from '@/lib/dashboard-api';
*/
export default function WidgetSettingsPage() {
const t = useTranslations('settings');
const tCommon = useTranslations('common');
const [widgets, setWidgets] = useState<
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
>([]);
@@ -32,10 +33,10 @@ export default function WidgetSettingsPage() {
</h1>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
) : widgets.length === 0 ? (
<p className="text-sm text-muted-foreground">
No widgets placed on dashboard yet.
{t('widgets.empty')}
</p>
) : (
<WidgetSettingsPanel
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ThemeProvider } from 'next-themes';
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
import './globals.css';
export const metadata: Metadata = {
@@ -27,6 +28,7 @@ export default async function RootLayout({
disableTransitionOnChange
>
<NextIntlClientProvider messages={messages}>
<DesktopContextMenuGuard />
{children}
</NextIntlClientProvider>
</ThemeProvider>
+6 -1
View File
@@ -8,7 +8,12 @@
* mandantenspezifische Gestaltung setzt an dieser Datei an.
*/
/** Gelbwert der gedrehten Signalkachel. */
/**
* Standard-Gelb der gedrehten Signalkachel und Rueckfall, wenn keine
* persoenliche Akzentfarbe (`--primary`) gesetzt ist. Die Kachel selbst wird
* in tessera-logo.tsx per `style={{ fill: var(--primary, BRAND_YELLOW) }}`
* gefuellt -- nicht mehr per festem `fill`-Praesentationsattribut.
*/
export const BRAND_YELLOW = '#ffed00';
/** Olivton der vier achsenparallelen Kacheln. */
@@ -1,6 +1,6 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BRAND_YELLOW } from './brand';
import { BRAND_OLIVE, BRAND_YELLOW } from './brand';
import { TesseraLogo } from './tessera-logo';
import de from '@/messages/de.json';
import en from '@/messages/en.json';
@@ -94,18 +94,28 @@ describe('TesseraLogo', () => {
expect(plate?.getAttribute('class')).not.toContain('dark:stroke-white/25');
});
it('renders exactly five tiles, exactly one in the brand yellow and exactly one rotated', () => {
it('renders exactly five tiles; only the rotated one is filled from the accent token with the brand yellow as fallback', () => {
render(<TesseraLogo variant="mark" />);
const mark = screen.getByRole('img', { name: 'Tessera Logo' });
const tiles = Array.from(mark.querySelectorAll('g rect'));
const tiles = Array.from(mark.querySelectorAll('g rect')) as SVGRectElement[];
expect(tiles).toHaveLength(5);
const yellowTiles = tiles.filter((tile) => tile.getAttribute('fill') === BRAND_YELLOW);
expect(yellowTiles).toHaveLength(1);
const accentTiles = tiles.filter((tile) => tile.style.fill === `var(--primary, ${BRAND_YELLOW})`);
expect(accentTiles).toHaveLength(1);
expect(accentTiles[0].hasAttribute('transform')).toBe(true);
const rotatedTiles = tiles.filter((tile) => tile.hasAttribute('transform'));
expect(rotatedTiles).toHaveLength(1);
const presentationYellowTiles = tiles.filter((tile) => tile.getAttribute('fill') === BRAND_YELLOW);
expect(presentationYellowTiles).toHaveLength(0);
const oliveTiles = tiles.filter((tile) => tile.getAttribute('fill') === BRAND_OLIVE);
expect(oliveTiles).toHaveLength(4);
for (const tile of oliveTiles) {
expect(tile.getAttribute('style')).toBeNull();
}
});
});
+11 -1
View File
@@ -69,6 +69,13 @@ function LogoMark({
<g>
<rect x="16" y="16" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
<rect x="30" y="16" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
{/*
`var()` ist in SVG-Praesentationsattributen (fill="...") nicht
zuverlaessig, im Inline-Style schon — deshalb hier style statt fill.
`--primary` wird von applyAccentColor() in auth-store.ts gesetzt;
ohne angemeldeten Nutzer (z. B. Anmeldeseite) ist der Token nicht
definiert und der Rueckfall in var() greift: BRAND_YELLOW.
*/}
<rect
x="45"
y="15"
@@ -76,7 +83,7 @@ function LogoMark({
height="12"
rx="2.5"
transform="rotate(12 51 21)"
fill={BRAND_YELLOW}
style={{ fill: `var(--primary, ${BRAND_YELLOW})` }}
/>
<rect x="30" y="30" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
<rect x="30" y="44" width="12" height="12" rx="2.5" fill={BRAND_OLIVE} />
@@ -90,6 +97,9 @@ function LogoMark({
* Schriftzug und deren Anordnung an einer einzigen Stelle -- eine spaetere
* mandantenspezifische Gestaltung setzt hier an, nicht an den einzelnen
* Verwendungsstellen.
*
* Die gedrehte Signalkachel der Bildmarke folgt der persoenlichen
* Akzentfarbe (CSS-Token `--primary`, siehe LogoMark).
*/
export function TesseraLogo({
variant = 'horizontal',
@@ -0,0 +1,87 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { DesktopContextMenuGuard } from './desktop-context-menu-guard';
import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client';
/**
* desktop-context-menu-guard.test (260917-h2s) — unterdrueckt das
* WebView2-Browser-Kontextmenue im Desktop-Client, laesst es in
* Eingabefeldern/contenteditable-Bereichen zu. `fire` gibt den
* `dispatchEvent`-Rueckgabewert zurueck: `false` bedeutet, `preventDefault()`
* wurde aufgerufen.
*/
function setCookie() {
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
}
function clearCookie() {
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
function fire(el: Element) {
return el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
}
afterEach(() => {
cleanup();
clearCookie();
});
describe('DesktopContextMenuGuard', () => {
it('mit Cookie: unterdrueckt ausserhalb von Eingabefeldern, laesst sie darin zu', async () => {
setCookie();
render(<DesktopContextMenuGuard />);
await act(async () => {});
const div = document.createElement('div');
document.body.appendChild(div);
expect(fire(div)).toBe(false);
const input = document.createElement('input');
document.body.appendChild(input);
expect(fire(input)).toBe(true);
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
expect(fire(textarea)).toBe(true);
const select = document.createElement('select');
document.body.appendChild(select);
expect(fire(select)).toBe(true);
const editable = document.createElement('div');
editable.setAttribute('contenteditable', 'true');
const span = document.createElement('span');
editable.appendChild(span);
document.body.appendChild(editable);
expect(fire(span)).toBe(true);
document.body.removeChild(div);
document.body.removeChild(input);
document.body.removeChild(textarea);
document.body.removeChild(select);
document.body.removeChild(editable);
});
it('ohne Cookie: Kontextmenue bleibt erlaubt', async () => {
render(<DesktopContextMenuGuard />);
await act(async () => {});
const div = document.createElement('div');
document.body.appendChild(div);
expect(fire(div)).toBe(true);
document.body.removeChild(div);
});
it('nach unmount: Kontextmenue bleibt wieder erlaubt', async () => {
setCookie();
const { unmount } = render(<DesktopContextMenuGuard />);
await act(async () => {});
unmount();
const div = document.createElement('div');
document.body.appendChild(div);
expect(fire(div)).toBe(true);
document.body.removeChild(div);
});
});
@@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
import { useIsDesktopClient } from '@/lib/desktop-client';
/**
* DesktopContextMenuGuard (260917-h2s) — unterdrueckt das
* WebView2-Browser-Kontextmenue (Zurueck, Aktualisieren, Speichern unter,
* Drucken …) im Desktop-Client; in Eingabefeldern und contenteditable-
* Bereichen bleibt es erhalten, damit Kopieren/Einfuegen erreichbar bleibt.
* jsdom kennt `isContentEditable` nicht -- deshalb zusaetzlich der Selektor.
*/
const EDITABLE_SELECTOR =
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
export function DesktopContextMenuGuard() {
const isDesktop = useIsDesktopClient();
useEffect(() => {
if (!isDesktop) return;
const handler = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(EDITABLE_SELECTOR) || (target as HTMLElement).isContentEditable === true) {
return;
}
event.preventDefault();
};
document.addEventListener('contextmenu', handler);
return () => {
document.removeEventListener('contextmenu', handler);
};
}, [isDesktop]);
return null;
}
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopDownloadLinks } from './desktop-download-links';
@@ -35,9 +35,14 @@ vi.mock('@/lib/desktop', async (importOriginal) => {
};
});
function clearDesktopCookie() {
document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
clearDesktopCookie();
});
const windowsFile = {
@@ -94,4 +99,19 @@ describe('DesktopDownloadLinks (18-03)', () => {
expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
expect(screen.queryByText('Linux-Version')).not.toBeInTheDocument();
});
it('Test 4 (im Desktop-Client): rendert nichts und fragt /desktop/latest gar nicht erst an', async () => {
document.cookie = 'tessera_desktop=1; path=/';
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
files: { windows: windowsFile, linux: linuxFile },
});
const { container } = render(<DesktopDownloadLinks />);
await act(async () => {});
expect(loadDesktopLatest).not.toHaveBeenCalled();
expect(container.firstChild).toBeNull();
});
});
@@ -3,18 +3,23 @@
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { desktopDownloadUrl, type DesktopLatestInfo, loadDesktopLatest } from '@/lib/desktop';
import { isDesktopClient, useIsDesktopClient } from '@/lib/desktop-client';
/**
* Unauffaelliger Download-Link auf der Anmeldeseite (D-12): erscheint nur,
* wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger,
* nichts, solange nichts geladen ist. Windows fuehrt (Hauptlink), Linux
* folgt als kleiner zweiter Link, wenn beide Pakete vorliegen.
* folgt als kleiner zweiter Link, wenn beide Pakete vorliegen. Im
* Desktop-Client entfaellt der Block komplett (260917-h2s) -- die Kennung
* kommt ueber das Cookie `tessera_desktop`.
*/
export function DesktopDownloadLinks() {
const t = useTranslations('auth');
const [info, setInfo] = useState<DesktopLatestInfo | null>(null);
const isDesktop = useIsDesktopClient();
useEffect(() => {
if (isDesktopClient()) return;
let active = true;
loadDesktopLatest().then((data) => {
if (active) setInfo(data);
@@ -24,6 +29,10 @@ export function DesktopDownloadLinks() {
};
}, []);
if (isDesktop) {
return null;
}
const files = info?.files;
const windows = files?.windows;
const linux = files?.linux;
@@ -0,0 +1,152 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/stores/auth-store';
/**
* header.test — Sitzungswaechter im Header (quick-260917-gyd).
*
* next-intl de.json-gestuetzt (Muster: account-settings-form.test.tsx),
* fetchSessionState/logout aus @/lib/auth-actions per vi.hoisted gemockt,
* usePathname per mutable Variable (Muster: sidebar.test.tsx). Der echte
* useAuthStore wird verwendet (nicht gemockt) und in afterEach
* zurueckgesetzt.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<
string,
unknown
>;
const lookup = (path: string): string | undefined =>
path
.split('.')
.reduce<unknown>(
(o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined),
messages,
) as string | undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values
? raw.replace(/\{(\w+)\}/g, (_: string, n: string) =>
String(values[n] ?? ''),
)
: raw;
},
useLocale: () => 'de',
};
});
let mockPathname = '/settings/general/desktop';
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}));
vi.mock('next/link', () => ({
default: ({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
const { fetchSessionState, logout } = vi.hoisted(() => ({
fetchSessionState: vi.fn(),
logout: vi.fn(),
}));
vi.mock('@/lib/auth-actions', () => ({ fetchSessionState, logout }));
vi.mock('@/components/bug-report/bug-report-button', () => ({
BugReportButton: () => null,
}));
vi.mock('@/components/theme-toggle', () => ({
ThemeToggle: () => null,
}));
vi.mock('@/components/brand/tessera-logo', () => ({
TesseraLogo: () => null,
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
useAuthStore.setState({ user: null });
vi.clearAllMocks();
mockPathname = '/settings/general/desktop';
});
async function renderHeader() {
const { Header } = await import('./header');
render(<Header />);
}
describe('Header — Sitzungswaechter (quick-260917-gyd)', () => {
it('Test 1 (authenticated): Store enthaelt den Benutzer, kein Redirect', async () => {
vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' });
fetchSessionState.mockResolvedValue({
status: 'authenticated',
user: {
id: 'u1',
username: 'schalli',
displayName: 'Schalli',
role: 'ADMIN',
tenantId: 't1',
hasAvatar: false,
accentColor: null,
},
});
await renderHeader();
await waitFor(() =>
expect(useAuthStore.getState().user?.username).toBe('schalli'),
);
expect((window as any).location.href).toBe('');
});
it('Test 2 (unauthenticated auf Unterseite): Vollnavigation auf /login?next=…', async () => {
vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unauthenticated' });
await renderHeader();
await waitFor(() =>
expect((window as any).location.href).toBe(
'/login?next=%2Fsettings%2Fgeneral%2Fdesktop',
),
);
});
it('Test 3 (unauthenticated auf /): Vollnavigation auf /login ohne next', async () => {
mockPathname = '/';
vi.stubGlobal('location', { href: '', pathname: '/', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unauthenticated' });
await renderHeader();
await waitFor(() => expect((window as any).location.href).toBe('/login'));
});
it('Test 4 (unavailable): kein Redirect, Store bleibt leer, Avatar zeigt "?"', async () => {
vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalledTimes(1));
expect((window as any).location.href).toBe('');
expect(useAuthStore.getState().user).toBeNull();
expect(
screen.getByRole('button', { name: 'Benutzermenu' }),
).toHaveTextContent('?');
});
});
+35 -6
View File
@@ -6,7 +6,8 @@ import { useTranslations } from 'next-intl';
import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { fetchCurrentUser, logout } from '@/lib/auth-actions';
import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next';
import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { usePathname } from 'next/navigation';
@@ -22,11 +23,26 @@ export function Header() {
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
// Fetch current user on mount to populate auth store
// Redirect-Sperre gegen doppelte Navigation (StrictMode-Doppeleffekt,
// Effekt-Wiederholungen) — quick-260917-gyd.
const redirectedRef = useRef(false);
// Sitzungswaechter (quick-260917-gyd): Der Header ist der EINZIGE
// Waechter, weil er auf jeder Portalseite genau einmal gerendert wird
// (AppShell im (portal)-Layout); das (auth)-Layout hat keinen Header,
// /login ist oeffentlich (middleware.ts publicRoutes) — Seitenleiste
// und Widget-Aufrufe brauchen deshalb keinen eigenen Umbau, ein
// Doppel-Redirect ist ausgeschlossen. `authenticated` fuellt den Store,
// `unauthenticated` (tote Sitzung, Cookie bereits serverseitig
// geloescht) leitet per Vollnavigation zur Anmeldeseite mit `next` auf
// die aktuelle Seite, `unavailable` (API-Ausfall) bleibt still.
useEffect(() => {
if (!user) {
fetchCurrentUser().then((u) => {
if (u) {
if (user) {
return;
}
fetchSessionState().then((state) => {
if (state.status === 'authenticated') {
const u = state.user;
setUser({
id: u.id,
username: u.username,
@@ -36,9 +52,22 @@ export function Header() {
hasAvatar: u.hasAvatar,
accentColor: u.accentColor,
});
} else if (state.status === 'unauthenticated') {
if (redirectedRef.current) {
return;
}
redirectedRef.current = true;
const next = buildNextParam(
window.location.pathname,
window.location.search,
);
window.location.href = next
? `/login?next=${encodeURIComponent(next)}`
: '/login';
}
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
});
}
}, [user, setUser]);
// Close dropdown on outside click
@@ -0,0 +1,221 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AccountSettingsForm } from './account-settings-form';
/**
* account-settings-form.test — Akzentfarbe Hex-Eingabe (quick-260917-gsh).
* next-intl-Mock de.json-gestuetzt (Muster: desktop-app-settings.test.tsx),
* `@/lib/auth-actions` per vi.hoisted + vi.mock ersetzt.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
useLocale: () => 'de',
};
});
const { fetchCurrentUser, updateAccentColorAction, changePasswordAction, uploadAvatarAction, deleteAvatarAction } =
vi.hoisted(() => ({
fetchCurrentUser: vi.fn(),
updateAccentColorAction: vi.fn(),
changePasswordAction: vi.fn(),
uploadAvatarAction: vi.fn(),
deleteAvatarAction: vi.fn(),
}));
vi.mock('@/lib/auth-actions', () => ({
fetchCurrentUser,
updateAccentColorAction,
changePasswordAction,
uploadAvatarAction,
deleteAvatarAction,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('AccountSettingsForm — Akzentfarbe Hex-Eingabe', () => {
it('prefills the hex textfield and the color picker from the loaded user', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
expect(hexInput.value).toBe('#123456');
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
expect(colorPicker.value).toBe('#123456');
});
it('accepts a valid hex value typed without # and syncs the color picker', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: 'FFED00' } });
expect(colorPicker.value).toBe('#ffed00');
expect(hexInput).toHaveAttribute('aria-invalid', 'false');
expect(screen.getByText('Farbe speichern')).not.toBeDisabled();
});
it('marks an invalid hex value, blocks save, and keeps the last valid color picker value', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: '#ggg' } });
expect(hexInput).toHaveAttribute('aria-invalid', 'true');
expect(hexInput.className).toContain('border-destructive');
expect(
screen.getByText('Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.'),
).toBeInTheDocument();
expect(screen.getByText('Farbe speichern')).toBeDisabled();
expect(colorPicker.value).toBe('#123456');
});
it('syncs the hex textfield when the color picker changes', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
await screen.findByRole('textbox', { name: 'Hex-Code' });
const hexInput = screen.getByRole('textbox', { name: 'Hex-Code' }) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.change(colorPicker, { target: { value: '#00ff00' } });
expect(hexInput.value).toBe('#00ff00');
});
it('normalizes the hex textfield to canonical form on blur', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: '#fe0' } });
fireEvent.blur(hexInput);
expect(hexInput.value).toBe('#ffee00');
});
it('resets both the hex textfield and the color picker on "Zurücksetzen"', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#123456',
});
updateAccentColorAction.mockResolvedValue({ success: true });
render(<AccountSettingsForm />);
const hexInput = (await screen.findByRole('textbox', {
name: 'Hex-Code',
})) as HTMLInputElement;
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
fireEvent.click(screen.getByText('Zurücksetzen'));
await waitFor(() => {
expect(hexInput.value).toBe('#ffed00');
});
expect(colorPicker.value).toBe('#ffed00');
expect(updateAccentColorAction).toHaveBeenCalledWith(null);
});
it('saves the normalized color and shows the success message', async () => {
fetchCurrentUser.mockResolvedValue({
id: 'u1',
username: 'max',
displayName: 'Max',
role: 'USER',
tenantId: 't1',
isLocalUser: true,
hasAvatar: false,
accentColor: '#ffed00',
});
updateAccentColorAction.mockResolvedValue({ success: true });
render(<AccountSettingsForm />);
await screen.findByRole('textbox', { name: 'Hex-Code' });
fireEvent.click(screen.getByText('Farbe speichern'));
expect(await screen.findByText('Akzentfarbe erfolgreich gespeichert.')).toBeInTheDocument();
expect(updateAccentColorAction).toHaveBeenCalledWith('#ffed00');
});
});
@@ -11,6 +11,7 @@ import {
} from '@/lib/auth-actions';
import { useAuthStore } from '@/lib/stores/auth-store';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { normalizeHexColor } from '@/lib/color';
const DEFAULT_ACCENT = BRAND_YELLOW;
@@ -38,6 +39,8 @@ export function AccountSettingsForm() {
// Accent color state
const [accentColor, setAccentColor] = useState<string>(DEFAULT_ACCENT);
const [hexInput, setHexInput] = useState<string>(DEFAULT_ACCENT);
const isHexValid = normalizeHexColor(hexInput) !== null;
const [accentSuccess, setAccentSuccess] = useState(false);
const [accentError, setAccentError] = useState<string | null>(null);
const [isAccentPending, startAccentTransition] = useTransition();
@@ -48,7 +51,9 @@ export function AccountSettingsForm() {
setIsLocalUser(u.isLocalUser ?? false);
setUsername(u.displayName ?? u.username ?? '');
setHasAvatar(!!u.hasAvatar);
setAccentColor(u.accentColor ?? DEFAULT_ACCENT);
const c = u.accentColor ?? DEFAULT_ACCENT;
setAccentColor(c);
setHexInput(c);
}
});
}, []);
@@ -113,6 +118,7 @@ export function AccountSettingsForm() {
}
function handleSaveAccentColor() {
if (!isHexValid) return;
setAccentSuccess(false);
setAccentError(null);
startAccentTransition(async () => {
@@ -128,6 +134,7 @@ export function AccountSettingsForm() {
function handleResetAccentColor() {
setAccentColor(DEFAULT_ACCENT);
setHexInput(DEFAULT_ACCENT);
setAccentSuccess(false);
setAccentError(null);
startAccentTransition(async () => {
@@ -212,14 +219,41 @@ export function AccountSettingsForm() {
{t('account.accentColorLabel')}
</h2>
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-3 mb-1">
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
onChange={(e) => {
setAccentColor(e.target.value);
setHexInput(e.target.value);
}}
className="h-10 w-16 cursor-pointer rounded-md border border-input p-1"
/>
<span className="font-mono text-sm text-muted-foreground">{accentColor}</span>
<input
id="accentColorHex"
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
maxLength={7}
placeholder={DEFAULT_ACCENT}
value={hexInput}
aria-label={t('account.accentColorHex')}
aria-invalid={!isHexValid}
onChange={(e) => {
const raw = e.target.value;
setHexInput(raw);
const n = normalizeHexColor(raw);
if (n) setAccentColor(n);
}}
onBlur={() => {
const n = normalizeHexColor(hexInput);
if (n) setHexInput(n);
}}
className={`h-10 w-28 rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isHexValid ? 'border-input' : 'border-destructive'
}`}
/>
<button
type="button"
onClick={handleResetAccentColor}
@@ -229,6 +263,9 @@ export function AccountSettingsForm() {
{t('account.accentColorReset')}
</button>
</div>
{!isHexValid && (
<p className="text-xs text-destructive mb-3">{t('account.accentColorHexInvalid')}</p>
)}
{accentSuccess && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
{t('account.accentColorSuccess')}
@@ -242,7 +279,7 @@ export function AccountSettingsForm() {
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending}
disabled={isAccentPending || !isHexValid}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
+171
View File
@@ -0,0 +1,171 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* auth-actions.test (fetchSessionState) — Sitzungswaechter (quick-260917-gyd).
*
* next/headers wird gemockt (cookies() liefert ein Promise auf ein Objekt
* mit get/set/delete aus vi.hoisted, Muster: module-access.test.tsx),
* next/navigation ebenfalls (redirect ist hier ungenutzt, muss aber
* importierbar bleiben), fetch per vi.stubGlobal. Die 'use server'-
* Direktive ist unter vitest wirkungslos.
*/
const { cookieGet, cookieSet, cookieDelete } = vi.hoisted(() => ({
cookieGet: vi.fn(),
cookieSet: vi.fn(),
cookieDelete: vi.fn(),
}));
vi.mock('next/headers', () => ({
cookies: () =>
Promise.resolve({
get: cookieGet,
set: cookieSet,
delete: cookieDelete,
}),
}));
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe('fetchSessionState', () => {
it('Test 1: kein Cookie -> unauthenticated, kein fetch', async () => {
cookieGet.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unauthenticated' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('Test 2: 200 mit Benutzer -> authenticated, Cookie bleibt, Cookie-Header gesetzt', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
const fetchMock = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('{"id":"u1","username":"schalli"}'),
}),
);
vi.stubGlobal('fetch', fetchMock);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result.status).toBe('authenticated');
if (result.status === 'authenticated') {
expect(result.user.username).toBe('schalli');
}
expect(cookieDelete).not.toHaveBeenCalled();
const [, options] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect((options.headers as Record<string, string>).Cookie).toBe(
'session=session-abc',
);
});
it('Test 3: Status 401 -> unauthenticated, Cookie genau einmal geloescht', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('') })),
);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unauthenticated' });
expect(cookieDelete).toHaveBeenCalledTimes(1);
expect(cookieDelete).toHaveBeenCalledWith('session');
});
it('Test 4: Status 403 -> unauthenticated, Cookie geloescht', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 403, text: () => Promise.resolve('') })),
);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unauthenticated' });
expect(cookieDelete).toHaveBeenCalledTimes(1);
});
it('Test 5: Status 200 mit leerem Body (oder "null") -> unauthenticated, Cookie geloescht', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') })),
);
const { fetchSessionState } = await import('./auth-actions');
const result1 = await fetchSessionState();
expect(result1).toEqual({ status: 'unauthenticated' });
expect(cookieDelete).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('null') })),
);
const result2 = await fetchSessionState();
expect(result2).toEqual({ status: 'unauthenticated' });
expect(cookieDelete).toHaveBeenCalledTimes(1);
});
it('Test 6: Status 500 -> unavailable, Cookie bleibt', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('') })),
);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unavailable' });
expect(cookieDelete).not.toHaveBeenCalled();
});
it('Test 7: fetch wirft (Netzwerkfehler) -> unavailable, Cookie bleibt', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new TypeError('fetch failed'))),
);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unavailable' });
expect(cookieDelete).not.toHaveBeenCalled();
});
it('Test 8: Status 200 mit nicht-JSON-Body -> unavailable, Cookie bleibt', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<html>') })),
);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unavailable' });
expect(cookieDelete).not.toHaveBeenCalled();
});
});
+66
View File
@@ -236,6 +236,72 @@ export async function updateAccentColorAction(
}
}
export type SessionState =
| { status: 'authenticated'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'unavailable' };
/**
* Klassifiziert die aktuelle Sitzung fuer den Header-Waechter
* (quick-260917-gyd). Die Unterscheidung ist load-bearing: nur eine
* nachweislich tote Sitzung (401/403 oder 200 ohne Benutzerobjekt — so
* antwortet NestJS, wenn `AuthService.getMe` bei geloeschtem Benutzer
* `null` liefert, z. B. nach Neuanlage der Datenbank) darf das Cookie
* loeschen und abmelden. Ein API-Ausfall (5xx, Netzwerkfehler, Antwort
* ohne gueltiges JSON) darf KEINE Abmelde-Schleife ausloesen und liefert
* deshalb `unavailable`, ohne das Cookie anzufassen.
*
* `cookieStore.delete()` ist nur in Server Actions/Route Handlers
* erlaubt — deshalb passiert die Loeschung hier und nicht im Header.
*/
export async function fetchSessionState(): Promise<SessionState> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) {
return { status: 'unauthenticated' };
}
try {
const response = await fetch(`${API_URL}/auth/me`, {
headers: {
Cookie: `session=${session}`,
},
cache: 'no-store',
});
if (response.status === 401 || response.status === 403) {
cookieStore.delete('session');
return { status: 'unauthenticated' };
}
if (!response.ok) {
return { status: 'unavailable' };
}
const body = (await response.text()).trim();
if (body === '' || body === 'null') {
cookieStore.delete('session');
return { status: 'unauthenticated' };
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return { status: 'unavailable' };
}
if (parsed && typeof parsed === 'object' && 'id' in parsed) {
return { status: 'authenticated', user: parsed as AuthUser };
}
return { status: 'unavailable' };
} catch {
return { status: 'unavailable' };
}
}
/**
* Fetch the current authenticated user from the API.
* Uses the session cookie for authentication.
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { normalizeHexColor } from './color';
/**
* color.test — Normalisierung der Hex-Eingabe der Akzentfarbe
* (quick-260917-gsh).
*/
describe('normalizeHexColor', () => {
it('accepts a canonical six-digit hex value with leading #', () => {
expect(normalizeHexColor('#ffed00')).toBe('#ffed00');
});
it('accepts a six-digit value without # and uppercase letters', () => {
expect(normalizeHexColor('FFED00')).toBe('#ffed00');
});
it('expands a three-digit shorthand', () => {
expect(normalizeHexColor('#fe0')).toBe('#ffee00');
});
it('trims surrounding whitespace and lowercases mixed case', () => {
expect(normalizeHexColor(' #FfEd00 ')).toBe('#ffed00');
});
it('rejects non-hex characters', () => {
expect(normalizeHexColor('#ggg')).toBeNull();
});
it('rejects the wrong digit count (5)', () => {
expect(normalizeHexColor('#12345')).toBeNull();
});
it('rejects an empty string', () => {
expect(normalizeHexColor('')).toBeNull();
});
it('rejects a lone hash', () => {
expect(normalizeHexColor('#')).toBeNull();
});
it('rejects the wrong digit count (7)', () => {
expect(normalizeHexColor('#1234567')).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Normalisierung der Hex-Eingabe der persoenlichen Akzentfarbe
* (Einstellungen → Konto, quick-260917-gsh).
*
* `PATCH /users/me/accent-color` verlangt serverseitig `/^#[0-9a-fA-F]{6}$/` —
* diese Funktion bringt Roheingaben (mit/ohne `#`, Kurzform, Gross-/
* Kleinschreibung, umgebender Leerraum) in genau diese kanonische Form
* oder liefert `null`, wenn das nicht moeglich ist.
*/
const HEX_PATTERN = /^[0-9a-f]{3}$|^[0-9a-f]{6}$/i;
export function normalizeHexColor(input: string): string | null {
const trimmed = input.trim();
const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed;
if (!HEX_PATTERN.test(withoutHash)) {
return null;
}
const expanded =
withoutHash.length === 3
? withoutHash
.split('')
.map((ch) => ch + ch)
.join('')
: withoutHash;
return `#${expanded.toLowerCase()}`;
}
+52
View File
@@ -0,0 +1,52 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DESKTOP_COOKIE_NAME, isDesktopClient, useIsDesktopClient } from './desktop-client';
/**
* desktop-client.test — Gegenstueck zur Middleware (260917-h2s): liest das
* Cookie `tessera_desktop`, das die Middleware auf jede Antwort setzt, wenn
* der Desktop-Client `?desktop=1` an seine erste Navigation anhaengt.
*/
function clearCookie() {
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
afterEach(() => {
clearCookie();
});
describe('isDesktopClient', () => {
it('ohne Cookie: false', () => {
expect(isDesktopClient()).toBe(false);
});
it('mit tessera_desktop=1: true', () => {
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
expect(isDesktopClient()).toBe(true);
});
it('mit tessera_desktop=0: false', () => {
document.cookie = `${DESKTOP_COOKIE_NAME}=0; path=/`;
expect(isDesktopClient()).toBe(false);
});
it('ohne document: false', () => {
vi.stubGlobal('document', undefined);
expect(isDesktopClient()).toBe(false);
vi.unstubAllGlobals();
});
});
describe('useIsDesktopClient', () => {
it('Cookie gesetzt: liefert nach dem Effekt true', async () => {
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
const { result } = renderHook(() => useIsDesktopClient());
await waitFor(() => expect(result.current).toBe(true));
});
it('ohne Cookie: bleibt false', async () => {
const { result } = renderHook(() => useIsDesktopClient());
await act(async () => {});
expect(result.current).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
/**
* desktop-client — erkennt, ob die Seite im Desktop-Client laeuft
* (260917-h2s). Gegenstueck zur Middleware (apps/web/src/middleware.ts,
* `withDesktopCookie`): der Rust-Client haengt `desktop=1` an seine erste
* Navigation, die Middleware setzt daraufhin das Cookie `tessera_desktop`
* auf jede Antwort. `isDesktopClient()` liest das Cookie synchron.
*/
export const DESKTOP_COOKIE_NAME = 'tessera_desktop';
export function isDesktopClient(): boolean {
if (typeof document === 'undefined') {
return false;
}
return document.cookie
.split(';')
.some((c) => c.trim() === `${DESKTOP_COOKIE_NAME}=1`);
}
/**
* Hook statt direktem Aufruf beim Render: `isDesktopClient()` synchron im
* ersten Render zu nutzen wuerde Server-HTML (kein `document`) und den
* ersten Client-Render auseinanderlaufen lassen -- Hydration-Fehler. Der
* `useEffect` gleicht das an; `useState(false)` haelt Server- und
* Erst-Render-Ergebnis identisch.
*/
export function useIsDesktopClient(): boolean {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
setIsDesktop(isDesktopClient());
}, []);
return isDesktop;
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { buildNextParam, sanitizeNextPath } from './safe-next';
/**
* safe-next.test — Ruecksprung nach Anmeldung (quick-260917-gyd).
*
* Reine Funktionen ohne DOM-/Node-Abhaengigkeit: `buildNextParam` baut den
* `next`-Wert aus Pfad + Query, `sanitizeNextPath` prueft ihn beim Absenden
* der Anmeldeseite gegen Open-Redirect-Versuche.
*/
describe('buildNextParam', () => {
it('Test 1: Pfad ohne Query bleibt unveraendert', () => {
expect(buildNextParam('/settings/general/desktop', '')).toBe(
'/settings/general/desktop',
);
});
it('Test 2: _rsc wird entfernt, andere Parameter bleiben', () => {
expect(buildNextParam('/modules/tender-radar', '?tab=alerts&_rsc=1abc')).toBe(
'/modules/tender-radar?tab=alerts',
);
});
it('Test 3: leere Query nach Entfernen von _rsc bleibt ohne "?"', () => {
expect(buildNextParam('/modules/tender-radar', '?_rsc=1abc')).toBe(
'/modules/tender-radar',
);
});
it('Test 4: Startseite und Anmeldeseite liefern null (kein Ruecksprung auf sich selbst)', () => {
expect(buildNextParam('/', '')).toBeNull();
expect(buildNextParam('/login', '?next=%2Fx')).toBeNull();
expect(buildNextParam('/login/', '')).toBeNull();
});
});
describe('sanitizeNextPath', () => {
it('Test 5: gueltige relative Pfade bleiben unveraendert', () => {
expect(sanitizeNextPath('/settings/general/desktop')).toBe(
'/settings/general/desktop',
);
expect(sanitizeNextPath('/modules/x?tab=1')).toBe('/modules/x?tab=1');
});
it('Test 6: fehlende oder falsch typisierte Werte fallen auf "/" zurueck', () => {
expect(sanitizeNextPath(null)).toBe('/');
expect(sanitizeNextPath(undefined)).toBe('/');
expect(sanitizeNextPath('')).toBe('/');
expect(sanitizeNextPath(42)).toBe('/');
});
it('Test 7: protokoll-relative und Schema-Adressen fallen auf "/" zurueck', () => {
expect(sanitizeNextPath('//evil.example')).toBe('/');
expect(sanitizeNextPath('/\\evil.example')).toBe('/');
expect(sanitizeNextPath('https://evil.example/x')).toBe('/');
expect(sanitizeNextPath('javascript:alert(1)')).toBe('/');
});
it('Test 8: fehlender Slash, Steuerzeichen und Ueberlaenge fallen auf "/" zurueck', () => {
expect(sanitizeNextPath('settings')).toBe('/');
expect(sanitizeNextPath('/foo\nbar')).toBe('/');
expect(sanitizeNextPath('/a b')).toBe('/');
expect(sanitizeNextPath('/x'.padEnd(3000, 'y'))).toBe('/');
});
it('Test 9: die Anmeldeseite selbst ist kein gueltiges Ziel, "/loginhistory" schon', () => {
expect(sanitizeNextPath('/login')).toBe('/');
expect(sanitizeNextPath('/login?next=/x')).toBe('/');
expect(sanitizeNextPath('/login/')).toBe('/');
expect(sanitizeNextPath('/loginhistory')).toBe('/loginhistory');
});
});
Binary file not shown.
+5
View File
@@ -129,6 +129,9 @@
"backToDashboard": "Zurück zum Dashboard",
"categoryDashboard": "Dashboard",
"categoryWidgets": "Widgets",
"widgets": {
"empty": "Es sind noch keine Widgets auf dem Dashboard platziert."
},
"categoryCalendar": "Kalender",
"categoryGeneral": "Allgemein",
"categoryAccount": "Konto",
@@ -149,6 +152,8 @@
"accentColorSave": "Farbe speichern",
"accentColorSuccess": "Akzentfarbe erfolgreich gespeichert.",
"accentColorError": "Speichern fehlgeschlagen.",
"accentColorHex": "Hex-Code",
"accentColorHexInvalid": "Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.",
"deleteAvatarCta": "Bild löschen",
"deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.",
"deleteAvatarError": "Löschen fehlgeschlagen."
+5
View File
@@ -129,6 +129,9 @@
"backToDashboard": "Back to Dashboard",
"categoryDashboard": "Dashboard",
"categoryWidgets": "Widgets",
"widgets": {
"empty": "No widgets have been placed on the dashboard yet."
},
"categoryCalendar": "Calendar",
"categoryGeneral": "General",
"categoryAccount": "Account",
@@ -149,6 +152,8 @@
"accentColorSave": "Save color",
"accentColorSuccess": "Accent color saved successfully.",
"accentColorError": "Failed to save color.",
"accentColorHex": "Hex code",
"accentColorHexInvalid": "Invalid color value. Please enter six hexadecimal characters, e.g. #ffed00.",
"deleteAvatarCta": "Delete picture",
"deleteAvatarSuccess": "Profile picture deleted successfully.",
"deleteAvatarError": "Failed to delete picture."
+68
View File
@@ -0,0 +1,68 @@
// @vitest-environment node
import { NextRequest } from 'next/server';
import { SignJWT } from 'jose';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { middleware } from './middleware';
/**
* middleware.test — Desktop-Client-Cookie (260917-h2s), eigener describe-Block
* neben den bestehenden Redirect-/Session-Faellen. `@vitest-environment node`,
* weil `NextRequest`/`NextResponse` node-typische APIs (Headers, URL) nutzen,
* die im jsdom-Standardmilieu der Suite nicht gebraucht werden.
*/
describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
beforeEach(() => {
vi.stubEnv('JWT_SECRET', 'test-secret');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('Test 1: /login?desktop=1 setzt das Cookie tessera_desktop=1', async () => {
const req = new NextRequest('http://localhost:3000/login?desktop=1');
const res = await middleware(req);
const setCookie = res.headers.get('set-cookie');
expect(setCookie).toContain('tessera_desktop=1');
expect(setCookie).toContain('Path=/');
expect(setCookie).toContain('Max-Age=31536000');
expect(setCookie).toContain('SameSite=lax');
expect(setCookie).not.toContain('Secure');
expect(setCookie).not.toContain('HttpOnly');
});
it('Test 2: /login ohne Parameter setzt kein Cookie', async () => {
const req = new NextRequest('http://localhost:3000/login');
const res = await middleware(req);
expect(res.headers.get('set-cookie')).toBeNull();
});
it('Test 3: /dashboard?desktop=1 ohne Session leitet um und setzt das Cookie', async () => {
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1');
const res = await middleware(req);
expect(res.status).toBe(307);
expect(res.headers.get('location')).toContain('/login');
expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1');
});
it('Test 4: https setzt Secure', async () => {
const req = new NextRequest('https://tessera.example.com/login?desktop=1');
const res = await middleware(req);
expect(res.headers.get('set-cookie')).toContain('Secure');
});
it('Test 5: gueltiges JWT laesst die Anfrage durch und setzt trotzdem das Cookie', async () => {
const token = await new SignJWT({ sub: 'u1' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.sign(new TextEncoder().encode('test-secret'));
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1', {
headers: { cookie: `session=${token}` },
});
const res = await middleware(req);
expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1');
expect(res.headers.get('x-middleware-next')).toBe('1');
});
});
+51 -7
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { buildNextParam } from '@/lib/safe-next';
/**
* Next.js middleware for frontend route protection (Pattern 4).
@@ -11,6 +12,46 @@ import { jwtVerify } from 'jose';
const publicRoutes = ['/login', '/reset-password'];
const DESKTOP_COOKIE = 'tessera_desktop';
/**
* Setzt das Cookie `tessera_desktop`, wenn die Anfrage `?desktop=1` traegt
* (260917-h2s). Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs,
* `with_desktop_marker`) haengt den Parameter nur an seine ERSTE Navigation
* an; das Cookie muss deshalb auf JEDER Antwort landen, auch auf dem
* Fruehausstieg fuer oeffentliche Routen und auf Redirects -- sonst geht die
* Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird
* von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen);
* der Wert ist kein Geheimnis.
*/
function withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse {
if (req.nextUrl.searchParams.get('desktop') === '1') {
res.cookies.set(DESKTOP_COOKIE, '1', {
path: '/',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
httpOnly: false,
secure: req.nextUrl.protocol === 'https:',
});
}
return res;
}
/**
* Umleitung zur Anmeldeseite mit `next`-Parameter (quick-260917-gyd): Pfad
* + Query der urspruenglich angeforderten Seite wandern mit, damit die
* Anmeldeseite nach erfolgreichem Login dorthin zurueckspringen kann
* (Ausloeser: Link "Update herunterladen" der Desktop-App).
*/
function redirectToLogin(req: NextRequest): NextResponse {
const url = new URL('/login', req.nextUrl);
const next = buildNextParam(req.nextUrl.pathname, req.nextUrl.search);
if (next !== null) {
url.searchParams.set('next', next);
}
return NextResponse.redirect(url);
}
function getSecret() {
const secret = process.env.JWT_SECRET || process.env.SESSION_SECRET;
if (!secret) {
@@ -26,7 +67,7 @@ export async function middleware(req: NextRequest) {
// Allow public routes without authentication
if (publicRoutes.some((route) => path.startsWith(route))) {
return NextResponse.next();
return withDesktopCookie(req, NextResponse.next());
}
// Skip static assets and API routes (handled by NestJS)
@@ -36,14 +77,14 @@ export async function middleware(req: NextRequest) {
path.startsWith('/favicon.ico') ||
path.startsWith('/api')
) {
return NextResponse.next();
return withDesktopCookie(req, NextResponse.next());
}
// Read session cookie
const session = req.cookies.get('session')?.value;
if (!session) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
return withDesktopCookie(req, redirectToLogin(req));
}
try {
@@ -56,15 +97,18 @@ export async function middleware(req: NextRequest) {
payload.mustChangePassword === true &&
!path.startsWith('/change-password')
) {
return NextResponse.redirect(new URL('/change-password', req.nextUrl));
return withDesktopCookie(
req,
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
);
}
return NextResponse.next();
return withDesktopCookie(req, NextResponse.next());
} catch {
// JWT verification failed -- clear stale cookie and redirect to login
const response = NextResponse.redirect(new URL('/login', req.nextUrl));
const response = redirectToLogin(req);
response.cookies.delete('session');
return response;
return withDesktopCookie(req, response);
}
}
+2 -2
View File
@@ -175,7 +175,7 @@ Sie finden die Desktop-App an zwei Stellen, beide ohne Zugang zu Gitea:
### Installation unter Windows
Führen Sie die heruntergeladene Datei `Tessera-Setup-X.Y.Z.exe` aus. Windows zeigt dabei die Meldung „Der Computer wurde durch Windows geschützt": Klicken Sie auf „Weitere Informationen" und danach auf „Trotzdem ausführen". Der Grund dafür: Die App ist für den internen Gebrauch nicht signiert, das Paket stammt aber unmittelbar aus Ihrem eigenen Tessera-Server. Danach erscheint „Tessera" im Startmenü. Eine neuere Version installieren Sie einfach über die bestehende — die bereits eingetragene Server-Adresse bleibt dabei erhalten.
Führen Sie die heruntergeladene Datei `Tessera-Setup-X.Y.Z.exe` aus. Windows zeigt dabei die Meldung „Der Computer wurde durch Windows geschützt": Klicken Sie auf „Weitere Informationen" und danach auf „Trotzdem ausführen". Der Installationsassistent führt auf Deutsch durch die Installation; sie erfolgt für den angemeldeten Benutzer und benötigt keine Administratorrechte. Der Grund für die Windows-Meldung: Die App ist für den internen Gebrauch nicht signiert, das Paket stammt aber unmittelbar aus Ihrem eigenen Tessera-Server. Danach erscheint „Tessera" im Startmenü. Eine neuere Version installieren Sie einfach über die bestehende — die bereits eingetragene Server-Adresse bleibt dabei erhalten.
### Installation unter Linux
@@ -190,7 +190,7 @@ Beim ersten Start fragt die App nach der Adresse, unter der Sie Tessera auch im
Schließen Sie das Fenster über das X, legt sich Tessera lediglich in den Infobereich Ihrer Taskleiste, statt sich zu beenden. Ein Linksklick auf das Symbol dort öffnet das Fenster wieder. Ein Rechtsklick zeigt ein Menü mit:
- **Öffnen**
- **Update herunterladen**
- **Update herunterladen** — wird aktiv, sobald eine neue Version vorliegt (auf dem Beta-Kanal: „Neuen Beta-Stand herunterladen")
- **Mit Windows starten** (unter Linux: **Beim Anmelden starten**) — mit Häkchen
- **Beenden**
+6 -1
View File
@@ -163,7 +163,12 @@ Docker-Stack (`docker compose build api` genügt für die API allein), liefert
aus dem Ubuntu-Paket `nsis` — siehe `docs/ci-cd-setup.md`, Abschnitt 4). Lokal
genügt für Rust-Änderungen `cargo check`/`cargo clippy` in
`apps/desktop/src-tauri`; einen Windows-Installer lokal zu bauen ist nicht
vorgesehen.
vorgesehen. Sprache, Symbol und Bilder des Installers stehen in
`apps/desktop/src-tauri/tauri.conf.json` unter `bundle.windows.nsis`
(Deutsch ohne Sprachauswahl, `icons/nsis-header.bmp` 150×57 und
`icons/nsis-sidebar.bmp` 164×314 als 24-Bit-BMP); Änderungen daran lassen
sich nur über den CI-Bau auf einem Windows-Rechner prüfen, lokal validiert
`cargo check` lediglich die Schlüssel.
## Architektur im Überblick