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:
Janpeter Visser 2026-04-29 18:44:14 +02:00 committed by GitHub
parent 8a9fb9d32b
commit 1cb5772edd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 413 additions and 142 deletions

View file

@ -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]}
</Badge>
}
actions={!isDemo ? (
actions={
<div className="flex items-center gap-1">
<button
onClick={(e) => { 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"
>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="text-muted-foreground hover:text-error text-xs"
aria-label="Verwijder PBI"
>
×
</button>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { 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}
>
</button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { 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}
>
×
</button>
</DemoTooltip>
</div>
) : undefined}
}
/>
)
}
@ -383,15 +390,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
</div>
</PopoverContent>
</Popover>
{!isDemo && (
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
disabled={isDemo}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
</Button>
)}
</DemoTooltip>
</>
}
/>
@ -400,11 +408,11 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
{pbis.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm space-y-3">
<p>Nog geen PBI&apos;s aangemaakt.</p>
{!isDemo && (
<Button size="sm" variant="outline" onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
Maak je eerste PBI aan
</Button>
)}
</DemoTooltip>
</div>
) : (
<DndContext

View file

@ -17,6 +17,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { PrioritySelect, PRIORITY_LABELS, PRIORITY_COLORS } from '@/components/shared/priority-select'
import { StoryLog } from '@/components/shared/story-log'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { createStoryAction, updateStoryAction, deleteStoryAction, getStoryLogsAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
import type { Story } from './story-panel'
@ -42,10 +43,10 @@ const STATUS_LABELS: Record<string, string> = {
DONE: 'Klaar',
}
function SubmitButton({ label }: { label: string }) {
function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
<Button type="submit" disabled={disabled || pending}>
{pending ? '…' : label}
</Button>
)
@ -262,9 +263,9 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
)}
</div>
{isEdit && !isDemo && (
{isEdit && (
<div className="px-5 py-3 border-t border-border shrink-0">
{confirmDelete ? (
{!isDemo && confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex-1">
Weet je het zeker? Taken worden ook verwijderd.
@ -277,24 +278,29 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="text-error hover:bg-error/10"
onClick={() => setConfirmDelete(true)}
>
Story verwijderen
</Button>
<DemoTooltip show={isDemo}>
<Button
type="button"
variant="ghost"
size="sm"
className="text-error hover:bg-error/10"
disabled={isDemo}
onClick={() => !isDemo && setConfirmDelete(true)}
>
Story verwijderen
</Button>
</DemoTooltip>
)}
</div>
)}
<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" />}>
{isDemo ? 'Sluiten' : 'Annuleren'}
Annuleren
</DialogClose>
{!isDemo && <SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} />}
<DemoTooltip show={isDemo}>
<SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} disabled={isDemo} />
</DemoTooltip>
</div>
</form>
</DialogContent>

View file

@ -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)
<SelectItem value="DONE">Klaar</SelectItem>
</SelectContent>
</Select>
{selectedPbiId && !isDemo && (
<Button
size="sm"
className="h-7 text-xs"
onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
>
+ Story
</Button>
{selectedPbiId && (
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo}
onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
>
+ Story
</Button>
</DemoTooltip>
)}
</>
}
@ -244,10 +248,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
) : rawStories.length === 0 ? (
<div className="text-center mt-8 space-y-3">
<p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
{!isDemo && selectedPbiId && (
<Button size="sm" variant="outline" onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
Maak je eerste story aan
</Button>
{selectedPbiId && (
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
Maak je eerste story aan
</Button>
</DemoTooltip>
)}
</div>
) : (