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 && patterns to
so demo visitors see app capabilities.
Affects: pbi-list, story-panel, story-dialog, task-list, sprint-backlog,
token-manager, product-list, activate-product-button, leave-product-button,
settings page.
* test(ST-1110.6): proxy demo-guard coverage — 403 for demo+non-GET on /api/*
* docs(ST-1110.7): document three-layer demo-readonly policy and mirror plan
---
CLAUDE.md | 2 +-
__tests__/api/pair-claim.test.ts | 8 +-
__tests__/api/pair-start.test.ts | 7 +-
__tests__/proxy/demo-guard.test.ts | 78 +++++++++++++++++++
app/(app)/settings/page.tsx | 9 +--
app/api/auth/pair/claim/route.ts | 5 ++
app/api/auth/pair/start/route.ts | 8 ++
components/backlog/pbi-list.tsx | 52 +++++++------
components/backlog/story-dialog.tsx | 36 +++++----
components/backlog/story-panel.tsx | 30 ++++---
components/dashboard/product-list.tsx | 40 ++++++----
components/settings/leave-product-button.tsx | 23 +++---
components/settings/token-manager.tsx | 46 +++++------
components/shared/activate-product-button.tsx | 18 +++--
components/sprint/sprint-backlog.tsx | 18 +++--
components/sprint/task-list.tsx | 27 ++++---
docs/plans/ST-1110-demo-readonly.md | 62 +++++++++++++++
docs/scrum4me-architecture.md | 46 +++++++++++
proxy.ts | 40 ++++++++--
19 files changed, 413 insertions(+), 142 deletions(-)
create mode 100644 __tests__/proxy/demo-guard.test.ts
create mode 100644 docs/plans/ST-1110-demo-readonly.md
diff --git a/CLAUDE.md b/CLAUDE.md
index 7dad5ee..61e57cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -137,7 +137,7 @@ Volledige Zod-schema in `lib/env.ts`. `.env.example` is de canonieke lijst voor
- **Toegangsmodel:** product-scoped resources gebruiken `productAccessFilter(userId)` tenzij het expliciet een eigenaarsactie is
- **Bulk-ID's:** reorder- en beslissingsacties valideren dat alle meegegeven IDs binnen dezelfde parent-scope vallen voordat er geschreven wordt
- **Foreign keys:** denormalized keys zoals `story.product_id` worden afgeleid uit de database-parent (`pbi.product_id`), nooit uit client-input
-- **Demo-check:** elke Server Action controleert `session.isDemo` vóór schrijven
+- **Demo-check (drie lagen — ST-1110):** write-acties zijn drielaags afgedekt: (1) middleware-guard in `proxy.ts` blokkeert non-GET op `/api/*` voor demo; (2) elke Server Action / Route Handler controleert `session.isDemo` vóór schrijven; (3) write-knoppen in UI zijn `disabled` met ``. Zie `docs/scrum4me-architecture.md#demo-user-policy` en `docs/plans/ST-1110-demo-readonly.md`
- **Foutberichten:** Nederlands voor eindgebruikers — comments in code: Engels
- **Dependencies:** elke geïmporteerde runtime package staat direct in `dependencies`, niet alleen transitief in `package-lock.json`
- **Docs-sync:** elke gedrags-, dependency-, API- of deploymentwijziging werkt README, relevante docs en patterns bij in dezelfde change
diff --git a/__tests__/api/pair-claim.test.ts b/__tests__/api/pair-claim.test.ts
index 3c594e5..60406f2 100644
--- a/__tests__/api/pair-claim.test.ts
+++ b/__tests__/api/pair-claim.test.ts
@@ -103,7 +103,7 @@ describe('POST /api/auth/pair/claim', () => {
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
})
- it('demo-user: isDemo doorgezet als vangnet', async () => {
+ it('demo-user: claim geblokkeerd met 403 (ST-1110.4)', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
mockPrisma.loginPairing.findUnique.mockResolvedValue({
@@ -112,8 +112,10 @@ describe('POST /api/auth/pair/claim', () => {
})
const res = await POST(makePost({ pairingId: PAIRING_ID }))
- expect(res.status).toBe(200)
- expect(mockSession.isDemo).toBe(true)
+ expect(res.status).toBe(403)
+ const body = await res.json()
+ expect(body.error).toMatch(/demo-modus/i)
+ expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
})
it('401 zonder s4m_pair-cookie', async () => {
diff --git a/__tests__/api/pair-start.test.ts b/__tests__/api/pair-start.test.ts
index 8c7207f..7543458 100644
--- a/__tests__/api/pair-start.test.ts
+++ b/__tests__/api/pair-start.test.ts
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-const { cookieJar } = vi.hoisted(() => ({
+const { cookieJar, mockGetIronSession } = vi.hoisted(() => ({
cookieJar: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
+ mockGetIronSession: vi.fn().mockResolvedValue({ isDemo: false }),
+}))
+
+vi.mock('iron-session', () => ({
+ getIronSession: mockGetIronSession,
}))
vi.mock('@/lib/prisma', () => ({
diff --git a/__tests__/proxy/demo-guard.test.ts b/__tests__/proxy/demo-guard.test.ts
new file mode 100644
index 0000000..f229a8f
--- /dev/null
+++ b/__tests__/proxy/demo-guard.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const { mockUnsealData } = vi.hoisted(() => ({
+ mockUnsealData: vi.fn(),
+}))
+
+vi.mock('iron-session', () => ({
+ unsealData: mockUnsealData,
+}))
+
+vi.mock('@/lib/session', () => ({
+ sessionOptions: { cookieName: 'scrum4me-session', password: 'test-secret' },
+}))
+
+import { NextRequest } from 'next/server'
+import { proxy } from '@/proxy'
+
+const COOKIE_NAME = 'scrum4me-session'
+const RAW_COOKIE = 'sealed-cookie-value'
+
+function makeRequest(method: string, path: string, withCookie = false): NextRequest {
+ const url = `http://localhost:3000${path}`
+ const headers = new Headers()
+ if (withCookie) headers.set('Cookie', `${COOKIE_NAME}=${RAW_COOKIE}`)
+ return new NextRequest(url, { method, headers })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('proxy demo-guard', () => {
+ it('demo + POST /api/todos → 403', async () => {
+ mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
+ const req = makeRequest('POST', '/api/todos', true)
+ const res = await proxy(req)
+ expect(res?.status).toBe(403)
+ const body = await res?.json()
+ expect(body.error).toMatch(/demo-modus/i)
+ })
+
+ it('demo + GET /api/todos → passthrough (GET is veilig)', async () => {
+ const req = makeRequest('GET', '/api/todos', true)
+ const res = await proxy(req)
+ // NextResponse.next() heeft geen status 403
+ expect(res?.status).not.toBe(403)
+ // unsealData nooit aangeroepen voor GET
+ expect(mockUnsealData).not.toHaveBeenCalled()
+ })
+
+ it('non-demo + POST /api/todos → passthrough', async () => {
+ mockUnsealData.mockResolvedValue({ userId: 'real-user', isDemo: false })
+ const req = makeRequest('POST', '/api/todos', true)
+ const res = await proxy(req)
+ expect(res?.status).not.toBe(403)
+ })
+
+ it('geen cookie + POST /api/todos → passthrough (geen sessie = niet geblokkeerd)', async () => {
+ const req = makeRequest('POST', '/api/todos', false)
+ const res = await proxy(req)
+ expect(mockUnsealData).not.toHaveBeenCalled()
+ expect(res?.status).not.toBe(403)
+ })
+
+ it('demo + POST /api/cron/expire-questions → passthrough (cron in allowlist)', async () => {
+ const req = makeRequest('POST', '/api/cron/expire-questions', true)
+ const res = await proxy(req)
+ expect(mockUnsealData).not.toHaveBeenCalled()
+ expect(res?.status).not.toBe(403)
+ })
+
+ it('demo + POST /api/auth/pair/start → 403 (M11-keuze: blokken)', async () => {
+ mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
+ const req = makeRequest('POST', '/api/auth/pair/start', true)
+ const res = await proxy(req)
+ expect(res?.status).toBe(403)
+ })
+})
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx
index 07b05a8..b299e45 100644
--- a/app/(app)/settings/page.tsx
+++ b/app/(app)/settings/page.tsx
@@ -95,10 +95,7 @@ export default async function SettingsPage() {
{!session.isDemo && (
-
+
+ Nieuw product
)}
@@ -149,8 +146,8 @@ export default async function SettingsPage() {
label="Maak actief"
/>
)}
- {pb.kind === 'member' && !session.isDemo && (
-
+ {pb.kind === 'member' && (
+
)}
diff --git a/app/api/auth/pair/claim/route.ts b/app/api/auth/pair/claim/route.ts
index 69b4b89..555cd39 100644
--- a/app/api/auth/pair/claim/route.ts
+++ b/app/api/auth/pair/claim/route.ts
@@ -80,6 +80,11 @@ export async function POST(request: Request) {
return Response.json({ error: 'Pairing zonder user' }, { status: 500 })
}
+ if (pairing.user?.is_demo) {
+ await clearPairCookie()
+ return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
+ }
+
const session = await getIronSession(await cookies(), sessionOptions)
session.userId = pairing.user_id
session.isDemo = pairing.user?.is_demo ?? false
diff --git a/app/api/auth/pair/start/route.ts b/app/api/auth/pair/start/route.ts
index 2062887..30ef836 100644
--- a/app/api/auth/pair/start/route.ts
+++ b/app/api/auth/pair/start/route.ts
@@ -9,6 +9,8 @@
//
// Rate-limit: 10 pogingen per IP per minuut (lib/rate-limit.ts → 'pair-start').
+import { getIronSession } from 'iron-session'
+import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
import {
generateMobileSecret,
@@ -17,6 +19,7 @@ import {
} from '@/lib/auth/pairing'
import { setPairCookie } from '@/lib/auth/pair-cookie'
import { checkRateLimit } from '@/lib/rate-limit'
+import { SessionData, sessionOptions } from '@/lib/session'
export const runtime = 'nodejs'
@@ -34,6 +37,11 @@ function getClientIp(request: Request): string {
}
export async function POST(request: Request) {
+ const session = await getIronSession(await cookies(), sessionOptions)
+ if (session.isDemo) {
+ return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
+ }
+
const ip = getClientIp(request)
if (!checkRateLimit(`pair-start:${ip}`)) {
return Response.json(
diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx
index 9586fd8..c4b0171 100644
--- a/components/backlog/pbi-list.tsx
+++ b/components/backlog/pbi-list.tsx
@@ -32,6 +32,7 @@ import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card'
+import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
import type { PbiStatusApi } from '@/lib/task-status'
@@ -164,24 +165,30 @@ function SortablePbiRow({
{PBI_STATUS_LABELS[pbi.status]}
}
- actions={!isDemo ? (
+ actions={
- { e.stopPropagation(); onEdit() }}
- className="border border-border rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
- aria-label="Bewerk PBI"
- >
- ✎
-
- { e.stopPropagation(); onDelete() }}
- className="text-muted-foreground hover:text-error text-xs"
- aria-label="Verwijder PBI"
- >
- ×
-
+
+ { e.stopPropagation(); if (!isDemo) onEdit() }}
+ className="border border-border rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+ aria-label="Bewerk PBI"
+ disabled={isDemo}
+ >
+ ✎
+
+
+
+ { e.stopPropagation(); if (!isDemo) onDelete() }}
+ className="text-muted-foreground hover:text-error text-xs disabled:opacity-40 disabled:cursor-not-allowed"
+ aria-label="Verwijder PBI"
+ disabled={isDemo}
+ >
+ ×
+
+
- ) : undefined}
+ }
/>
)
}
@@ -383,15 +390,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
- {!isDemo && (
+
setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
+ disabled={isDemo}
+ onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
- )}
+
>
}
/>
@@ -400,11 +408,11 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
{pbis.length === 0 ? (
Nog geen PBI's aangemaakt.
- {!isDemo && (
-
setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
+
+ !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
Maak je eerste PBI aan
- )}
+
) : (
= {
DONE: 'Klaar',
}
-function SubmitButton({ label }: { label: string }) {
+function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) {
const { pending } = useFormStatus()
return (
-
+
{pending ? '…' : label}
)
@@ -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
) : (
-
setConfirmDelete(true)}
- >
- Story verwijderen
-
+
+ !isDemo && setConfirmDelete(true)}
+ >
+ Story verwijderen
+
+
)}
)}
}>
- {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 && (
- setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
- >
- + Story
-
+ {selectedPbiId && (
+
+ !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
+ >
+ + Story
+
+
)}
>
}
@@ -244,10 +248,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
) : rawStories.length === 0 ? (
Nog geen stories voor dit PBI.
- {!isDemo && selectedPbiId && (
-
setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
- Maak je eerste story aan
-
+ {selectedPbiId && (
+
+ !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
+ Maak je eerste story aan
+
+
)}
) : (
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 && (
- }>
+
+ } disabled={isDemo}>
Maak je eerste product aan
- )}
+
)
}
@@ -103,21 +103,27 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd
product.id === activeProductId
? Actief
: (
- { e.stopPropagation(); handleActivate(product.id) }}
- className="text-xs text-primary hover:underline"
- >
- Activeer
-
+
+ { e.stopPropagation(); if (!isDemo) handleActivate(product.id) }}
+ className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
+ disabled={isDemo}
+ >
+ Activeer
+
+
)
)}
- {showArchived && !isDemo && (
- { e.stopPropagation(); handleRestore(product.id) }}
- className="text-xs text-primary hover:underline"
- >
- Herstellen
-
+ {showArchived && (
+
+ { e.stopPropagation(); if (!isDemo) handleRestore(product.id) }}
+ className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
+ disabled={isDemo}
+ >
+ Herstellen
+
+
)}
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 (
- setConfirming(true)}
- >
- Verlaten
-
+
+ !isDemo && setConfirming(true)}
+ >
+ Verlaten
+
+
)
}
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 (
-
- {pending ? 'Aanmaken…' : 'Token aanmaken'}
-
+
+
+ {pending ? 'Aanmaken…' : 'Token aanmaken'}
+
+
)
}
@@ -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 && (
+
handleRevoke(token.id)}
+ disabled={isDemo}
+ onClick={() => !isDemo && handleRevoke(token.id)}
>
Intrekken
- )}
+
))}
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 (
-
- {label}
-
+
+ !isDemo && handleActivate()}
+ disabled={isDemo || isPending}
+ className="text-xs text-primary hover:underline font-medium disabled:opacity-50 disabled:no-underline"
+ >
+ {label}
+
+
)
}
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 && (
+
{ e.stopPropagation(); onRemove() }}
- className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error"
+ onClick={e => { e.stopPropagation(); if (!isDemo) onRemove() }}
+ className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Verwijder uit sprint"
+ disabled={isDemo}
>
- )}
+
@@ -352,14 +353,15 @@ function DraggablePbiStoryRow({
- {!isDemo && (
+
!isDemo && onAdd()}
+ className="text-xs text-primary hover:underline shrink-0 disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
+ disabled={isDemo}
>
+ Toevoegen
- )}
+
)
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 && (
-
- setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk
- ×
-
- )}
+
+
+ !isDemo && setEditing(true)} disabled={isDemo} className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed">Bewerk
+
+
+ !isDemo && onDelete()} disabled={isDemo} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed">×
+
+
@@ -220,9 +223,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
actions={
<>
{doneCount}/{orderedTasks.length} klaar
- {!isDemo && (
- setCreating(true)}>+ Taak
- )}
+
+ !isDemo && setCreating(true)}>+ Taak
+
>
}
/>
@@ -235,7 +238,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
{orderedTasks.length === 0 && !creating ? (
Geen taken voor deze story.
- {!isDemo &&
setCreating(true)}>Maak eerste taak aan }
+
+ !isDemo && setCreating(true)}>Maak eerste taak aan
+
) : (
` 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 && }`, minder visuele clutter
+- Disabled + tooltip "Niet beschikbaar in demo-modus" — bezoeker ziet wat de app kan
+
+**Antwoord (2026-04-29):** **Disabled + tooltip**
+
+**Implementatie:** Alle `!isDemo && ` patronen omgezet naar ``.
+
+## Bestanden gewijzigd
+
+| Task | Commits | Bestanden |
+|---|---|---|
+| ST-1110.3 | `feat(ST-1110.3)` | `proxy.ts` |
+| ST-1110.3+4 | `feat(ST-1110.3+4)` | `proxy.ts`, `app/api/auth/pair/start/route.ts`, `app/api/auth/pair/claim/route.ts` |
+| ST-1110.5 | `feat(ST-1110.5)` | 12 component/pagina-bestanden |
+| ST-1110.5 tests | `test(ST-1110.5)` | `__tests__/api/pair-claim.test.ts`, `__tests__/api/pair-start.test.ts` |
+| ST-1110.6 | `test(ST-1110.6)` | `__tests__/proxy/demo-guard.test.ts` |
+| ST-1110.7 | `docs(ST-1110.7)` | `docs/scrum4me-architecture.md`, dit bestand |
+
+## Aandachtspunten toekomstige stories
+
+- Elke nieuwe write-route (Server Action of Route Handler) moet `session.isDemo` checken
+- De middleware-guard valt terug als defense in depth — niet als enige bescherming
+- Drag-and-drop handles blijven verborgen voor demo (`{!isDemo && }`)
+- Nieuwe write-knoppen in UI: ``
diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md
index 2d2182d..02da55b 100644
--- a/docs/scrum4me-architecture.md
+++ b/docs/scrum4me-architecture.md
@@ -1001,6 +1001,52 @@ Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij d
---
+## Demo-user policy (ST-1110)
+
+Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags:
+
+### Laag 1 — Middleware-guard (proxy.ts)
+
+`proxy.ts` blokkeert alle non-GET requests op `/api/*` voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt `unsealData` direct (geen `getIronSession`) omdat `request.cookies` in middleware `RequestCookies` is, niet de volledige `CookieStore`.
+
+```ts
+// Whitelist: paden die demo mag aanroepen ondanks non-GET
+const DEMO_WRITE_ALLOWLIST = [
+ '/api/cron/', // machine-auth, irrelevant voor demo
+]
+// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2
+```
+
+### Laag 2 — Per-route guards (Server Actions & Route Handlers)
+
+Elke schrijfactie controleert `session.isDemo` vóór DB-toegang:
+
+```ts
+if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
+```
+
+**QR-pairing (M10):**
+- `pair/start`: isDemo-check via `getIronSession(await cookies(), sessionOptions)` — blokkeert demo-desktops
+- `pair/claim`: check `pairing.user?.is_demo` na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd
+- `pair/approve` en `pair/cancel`: waren al geblokkeerd vóór ST-1110
+
+**Realtime SSE en cron-routes:** niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth).
+
+### Laag 3 — UI-laag (DemoTooltip)
+
+Alle write-knoppen zijn `disabled` met een `DemoTooltip show={isDemo}` wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: `components/shared/demo-tooltip.tsx`.
+
+Patroon:
+```tsx
+
+ !isDemo && handleAction()}>
+ Actie
+
+
+```
+
+**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).*)'],
}