# PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen > Stories: ST-1345, ST-1346, ST-1347, ST-1348 > Branch: `feat/demo-prefs` > Aangemaakt: 2026-05-11 --- ## Context De demo-gebruiker (`username='demo'`, één gedeelde DB-rij met `is_demo=true`) zit nu vast op het seed-default product en de seed-default sprint. Elke poging om te wisselen of een filter te wijzigen geeft een 403-toast ("Niet beschikbaar in demo-modus"), wat een potentiële klant geen goed beeld geeft van wat de app kan: hij kan niet door producten bladeren, geen alternatieve sprint openen, geen filter ervaren. **Beperking.** Alle demo-bezoekers delen één DB-rij. Directe DB-persistentie van demo-prefs zou cross-bezoeker-pollution geven (A's keuze zichtbaar voor B). Schrijven naar `user.settings` voor de demo-rij is dus structureel onveilig. **Doel.** Demo mag binnen één browsertab zijn UI-context vrij wijzigen (product, sprint, filters, layout). Geen DB-mutaties — alle wijzigingen sterven aan het einde van de tab/refresh. De huidige three-layer beschermingen voor data-mutaties blijven volledig intact. **Bestaande infra die we hergebruiken.** - [stores/user-settings/store.ts:80](../../stores/user-settings/store.ts) — `setPref` heeft al een demo-fork (lokale merge zonder server-call). - [components/shared/user-settings-bridge.tsx:34](../../components/shared/user-settings-bridge.tsx) — skipt SSE en server-sync voor demo. - Filters/sort/layout/selecties lopen volledig via `useUserSettingsStore` — alleen verifiëren dat de UI niets extra's vraagt aan de server. --- ## Beslissingen | Onderwerp | Keuze | Implicatie | |---|---|---| | Persistentie | **In-memory** (Zustand) | Geen cookie, geen localStorage, geen DB. Refresh = reset. | | Scope prefs | Filters/sort + layout (split-panes, collapsed PBIs, selecties) | Debug-mode en notificaties **buiten** scope. | | Documentatie | ADR-0006 update + addendum | One-stop: lezer ziet uitzondering bij oorspronkelijke beslissing. | | Server-actions | **Behouden 403 voor demo** | Defense in depth blijft intact. UI roept ze gewoon niet aan voor demo. | --- ## Scope **Demo MAG (nieuw):** - Wisselen van actief product (URL-navigatie + NavBar reflectie) - Wisselen van actieve sprint binnen een product (URL-navigatie) - Filters wijzigen (status, priority) op backlog en sprint-board — *werkt al* - Sortering wijzigen (kolom-headers) — *werkt al* - Collapse/expand van PBIs, selectie van actieve PBI/story/taak — *werkt al* - Split-pane breedte verslepen — *werkt al* **Demo MAG NIET (ongewijzigd):** - PBI/story/taak aanmaken, wijzigen, verwijderen, verplaatsen - Sprints openen/sluiten, builden, archiveren - Rollen toekennen of intrekken (`UserRole`) - Accountgegevens wijzigen (username, password, email) - QR-pairing, web-push abonnement, notificaties - Debug-mode toggle - Cron / webhook secrets --- ## Architectuur **In-memory only.** Client-side `useUserSettingsStore` is de enige bron van demo-state. Server-actions blijven 403 retourneren — die blokkade is geen bug maar een veiligheidsnet voor het geval client-code per ongeluk een server-call doet. De UI moet voor demo de server-call dus *gewoon overslaan*. **Product-switch (geen DB-write).** - Vandaag: NavBar roept `setActiveProductAction(productId)` → 403 → toast. - Nieuw: voor demo doet NavBar alleen `router.push('/products/X')`. Geen action. - Server-render van layouts blijft `user.active_product_id` (de seed-default) lezen, maar de NavBar leidt z'n weergegeven actieve product voor demo af uit `pathname`, zodat label en highlight kloppen met waar je daadwerkelijk bent. **Sprint-switch (geen DB-write).** - Vandaag: SprintSwitcher roept `setActiveSprintAction` → 403 → toast. - Nieuw: voor demo doet de switcher alleen `router.push('/products/X/sprint/Y')`. De sprint-pagina is `[sprintId]`-driven, dus de juiste sprint laadt zonder dat `user.settings.layout.activeSprints[X]` ge-update hoeft te worden. **Filters / layout / selecties.** Ongewijzigd. Te verifiëren dat alle UI-componenten `setPref` gebruiken (niet rechtstreeks een server-action of fetch). --- ## Stories & taken ### ST-1345 — SprintSwitcher demo-fork | Taak | Bestand | Beschrijving | |---|---|---| | T-950 | [components/shared/sprint-switcher.tsx](../../components/shared/sprint-switcher.tsx) | Lees `isDemo` uit store + fork `handleSwitchSprint` | | T-951 | `__tests__/components/shared/sprint-switcher.test.tsx` | Vitest: demo vs niet-demo gedrag | ### ST-1346 — NavBar demo-fork + URL-derived display | Taak | Bestand | Beschrijving | |---|---|---| | T-952 | [components/shared/nav-bar.tsx](../../components/shared/nav-bar.tsx) | Fork `handleSwitchProduct` voor demo | | T-953 | [components/shared/nav-bar.tsx](../../components/shared/nav-bar.tsx) | URL-derived `displayActive` voor label + highlight | | T-954 | `__tests__/components/shared/nav-bar.test.tsx` | Vitest: handler-fork + URL-derived display | ### ST-1347 — ADR-0006 update + patroon-doc | Taak | Bestand | Beschrijving | |---|---|---| | T-955 | [docs/adr/0006-demo-user-three-layer-policy.md](../adr/0006-demo-user-three-layer-policy.md) | "Updated 2026-05-11"-sectie met uitzondering | | T-956 | `docs/patterns/demo-client-state.md` (optioneel) | Patroon-doc + CLAUDE.md quickref-rij | ### ST-1348 — Verificatie | Taak | Bestand | Beschrijving | |---|---|---| | T-957 | `__tests__/**` | Bestaande tests bijwerken die 403-toast voor demo verwachten | | T-958 | n.v.t. | Browser-flow + DB-no-pollution + defense-in-depth + build | --- ## Concrete code-wijzigingen (samengevat) ### 1. SprintSwitcher fork [components/shared/sprint-switcher.tsx:54](../../components/shared/sprint-switcher.tsx): ```tsx const isDemo = useUserSettingsStore(s => s.context.isDemo) function handleSwitchSprint(sprintId: string) { if (sprintId === activeSprint?.id) return if (isDemo) { router.push(`/products/${productId}/sprint/${sprintId}`) return } startTransition(async () => { const result = await setActiveSprintAction(productId, sprintId) // ... bestaande logica }) } ``` ### 2. NavBar fork + URL-derived display [components/shared/nav-bar.tsx:48](../../components/shared/nav-bar.tsx): ```tsx const pathname = usePathname() const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null const displayActive = isDemo && urlProductId ? (products.find(p => p.id === urlProductId) ?? activeProduct) : activeProduct function handleSwitchProduct(productId: string) { if (productId === activeProduct?.id) return if (isDemo) { router.push(`/products/${productId}`) return } startTransition(async () => { const result = await setActiveProductAction(productId) // ... bestaande logica }) } ``` In de render: vervang gebruik van `activeProduct` door `displayActive` in label, highlight-class en onClick-equality-check. ### 3. ADR-0006 addendum Nieuwe sectie **"Updated 2026-05-11 — Exception for client-side UI preferences"** na "Consequences" — zie T-955 implementation_plan voor volledige tekst. ### 4. Server-actions — *geen wijziging* Alle 403-guards blijven: - [actions/active-product.ts:20](../../actions/active-product.ts) - [actions/active-sprint.ts:24](../../actions/active-sprint.ts) - [actions/user-settings.ts:28](../../actions/user-settings.ts) --- ## Verificatie (end-to-end checklist) ```bash npm run verify && npm run build ``` **Functioneel — handmatig in browser (zie T-958):** 1. Reset: `psql $DATABASE_URL -c "UPDATE \"User\" SET settings='{}'::jsonb, active_product_id=NULL WHERE username='demo';"` 2. Login als `demo`/`demo1234`. 3. `/dashboard` → producten zichtbaar → klik ander product → URL klopt → label klopt → géén toast. 4. Backlog → status/priority filter wijzigen → werkt direct → géén POST in Network-tab. 5. Sort op kolom → werkt direct. 6. Sprint-switcher → andere sprint → URL klopt → board laadt → géén toast. 7. Split-pane verslepen → blijft binnen sessie. 8. Hard refresh → defaults terug (verwacht in-memory). 9. Tweede tab → eigen state, geen kruisbestuiving. **Defense in depth:** 10. DevTools console: `await fetch('/api/products', {method:'POST',body:'{}'})` → 403. 11. `grep -rn "session.isDemo" actions/` → alle write-actions houden hun guard. **DB-no-pollution:** 12. `SELECT settings, active_product_id FROM "User" WHERE username='demo';` → `{}` en NULL. **Tests:** 13. `npm test` → alle tests slagen, inclusief nieuwe NavBar/SprintSwitcher tests. --- ## Risico's & mitigaties | Risico | Mitigatie | |---|---| | Toekomstige UI-code roept per ongeluk een write-action aan voor demo | Server-action 403 blijft + nieuwe `demo-client-state.md` patroon-doc + ADR-0006 update | | Server-side render van NavBar toont seed-default `activeProduct` na product-switch | URL-derived `displayActive` (T-953) | | `setActiveSprintInSettings()` in [lib/active-sprint.ts:51](../../lib/active-sprint.ts) heeft geen interne demo-check (huidige tech debt) | Buiten scope: alle bekende callers checken al `session.isDemo`. Eventueel apart op te pakken. | | Demo verliest filterkeuze bij refresh | Acceptabel volgens vragenronde (in-memory gekozen). |