From 96e44234853a2903d47a19739f037fdbc765ab57 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 12 May 2026 19:33:25 +0200 Subject: [PATCH] docs(PBI-80): ADR-0006 addendum + demo-client-state patroon (ST-1347) ADR-0006 krijgt een "Updated 2026-05-12"-sectie die de PBI-80-uitzondering documenteert: client-side UI-prefs (filters, sort, layout, scope-keuze) zijn voor demo toegestaan via in-memory store, terwijl alle data-mutaties three-layer beschermd blijven. Patroon-doc beschrijft wanneer en hoe `isDemo` te gebruiken in nieuwe componenten. CLAUDE.md quickref + docs/INDEX.md ge-update. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + docs/INDEX.md | 4 +- docs/adr/0006-demo-user-three-layer-policy.md | 21 ++ docs/patterns/demo-client-state.md | 129 ++++++++++ docs/plans/PBI-80-demo-prefs.md | 229 ++++++++++++++++++ 5 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 docs/patterns/demo-client-state.md create mode 100644 docs/plans/PBI-80-demo-prefs.md diff --git a/CLAUDE.md b/CLAUDE.md index 40245da..9e93517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` | | Debug-id op component-root | `docs/patterns/debug-id.md` | | Debug-labels (BEM) | `docs/patterns/debug-labels.md` | +| Demo client-state (PBI-80) | `docs/patterns/demo-client-state.md` | --- diff --git a/docs/INDEX.md b/docs/INDEX.md index e7f4d51..10f331e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-10 from front-matter and headings. +Auto-generated on 2026-05-12 from front-matter and headings. ## Architecture Decision Records @@ -55,6 +55,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — | | [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — | | [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — | +| [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — | | [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | @@ -83,6 +84,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [Bidirectionele async-comms MCP-agent ↔ user](./patterns/claude-question-channel.md) | active | 2026-05-03 | | [Debug-id op component-root](./patterns/debug-id.md) | active | 2026-05-09 | | [Debug-labels: BEM data-debug-id patroon](./patterns/debug-labels.md) | active | 2026-05-09 | +| [Demo client-state (UI-prefs zonder DB)](./patterns/demo-client-state.md) | active | 2026-05-12 | | [Entity Dialog](./patterns/dialog.md) | active | 2026-05-08 | | [iron-session](./patterns/iron-session.md) | active | 2026-05-03 | | [Prisma Client singleton](./patterns/prisma-client.md) | active | 2026-05-03 | diff --git a/docs/adr/0006-demo-user-three-layer-policy.md b/docs/adr/0006-demo-user-three-layer-policy.md index cbcbc85..032df22 100644 --- a/docs/adr/0006-demo-user-three-layer-policy.md +++ b/docs/adr/0006-demo-user-three-layer-policy.md @@ -28,3 +28,24 @@ Write protection for the demo user is enforced at **three independent layers**: - Three enforcement sites for every new write operation — easy to miss one when adding a new feature. - Mitigation: the `DemoTooltip` pattern is documented in `docs/patterns/` and enforced in code review. + +## Updated 2026-05-12 — Exception for client-side UI preferences + +PBI-80 relaxes the policy *for client-side UI preferences only*: + +- **Allowed for demo:** product-switch and sprint-switch via URL navigation, + filters/sort, layout state (split-panes, collapsed PBIs, selections) — + routed through the in-memory `useUserSettingsStore`. +- **Why this is safe:** none of these touch the database. The demo user is a + single shared row, but each visitor's browser holds its own Zustand store + and URL state. A refresh resets to seed defaults; visitors never see each + other's choices. +- **Unchanged — three-layer enforcement still applies to:** all data mutations + (PBI/story/task/sprint create/update/delete/reorder), account fields + (username, password, email), role assignment, QR-pairing, web-push, and any + cron/webhook secrets. +- **Pattern for new demo-friendly features:** if it is UI state, route it + through `useUserSettingsStore.setPref` (which already has a demo-fork at + [stores/user-settings/store.ts:80](../../stores/user-settings/store.ts)) or + pure URL navigation via `router.push`. Never call a server action for demo. + See [docs/patterns/demo-client-state.md](../patterns/demo-client-state.md). diff --git a/docs/patterns/demo-client-state.md b/docs/patterns/demo-client-state.md new file mode 100644 index 0000000..f88c473 --- /dev/null +++ b/docs/patterns/demo-client-state.md @@ -0,0 +1,129 @@ +--- +title: "Demo client-state (UI-prefs zonder DB)" +status: active +audience: [ai-agent, contributor] +language: nl +last_updated: 2026-05-12 +when_to_read: "Bij elk nieuw UI-element dat de demo-gebruiker zou willen kunnen wijzigen — filter, sortering, panel-state, geselecteerde scope (product/sprint), enz." +--- + +# Patroon: Demo client-state + +De demo-gebruiker (`session.isDemo === true`) deelt één DB-rij met alle andere +demo-bezoekers. DB-writes voor demo zouden cross-bezoeker-pollution geven, dus +de three-layer policy uit [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) +blokkeert ze. PBI-80 introduceert één uitzondering: **client-side UI-state mag +gewijzigd worden, in-memory en zonder server-call.** + +--- + +## Wanneer toepassen + +| Soort wijziging | Voor demo? | Hoe | +|---|---|---| +| Filter / sortering / collapse / split-pane / selectie | **Ja** | `useUserSettingsStore.setPref([...], value)` — store regelt de demo-fork al | +| Wisselen van actief product of sprint | **Ja** | `router.push('/products/...')` zonder server-action | +| PBI/story/taak/sprint create/update/delete/reorder | **Nee** | Server-action met 403-guard blijft hard verplicht | +| Account, rollen, pairing, web-push | **Nee** | Idem | + +--- + +## Hoe `isDemo` lezen (client component) + +```tsx +import { useUserSettingsStore } from '@/stores/user-settings/store' + +const isDemo = useUserSettingsStore(s => s.context.isDemo) +``` + +`UserSettingsBridge` hydrateert deze waarde in `app/(app)/layout.tsx`, +dus elke client child ziet meteen de juiste vlag. + +--- + +## Voorbeeld 1 — UI pref (filters, sort, layout) + +Geen extra werk. De store-actie regelt de demo-fork zelf: + +```tsx +// Werkt voor alle gebruikers, demo + niet-demo +useUserSettingsStore.getState().setPref( + ['views', 'pbiList', 'filterStatus'], + 'OPEN', +) +``` + +Voor demo doet `setPref` een lokale Zustand-merge zonder server-call; +voor niet-demo gaat het via `updateUserSettingsAction` (DB + SSE). + +--- + +## Voorbeeld 2 — Scope-wissel (product/sprint) + +Fork in de UI-handler — server-action blijft achter de fork onveranderd: + +```tsx +function handleSwitchProduct(productId: string) { + if (productId === activeId) return + if (isDemo) { + router.push(`/products/${productId}`) + return + } + startTransition(async () => { + const result = await setActiveProductAction(productId) + // ... bestaande not-demo flow + }) +} +``` + +Voor pagina's waarvan de scope al in de URL zit (zoals `/products/[id]/sprint/[sprintId]`) +is `router.push` met de gewenste path voldoende — server resolveert de +juiste data uit de URL-params. + +--- + +## Visuele consistentie na URL-only switch + +Server-rendered layouts blijven voor demo de seed-default lezen +(`user.active_product_id`, `user.settings.layout.activeSprints[...]`). Als de +UI een "actief X"-label toont dat van de server-prop komt, leid het voor demo +af uit `pathname`: + +```tsx +const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null +const displayActive = + isDemo && urlProductId + ? products.find(p => p.id === urlProductId) ?? activeProduct + : activeProduct +``` + +Gebruik `displayActive` in de render in plaats van de prop. + +--- + +## Verboden voor demo + +- Server-action aanroepen zonder fork — 403 + onnodige toast. +- Wegschrijven naar cookies of localStorage — pollutie tussen bezoekers. +- `setActiveSprintInSettings` / vergelijkbare DB-helpers rechtstreeks aanroepen. +- Web-push subscription registreren — schrijft naar gedeelde `PushSubscription`-tabel. + +--- + +## Defense in depth + +Server-actions (`actions/active-product.ts`, `actions/active-sprint.ts`, +`actions/user-settings.ts`) **behouden** hun `if (session.isDemo) return 403`-guard. +Als toekomstige UI-code per ongeluk de fork mist, faalt de call hard met 403 en +zien we het via toast/logs. + +--- + +## Zie ook + +- [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) — three-layer + beschermingen + de PBI-80-uitzondering. +- [docs/patterns/proxy.md](./proxy.md) — proxy-laag die `/api/*`-writes voor + demo afvangt. +- [stores/user-settings/store.ts](../../stores/user-settings/store.ts) — bron + van waarheid voor `isDemo` + `setPref` met demo-fork. diff --git a/docs/plans/PBI-80-demo-prefs.md b/docs/plans/PBI-80-demo-prefs.md new file mode 100644 index 0000000..f91a309 --- /dev/null +++ b/docs/plans/PBI-80-demo-prefs.md @@ -0,0 +1,229 @@ +# 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). |