--- 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=` (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`) 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." --- 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. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.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 Task 1: Ruecksprung — `next`-Parameter in Middleware und Anmeldeseite mit getesteter Pfadpruefung 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/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) 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/`) **RED zuerst:** safe-next.test.ts mit den Faellen aus `` 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). 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 safe-next.test.ts gruen (alle Faelle aus ``); 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 `/`. Task 2: Sitzungswaechter — `fetchSessionState()` unterscheidet tote Sitzung von API-Ausfall, Header leitet ab 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/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`) 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=` - 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 (`''`) → `{ 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 `?` **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` — Ablauf: Cookie `session` lesen; fehlt es → `unauthenticated` ohne fetch. Sonst `GET ${API_URL}/auth/me` mit `Cookie: session=` 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 ``. 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 `` mit `href`. 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" 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 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. Task 3: Widgets-Seite uebersetzen, CHANGELOG ergaenzen, Gesamtlauf apps/web/src/app/(portal)/settings/dashboard/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md `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. - 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) **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. 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 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. ## 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. | - `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. - 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. Create `.planning/quick/260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng/260917-gyd-SUMMARY.md` when done