Files
tessera-ctl/.planning/quick/260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng/260917-gyd-PLAN.md
T

32 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260917-gyd 01 execute 1
true
QUICK-260917-GYD
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
tokens raw_tokens tasks confidence
38000 38000 3 low
truths artifacts key_links
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/.
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
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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 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 (`'<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 `?` **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`.
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.

<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>
- `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.

<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>
Create `.planning/quick/260917-gyd-web-nach-anmeldung-zurueck-zur-ursprueng/260917-gyd-SUMMARY.md` when done