M12 / ST-1110: Demo gebruiker read-only (#17)
* 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 && <Button> patterns to <DemoTooltip show={isDemo}>
<Button disabled={isDemo}> 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
This commit is contained in:
parent
8a9fb9d32b
commit
1cb5772edd
19 changed files with 413 additions and 142 deletions
|
|
@ -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
|
- **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
|
- **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
|
- **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 `<DemoTooltip show={isDemo}>`. Zie `docs/scrum4me-architecture.md#demo-user-policy` en `docs/plans/ST-1110-demo-readonly.md`
|
||||||
- **Foutberichten:** Nederlands voor eindgebruikers — comments in code: Engels
|
- **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`
|
- **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
|
- **Docs-sync:** elke gedrags-, dependency-, API- of deploymentwijziging werkt README, relevante docs en patterns bij in dezelfde change
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ describe('POST /api/auth/pair/claim', () => {
|
||||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
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)
|
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||||
|
|
@ -112,8 +112,10 @@ describe('POST /api/auth/pair/claim', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(403)
|
||||||
expect(mockSession.isDemo).toBe(true)
|
const body = await res.json()
|
||||||
|
expect(body.error).toMatch(/demo-modus/i)
|
||||||
|
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('401 zonder s4m_pair-cookie', async () => {
|
it('401 zonder s4m_pair-cookie', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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() },
|
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', () => ({
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
|
|
||||||
78
__tests__/proxy/demo-guard.test.ts
Normal file
78
__tests__/proxy/demo-guard.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -95,10 +95,7 @@ export default async function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!session.isDemo && (
|
{!session.isDemo && (
|
||||||
<Link
|
<Link href="/products/new" className="shrink-0 text-xs text-primary hover:underline font-medium">
|
||||||
href="/products/new"
|
|
||||||
className="shrink-0 text-xs text-primary hover:underline font-medium"
|
|
||||||
>
|
|
||||||
+ Nieuw product
|
+ Nieuw product
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
@ -149,8 +146,8 @@ export default async function SettingsPage() {
|
||||||
label="Maak actief"
|
label="Maak actief"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pb.kind === 'member' && !session.isDemo && (
|
{pb.kind === 'member' && (
|
||||||
<LeaveProductButton productId={pb.id} />
|
<LeaveProductButton productId={pb.id} isDemo={session.isDemo ?? false} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,11 @@ export async function POST(request: Request) {
|
||||||
return Response.json({ error: 'Pairing zonder user' }, { status: 500 })
|
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<SessionData>(await cookies(), sessionOptions)
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
session.userId = pairing.user_id
|
session.userId = pairing.user_id
|
||||||
session.isDemo = pairing.user?.is_demo ?? false
|
session.isDemo = pairing.user?.is_demo ?? false
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
//
|
//
|
||||||
// Rate-limit: 10 pogingen per IP per minuut (lib/rate-limit.ts → 'pair-start').
|
// 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 { prisma } from '@/lib/prisma'
|
||||||
import {
|
import {
|
||||||
generateMobileSecret,
|
generateMobileSecret,
|
||||||
|
|
@ -17,6 +19,7 @@ import {
|
||||||
} from '@/lib/auth/pairing'
|
} from '@/lib/auth/pairing'
|
||||||
import { setPairCookie } from '@/lib/auth/pair-cookie'
|
import { setPairCookie } from '@/lib/auth/pair-cookie'
|
||||||
import { checkRateLimit } from '@/lib/rate-limit'
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
|
@ -34,6 +37,11 @@ function getClientIp(request: Request): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
if (session.isDemo) {
|
||||||
|
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const ip = getClientIp(request)
|
const ip = getClientIp(request)
|
||||||
if (!checkRateLimit(`pair-start:${ip}`)) {
|
if (!checkRateLimit(`pair-start:${ip}`)) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||||
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||||
import type { PbiStatusApi } from '@/lib/task-status'
|
import type { PbiStatusApi } from '@/lib/task-status'
|
||||||
|
|
@ -164,24 +165,30 @@ function SortablePbiRow({
|
||||||
{PBI_STATUS_LABELS[pbi.status]}
|
{PBI_STATUS_LABELS[pbi.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
actions={!isDemo ? (
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<DemoTooltip show={isDemo}>
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
<button
|
||||||
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"
|
onClick={(e) => { e.stopPropagation(); if (!isDemo) onEdit() }}
|
||||||
aria-label="Bewerk PBI"
|
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}
|
||||||
</button>
|
>
|
||||||
<button
|
✎
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
</button>
|
||||||
className="text-muted-foreground hover:text-error text-xs"
|
</DemoTooltip>
|
||||||
aria-label="Verwijder PBI"
|
<DemoTooltip show={isDemo}>
|
||||||
>
|
<button
|
||||||
×
|
onClick={(e) => { e.stopPropagation(); if (!isDemo) onDelete() }}
|
||||||
</button>
|
className="text-muted-foreground hover:text-error text-xs disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Verwijder PBI"
|
||||||
|
disabled={isDemo}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -383,15 +390,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
|
disabled={isDemo}
|
||||||
|
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
|
||||||
>
|
>
|
||||||
+ PBI
|
+ PBI
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -400,11 +408,11 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
{pbis.length === 0 ? (
|
{pbis.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted-foreground text-sm space-y-3">
|
<div className="p-8 text-center text-muted-foreground text-sm space-y-3">
|
||||||
<p>Nog geen PBI's aangemaakt.</p>
|
<p>Nog geen PBI's aangemaakt.</p>
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button size="sm" variant="outline" onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
|
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
|
||||||
Maak je eerste PBI aan
|
Maak je eerste PBI aan
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { PrioritySelect, PRIORITY_LABELS, PRIORITY_COLORS } from '@/components/shared/priority-select'
|
import { PrioritySelect, PRIORITY_LABELS, PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||||
import { StoryLog } from '@/components/shared/story-log'
|
import { StoryLog } from '@/components/shared/story-log'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { createStoryAction, updateStoryAction, deleteStoryAction, getStoryLogsAction } from '@/actions/stories'
|
import { createStoryAction, updateStoryAction, deleteStoryAction, getStoryLogsAction } from '@/actions/stories'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { Story } from './story-panel'
|
import type { Story } from './story-panel'
|
||||||
|
|
@ -42,10 +43,10 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
DONE: 'Klaar',
|
DONE: 'Klaar',
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubmitButton({ label }: { label: string }) {
|
function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={disabled || pending}>
|
||||||
{pending ? '…' : label}
|
{pending ? '…' : label}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
@ -262,9 +263,9 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEdit && !isDemo && (
|
{isEdit && (
|
||||||
<div className="px-5 py-3 border-t border-border shrink-0">
|
<div className="px-5 py-3 border-t border-border shrink-0">
|
||||||
{confirmDelete ? (
|
{!isDemo && confirmDelete ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground flex-1">
|
<span className="text-xs text-muted-foreground flex-1">
|
||||||
Weet je het zeker? Taken worden ook verwijderd.
|
Weet je het zeker? Taken worden ook verwijderd.
|
||||||
|
|
@ -277,24 +278,29 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<DemoTooltip show={isDemo}>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="text-error hover:bg-error/10"
|
size="sm"
|
||||||
onClick={() => setConfirmDelete(true)}
|
className="text-error hover:bg-error/10"
|
||||||
>
|
disabled={isDemo}
|
||||||
Story verwijderen
|
onClick={() => !isDemo && setConfirmDelete(true)}
|
||||||
</Button>
|
>
|
||||||
|
Story verwijderen
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0 rounded-b-xl bg-muted/50">
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0 rounded-b-xl bg-muted/50">
|
||||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||||
{isDemo ? 'Sluiten' : 'Annuleren'}
|
Annuleren
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
{!isDemo && <SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} />}
|
<DemoTooltip show={isDemo}>
|
||||||
|
<SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} disabled={isDemo} />
|
||||||
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { usePlannerStore } from '@/stores/planner-store'
|
||||||
import { reorderStoriesAction } from '@/actions/stories'
|
import { reorderStoriesAction } from '@/actions/stories'
|
||||||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type SortMode = 'priority' | 'code' | 'date'
|
type SortMode = 'priority' | 'code' | 'date'
|
||||||
|
|
@ -223,14 +224,17 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
<SelectItem value="DONE">Klaar</SelectItem>
|
<SelectItem value="DONE">Klaar</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{selectedPbiId && !isDemo && (
|
{selectedPbiId && (
|
||||||
<Button
|
<DemoTooltip show={isDemo}>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-7 text-xs"
|
size="sm"
|
||||||
onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
|
className="h-7 text-xs"
|
||||||
>
|
disabled={isDemo}
|
||||||
+ Story
|
onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
|
||||||
</Button>
|
>
|
||||||
|
+ Story
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
@ -244,10 +248,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
) : rawStories.length === 0 ? (
|
) : rawStories.length === 0 ? (
|
||||||
<div className="text-center mt-8 space-y-3">
|
<div className="text-center mt-8 space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
|
<p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
|
||||||
{!isDemo && selectedPbiId && (
|
{selectedPbiId && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
|
<DemoTooltip show={isDemo}>
|
||||||
Maak je eerste story aan
|
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
|
||||||
</Button>
|
Maak je eerste story aan
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CodeBadge } from '@/components/shared/code-badge'
|
import { CodeBadge } from '@/components/shared/code-badge'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { restoreProductAction } from '@/actions/products'
|
import { restoreProductAction } from '@/actions/products'
|
||||||
import { setActiveProductAction } from '@/actions/active-product'
|
import { setActiveProductAction } from '@/actions/active-product'
|
||||||
|
|
||||||
|
|
@ -38,7 +39,6 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleActivate(id: string) {
|
function handleActivate(id: string) {
|
||||||
if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return }
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await setActiveProductAction(id)
|
const result = await setActiveProductAction(id)
|
||||||
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt')
|
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.'
|
? 'Geen gearchiveerde producten.'
|
||||||
: 'Je hebt nog geen producten aangemaakt.'}
|
: 'Je hebt nog geen producten aangemaakt.'}
|
||||||
</p>
|
</p>
|
||||||
{!isDemo && !showArchived && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button variant="outline" nativeButton={false} render={<Link href="/products/new" />}>
|
<Button variant="outline" nativeButton={false} render={<Link href={isDemo ? '#' : '/products/new'} />} disabled={isDemo}>
|
||||||
Maak je eerste product aan
|
Maak je eerste product aan
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -103,21 +103,27 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd
|
||||||
product.id === activeProductId
|
product.id === activeProductId
|
||||||
? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge>
|
? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge>
|
||||||
: (
|
: (
|
||||||
<button
|
<DemoTooltip show={isDemo}>
|
||||||
onClick={(e) => { e.stopPropagation(); handleActivate(product.id) }}
|
<button
|
||||||
className="text-xs text-primary hover:underline"
|
onClick={(e) => { e.stopPropagation(); if (!isDemo) handleActivate(product.id) }}
|
||||||
>
|
className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
|
||||||
Activeer
|
disabled={isDemo}
|
||||||
</button>
|
>
|
||||||
|
Activeer
|
||||||
|
</button>
|
||||||
|
</DemoTooltip>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{showArchived && !isDemo && (
|
{showArchived && (
|
||||||
<button
|
<DemoTooltip show={isDemo}>
|
||||||
onClick={(e) => { e.stopPropagation(); handleRestore(product.id) }}
|
<button
|
||||||
className="text-xs text-primary hover:underline"
|
onClick={(e) => { e.stopPropagation(); if (!isDemo) handleRestore(product.id) }}
|
||||||
>
|
className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
Herstellen
|
disabled={isDemo}
|
||||||
</button>
|
>
|
||||||
|
Herstellen
|
||||||
|
</button>
|
||||||
|
</DemoTooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { leaveProductAction } from '@/actions/products'
|
import { leaveProductAction } from '@/actions/products'
|
||||||
|
|
||||||
interface LeaveProductButtonProps {
|
interface LeaveProductButtonProps {
|
||||||
productId: string
|
productId: string
|
||||||
|
isDemo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeaveProductButton({ productId }: LeaveProductButtonProps) {
|
export function LeaveProductButton({ productId, isDemo = false }: LeaveProductButtonProps) {
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
|
@ -32,13 +34,16 @@ export function LeaveProductButton({ productId }: LeaveProductButtonProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<DemoTooltip show={isDemo}>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="shrink-0 border-error/40 text-error hover:bg-error/10"
|
size="sm"
|
||||||
onClick={() => setConfirming(true)}
|
className="shrink-0 border-error/40 text-error hover:bg-error/10"
|
||||||
>
|
disabled={isDemo}
|
||||||
Verlaten
|
onClick={() => !isDemo && setConfirming(true)}
|
||||||
</Button>
|
>
|
||||||
|
Verlaten
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useActionState, useTransition } from 'react'
|
||||||
import { useFormStatus } from 'react-dom'
|
import { useFormStatus } from 'react-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { createApiTokenAction, revokeApiTokenAction } from '@/actions/api-tokens'
|
import { createApiTokenAction, revokeApiTokenAction } from '@/actions/api-tokens'
|
||||||
|
|
||||||
interface Token {
|
interface Token {
|
||||||
|
|
@ -18,12 +19,14 @@ interface TokenManagerProps {
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateSubmitButton() {
|
function CreateSubmitButton({ isDemo }: { isDemo: boolean }) {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
return (
|
return (
|
||||||
<Button type="submit" disabled={pending}>
|
<DemoTooltip show={isDemo}>
|
||||||
{pending ? 'Aanmaken…' : 'Token aanmaken'}
|
<Button type="submit" disabled={isDemo || pending}>
|
||||||
</Button>
|
{pending ? 'Aanmaken…' : 'Token aanmaken'}
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,21 +83,19 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create form */}
|
{/* Create form */}
|
||||||
{!isDemo && (
|
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4">
|
||||||
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4">
|
<h2 className="text-sm font-medium text-foreground">Nieuw token aanmaken</h2>
|
||||||
<h2 className="text-sm font-medium text-foreground">Nieuw token aanmaken</h2>
|
<form action={formAction} className="flex gap-2">
|
||||||
<form action={formAction} className="flex gap-2">
|
<Input name="label" placeholder="Label (optioneel)" className="flex-1" disabled={isDemo} />
|
||||||
<Input name="label" placeholder="Label (optioneel)" className="flex-1" />
|
<CreateSubmitButton isDemo={isDemo} />
|
||||||
<CreateSubmitButton />
|
</form>
|
||||||
</form>
|
{typeof state?.error === 'string' && (
|
||||||
{typeof state?.error === 'string' && (
|
<p className="text-xs text-error">{state.error}</p>
|
||||||
<p className="text-xs text-error">{state.error}</p>
|
)}
|
||||||
)}
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}.
|
||||||
Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active tokens */}
|
{/* Active tokens */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -111,16 +112,17 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) {
|
||||||
Aangemaakt {new Date(token.created_at).toLocaleDateString('nl-NL')}
|
Aangemaakt {new Date(token.created_at).toLocaleDateString('nl-NL')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-error hover:bg-error/10 shrink-0"
|
className="text-error hover:bg-error/10 shrink-0"
|
||||||
onClick={() => handleRevoke(token.id)}
|
disabled={isDemo}
|
||||||
|
onClick={() => !isDemo && handleRevoke(token.id)}
|
||||||
>
|
>
|
||||||
Intrekken
|
Intrekken
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTransition } from 'react'
|
import { useTransition } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { setActiveProductAction } from '@/actions/active-product'
|
import { setActiveProductAction } from '@/actions/active-product'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -18,7 +19,6 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = '
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
function handleActivate() {
|
function handleActivate() {
|
||||||
if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return }
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await setActiveProductAction(productId)
|
const result = await setActiveProductAction(productId)
|
||||||
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt')
|
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 (
|
return (
|
||||||
<button
|
<DemoTooltip show={isDemo}>
|
||||||
onClick={handleActivate}
|
<button
|
||||||
disabled={isPending}
|
onClick={() => !isDemo && handleActivate()}
|
||||||
className="text-xs text-primary hover:underline font-medium disabled:opacity-50"
|
disabled={isDemo || isPending}
|
||||||
>
|
className="text-xs text-primary hover:underline font-medium disabled:opacity-50 disabled:no-underline"
|
||||||
{label}
|
>
|
||||||
</button>
|
{label}
|
||||||
|
</button>
|
||||||
|
</DemoTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,15 +189,16 @@ function SortableSprintRow({
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</DemoTooltip>
|
</DemoTooltip>
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onRemove() }}
|
onClick={e => { e.stopPropagation(); if (!isDemo) onRemove() }}
|
||||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error"
|
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"
|
aria-label="Verwijder uit sprint"
|
||||||
|
disabled={isDemo}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,14 +353,15 @@ function DraggablePbiStoryRow({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<button
|
<button
|
||||||
onClick={onAdd}
|
onClick={() => !isDemo && onAdd()}
|
||||||
className="text-xs text-primary hover:underline shrink-0"
|
className="text-xs text-primary hover:underline shrink-0 disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
|
||||||
|
disabled={isDemo}
|
||||||
>
|
>
|
||||||
+ Toevoegen
|
+ Toevoegen
|
||||||
</button>
|
</button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
createTaskAction, updateTaskStatusAction, updateTaskAction,
|
createTaskAction, updateTaskStatusAction, updateTaskAction,
|
||||||
deleteTaskAction, reorderTasksAction,
|
deleteTaskAction, reorderTasksAction,
|
||||||
} from '@/actions/tasks'
|
} from '@/actions/tasks'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
|
const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
|
||||||
|
|
@ -99,7 +100,7 @@ function SortableTaskRow({
|
||||||
PRIORITY_BORDER[task.priority]
|
PRIORITY_BORDER[task.priority]
|
||||||
)}>
|
)}>
|
||||||
{!isDemo && (
|
{!isDemo && (
|
||||||
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5">⠿</span>
|
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" aria-hidden="true">⠿</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
|
@ -114,12 +115,14 @@ function SortableTaskRow({
|
||||||
{STATUS_LABELS[task.status]}
|
{STATUS_LABELS[task.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
{!isDemo && (
|
<div className="opacity-0 group-hover:opacity-100 flex gap-1 shrink-0">
|
||||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1 shrink-0">
|
<DemoTooltip show={isDemo}>
|
||||||
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
|
<button onClick={() => !isDemo && setEditing(true)} disabled={isDemo} className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed">Bewerk</button>
|
||||||
<button onClick={onDelete} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error">×</button>
|
</DemoTooltip>
|
||||||
</div>
|
<DemoTooltip show={isDemo}>
|
||||||
)}
|
<button onClick={() => !isDemo && onDelete()} disabled={isDemo} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed">×</button>
|
||||||
|
</DemoTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,9 +223,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-muted-foreground">{doneCount}/{orderedTasks.length} klaar</span>
|
<span className="text-xs text-muted-foreground">{doneCount}/{orderedTasks.length} klaar</span>
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button size="sm" className="h-7 text-xs" onClick={() => setCreating(true)}>+ Taak</Button>
|
<Button size="sm" className="h-7 text-xs" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>+ Taak</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -235,7 +238,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
||||||
{orderedTasks.length === 0 && !creating ? (
|
{orderedTasks.length === 0 && !creating ? (
|
||||||
<div className="text-center mt-8 space-y-3">
|
<div className="text-center mt-8 space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">Geen taken voor deze story.</p>
|
<p className="text-sm text-muted-foreground">Geen taken voor deze story.</p>
|
||||||
{!isDemo && <Button size="sm" variant="outline" onClick={() => setCreating(true)}>Maak eerste taak aan</Button>}
|
<DemoTooltip show={isDemo}>
|
||||||
|
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>Maak eerste taak aan</Button>
|
||||||
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
|
|
|
||||||
62
docs/plans/ST-1110-demo-readonly.md
Normal file
62
docs/plans/ST-1110-demo-readonly.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Plan: ST-1110 — Demo gebruiker read-only
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Demo-gebruikers (`is_demo=true` in DB) mogen de app niet muteren. Vóór ST-1110 was de beveiliging gemengd en inconsistent: sommige routes hadden een isDemo-check, sommige niet; UI-patronen waren inconsistent (deels verborgen, deels disabled+toast).
|
||||||
|
|
||||||
|
## Audit resultaten (ST-1110.1)
|
||||||
|
|
||||||
|
Gevonden beveiligingsgaten vóór ST-1110:
|
||||||
|
- `/api/auth/pair/start` en `/api/auth/pair/claim`: geen isDemo-check
|
||||||
|
- `/api/todos`, `/api/stories`, `/api/tasks` e.a.: hadden isDemo-check in actie, maar geen middleware-laag
|
||||||
|
- UI: 24+ locaties gemengd patroon (`!isDemo && <Button>` 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 && <Button />}`, 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 && <Button>` patronen omgezet naar `<DemoTooltip show={isDemo}><Button disabled={isDemo}>`.
|
||||||
|
|
||||||
|
## 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 && <span {...listeners} />}`)
|
||||||
|
- Nieuwe write-knoppen in UI: `<DemoTooltip show={isDemo}><Button disabled={isDemo}>`
|
||||||
|
|
@ -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
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<Button disabled={isDemo} onClick={() => !isDemo && handleAction()}>
|
||||||
|
Actie
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && <span {...listeners} />}`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren.
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variabele | Doel | Waar te vinden |
|
| Variabele | Doel | Waar te vinden |
|
||||||
|
|
|
||||||
40
proxy.ts
40
proxy.ts
|
|
@ -1,17 +1,43 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { NextRequest } 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 protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo']
|
||||||
const authRoutes = ['/login', '/register']
|
const authRoutes = ['/login', '/register']
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||||
const path = request.nextUrl.pathname
|
|
||||||
const isProtected = protectedRoutes.some(r => path.startsWith(r))
|
|
||||||
const isAuthRoute = authRoutes.some(r => path.startsWith(r))
|
|
||||||
|
|
||||||
// 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<SessionData>(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 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) {
|
if (isProtected && !hasSession) {
|
||||||
return NextResponse.redirect(new URL('/login', request.url))
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
|
@ -25,5 +51,5 @@ export function proxy(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue