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={
- - + + + + + +
- ) : 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).*)'], }