From 1cb5772edd9b2ab75b551b73c6f95591ec6c0f32 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 18:44:14 +0200 Subject: [PATCH] M12 / ST-1110: Demo gebruiker read-only (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ST-1110.3): add proxy.ts demo-guard for non-GET API routes * feat(ST-1110.3+4): demo-guard proxy + block demo in QR-pairing - proxy.ts: gebruik unsealData ipv getIronSession (middleware-compatibel) - pair/start: isDemo-check via cookies() guard - pair/claim: check pairing.user.is_demo na DB-read; 403 + clearPairCookie * feat(ST-1110.5): unify demo write-button pattern to disabled+tooltip Convert all !isDemo && - + + + + + + - ) : undefined} + } /> ) } @@ -383,15 +390,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { - {!isDemo && ( + - )} + } /> @@ -400,11 +408,11 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { {pbis.length === 0 ? (

Nog geen PBI's aangemaakt.

- {!isDemo && ( - - )} +
) : ( = { DONE: 'Klaar', } -function SubmitButton({ label }: { label: string }) { +function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) { const { pending } = useFormStatus() return ( - ) @@ -262,9 +263,9 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps )} - {isEdit && !isDemo && ( + {isEdit && (
- {confirmDelete ? ( + {!isDemo && confirmDelete ? (
Weet je het zeker? Taken worden ook verwijderd. @@ -277,24 +278,29 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
) : ( - + + + )}
)}
}> - {isDemo ? 'Sluiten' : 'Annuleren'} + Annuleren - {!isDemo && } + + +
diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 5f4eb6d..bb65854 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -30,6 +30,7 @@ import { usePlannerStore } from '@/stores/planner-store' import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' import { BacklogCard } from './backlog-card' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' type SortMode = 'priority' | 'code' | 'date' @@ -223,14 +224,17 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) Klaar - {selectedPbiId && !isDemo && ( - + {selectedPbiId && ( + + + )} } @@ -244,10 +248,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) ) : rawStories.length === 0 ? (

Nog geen stories voor dit PBI.

- {!isDemo && selectedPbiId && ( - + {selectedPbiId && ( + + + )}
) : ( diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index fa1280b..d01f8ce 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { restoreProductAction } from '@/actions/products' import { setActiveProductAction } from '@/actions/active-product' @@ -38,7 +39,6 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd } function handleActivate(id: string) { - if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return } startTransition(async () => { const result = await setActiveProductAction(id) if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt') @@ -54,11 +54,11 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd ? 'Geen gearchiveerde producten.' : 'Je hebt nog geen producten aangemaakt.'}

- {!isDemo && !showArchived && ( - - )} + ) } @@ -103,21 +103,27 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd product.id === activeProductId ? Actief : ( - + + + ) )} - {showArchived && !isDemo && ( - + {showArchived && ( + + + )} diff --git a/components/settings/leave-product-button.tsx b/components/settings/leave-product-button.tsx index 4dba5ad..976e480 100644 --- a/components/settings/leave-product-button.tsx +++ b/components/settings/leave-product-button.tsx @@ -2,13 +2,15 @@ import { useState, useTransition } from 'react' import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { leaveProductAction } from '@/actions/products' interface LeaveProductButtonProps { productId: string + isDemo?: boolean } -export function LeaveProductButton({ productId }: LeaveProductButtonProps) { +export function LeaveProductButton({ productId, isDemo = false }: LeaveProductButtonProps) { const [confirming, setConfirming] = useState(false) const [isPending, startTransition] = useTransition() @@ -32,13 +34,16 @@ export function LeaveProductButton({ productId }: LeaveProductButtonProps) { } return ( - + + + ) } diff --git a/components/settings/token-manager.tsx b/components/settings/token-manager.tsx index 6f48b51..ba1f705 100644 --- a/components/settings/token-manager.tsx +++ b/components/settings/token-manager.tsx @@ -4,6 +4,7 @@ import { useState, useActionState, useTransition } from 'react' import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { createApiTokenAction, revokeApiTokenAction } from '@/actions/api-tokens' interface Token { @@ -18,12 +19,14 @@ interface TokenManagerProps { isDemo: boolean } -function CreateSubmitButton() { +function CreateSubmitButton({ isDemo }: { isDemo: boolean }) { const { pending } = useFormStatus() return ( - + + + ) } @@ -80,21 +83,19 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { )} {/* Create form */} - {!isDemo && ( -
-

Nieuw token aanmaken

-
- - - - {typeof state?.error === 'string' && ( -

{state.error}

- )} -

- Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}. -

-
- )} +
+

Nieuw token aanmaken

+
+ + + + {typeof state?.error === 'string' && ( +

{state.error}

+ )} +

+ Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}. +

+
{/* Active tokens */}
@@ -111,16 +112,17 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { Aangemaakt {new Date(token.created_at).toLocaleDateString('nl-NL')}

- {!isDemo && ( + - )} + ))} diff --git a/components/shared/activate-product-button.tsx b/components/shared/activate-product-button.tsx index 90cf54b..90c19c4 100644 --- a/components/shared/activate-product-button.tsx +++ b/components/shared/activate-product-button.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation' import { useTransition } from 'react' import { toast } from 'sonner' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { setActiveProductAction } from '@/actions/active-product' interface Props { @@ -18,7 +19,6 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = ' const [isPending, startTransition] = useTransition() function handleActivate() { - if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return } startTransition(async () => { const result = await setActiveProductAction(productId) if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt') @@ -28,12 +28,14 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = ' } return ( - + + + ) } diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index 5d4ceb7..3555912 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -189,15 +189,16 @@ function SortableSprintRow({ - {!isDemo && ( + - )} + @@ -352,14 +353,15 @@ function DraggablePbiStoryRow({ - {!isDemo && ( + - )} + ) diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 3261322..99650c3 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -24,6 +24,7 @@ import { createTaskAction, updateTaskStatusAction, updateTaskAction, deleteTaskAction, reorderTasksAction, } from '@/actions/tasks' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' const STATUS_CYCLE: Record = { @@ -99,7 +100,7 @@ function SortableTaskRow({ PRIORITY_BORDER[task.priority] )}> {!isDemo && ( - + )}
@@ -114,12 +115,14 @@ function SortableTaskRow({ {STATUS_LABELS[task.status]} - {!isDemo && ( -
- - -
- )} +
+ + + + + + +
@@ -220,9 +223,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, actions={ <> {doneCount}/{orderedTasks.length} klaar - {!isDemo && ( - - )} + + + } /> @@ -235,7 +238,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, {orderedTasks.length === 0 && !creating ? (

Geen taken voor deze story.

- {!isDemo && } + + +
) : ( ` of `disabled+toast`) + +## Gekozen aanpak (ST-1110.2) + +Drie-laagse bescherming: +1. **Middleware-guard** in `proxy.ts` (defense in depth voor toekomstige routes) +2. **Per-route guards** in Server Actions en Route Handlers +3. **UI-laag**: uniform `disabled + DemoTooltip` + +## M11-antwoorden + +### ST-1110.4 — QR-pairing voor demo-gebruiker +**Vraag:** Mag een demo-gebruiker een QR-pairing starten en claimen? +**Opties:** +- Blokken — voeg isDemo-check toe in pair/start en pair/claim, demo krijgt 403 +- Openhouden — geen wijziging; demo kan pair-flow starten maar approve faalt toch + +**Antwoord (2026-04-29):** **Blokken** + +**Implementatie:** +- `pair/start`: `getIronSession(await cookies(), sessionOptions)` → 403 als `session.isDemo` +- `pair/claim`: check `pairing.user?.is_demo` na DB-read → 403 + `clearPairCookie()` +- proxy.ts DEMO_WRITE_ALLOWLIST bevat pair-paden NIET + +### ST-1110.5 — Write-knoppen voor demo-gebruiker +**Vraag:** Hoe write-knoppen tonen aan demo-gebruikers? +**Opties:** +- Verbergen — `{!isDemo && + +``` + +**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. + ## Environment variables | Variabele | Doel | Waar te vinden | diff --git a/proxy.ts b/proxy.ts index 0a4e228..afbfd55 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,17 +1,43 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { sessionOptions } from '@/lib/session' +import { unsealData } from 'iron-session' +import { sessionOptions, type SessionData } from '@/lib/session' const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo'] const authRoutes = ['/login', '/register'] -export function proxy(request: NextRequest) { - const path = request.nextUrl.pathname - const isProtected = protectedRoutes.some(r => path.startsWith(r)) - const isAuthRoute = authRoutes.some(r => path.startsWith(r)) +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) - // Check cookie existence only — full session validation happens in layout.tsx +// Paden die demo MAY aanroepen ook al zijn het non-GET — worden ingevuld na ST-1110.4 +const DEMO_WRITE_ALLOWLIST = [ + '/api/cron/', // machine-auth, irrelevant for demo +] + +export async function proxy(request: NextRequest) { + const { pathname, method } = { pathname: request.nextUrl.pathname, method: request.method } + + // Demo-guard: block non-GET API writes for demo users (defense in depth) + if ( + pathname.startsWith('/api/') && + !SAFE_METHODS.has(method) && + !DEMO_WRITE_ALLOWLIST.some(p => pathname.startsWith(p)) + ) { + const raw = request.cookies.get(sessionOptions.cookieName)?.value + if (raw) { + const session = await unsealData(raw, { password: sessionOptions.password as string }) + if (session.isDemo) { + return NextResponse.json( + { error: 'Niet beschikbaar in demo-modus' }, + { status: 403 } + ) + } + } + } + + // Route protection: check cookie existence only — full validation in layout.tsx const hasSession = !!request.cookies.get(sessionOptions.cookieName)?.value + const isProtected = protectedRoutes.some(r => pathname.startsWith(r)) + const isAuthRoute = authRoutes.some(r => pathname.startsWith(r)) if (isProtected && !hasSession) { return NextResponse.redirect(new URL('/login', request.url)) @@ -25,5 +51,5 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }