feat: ST-601-ST-612 M6 polish, beveiliging en launch-ready

- ST-601/602: loading skeletons en error boundary
- ST-603: Sonner toasts op alle CRUD-operaties
- ST-604: DemoTooltip op uitgeschakelde knoppen
- ST-605: KeyboardSensor dnd-kit, Escape sluit modals
- ST-606: min-width banner < 1024px
- ST-607: WCAG AA aria-labels en skip link
- ST-608: rate limiting login (10/min) en registratie (5/uur)
- ST-609: security integratietests cross-user toegang (7 tests)
- ST-610: GitHub Actions CI/CD workflow
- ST-611: README met quickstart, deployment en API-docs
- ST-612: Lars-flow acceptatiechecklist
- fix: settings toont gebruikersnaam i.p.v. interne id
- fix: seed idempotent, testdata altijd gekoppeld aan demo-gebruiker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-24 12:36:23 +02:00
parent 8bb8754d01
commit d11b114fc1
27 changed files with 1858 additions and 67 deletions

View file

@ -8,6 +8,7 @@ import {
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
@ -18,10 +19,12 @@ import {
useSortable,
verticalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -96,6 +99,7 @@ function SortablePbiRow({
<span
{...attributes}
{...listeners}
aria-label="Versleep om te sorteren"
className="mr-2 text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none"
onClick={(e) => e.stopPropagation()}
>
@ -129,7 +133,7 @@ function CreatePbiForm({
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createPbiAction(_prev, fd)
if (result?.success) onDone()
if (result?.success) { toast.success('PBI aangemaakt'); onDone() }
return result
},
undefined
@ -198,7 +202,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
p => grouped[p].length > 0 || creatingForPriority === p
)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)

View file

@ -7,6 +7,7 @@ import {
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
@ -17,6 +18,7 @@ import {
useSortable,
horizontalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
@ -135,7 +137,7 @@ function StoryDetailSheet({
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateStoryAction(_prev, fd)
if (result?.success) onClose()
if (result?.success) { toast.success('Story opgeslagen'); onClose() }
return result
},
undefined
@ -143,7 +145,9 @@ function StoryDetailSheet({
function handleDelete() {
startDeleteTransition(async () => {
await deleteStoryAction(story.id)
const result = await deleteStoryAction(story.id)
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
else toast.success('Story verwijderd')
onClose()
})
}
@ -279,7 +283,7 @@ function CreateStoryForm({
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createStoryAction(_prev, fd)
if (result?.success) onDone()
if (result?.success) { toast.success('Story aangemaakt'); onDone() }
return result
},
undefined
@ -346,7 +350,10 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
p => grouped[p].length > 0 || creatingPriority === p
)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)

View file

@ -3,6 +3,7 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { restoreProductAction } from '@/actions/products'
@ -25,8 +26,9 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL
function handleRestore(id: string) {
startTransition(async () => {
await restoreProductAction(id)
router.refresh()
const result = await restoreProductAction(id)
if ('error' in result) toast.error(result.error ?? 'Herstellen mislukt')
else { toast.success('Product hersteld'); router.refresh() }
})
}

View file

@ -1,7 +1,9 @@
'use client'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { updateRolesAction } from '@/actions/todos'
const ALL_ROLES = [
@ -38,8 +40,8 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) {
}
startTransition(async () => {
const result = await updateRolesAction([...selected])
if (result.success) setSaved(true)
else setError(result.error ?? 'Opslaan mislukt')
if (result.success) { setSaved(true); toast.success('Rollen opgeslagen') }
else { setError(result.error ?? 'Opslaan mislukt'); toast.error(result.error ?? 'Opslaan mislukt') }
})
}
@ -62,9 +64,9 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) {
</div>
{error && <p className="text-xs text-error">{error}</p>}
{saved && <p className="text-xs text-success">Rollen opgeslagen.</p>}
{!isDemo && (
<Button size="sm" onClick={handleSave}>Opslaan</Button>
)}
<DemoTooltip show={isDemo}>
<Button size="sm" onClick={handleSave} disabled={isDemo}>Opslaan</Button>
</DemoTooltip>
</div>
)
}

View file

@ -0,0 +1,25 @@
'use client'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface DemoTooltipProps {
show: boolean
children: React.ReactNode
}
// Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true.
// Uses a span trigger so tooltip works on disabled elements.
export function DemoTooltip({ show, children }: DemoTooltipProps) {
if (!show) return <>{children}</>
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
{children}
</TooltipTrigger>
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View file

@ -0,0 +1,10 @@
'use client'
// Shows a warning banner on screens narrower than 1024px.
export function MinWidthBanner() {
return (
<div className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning">
Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm.
</div>
)
}

View file

@ -4,10 +4,11 @@ import { useState, useTransition, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
DndContext, DragEndEvent, DragOverEvent, DragStartEvent, DragOverlay,
PointerSensor, useSensor, useSensors, closestCenter,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import {
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
@ -68,6 +69,7 @@ function SortableSprintRow({
>
{!isDemo && (
<span {...attributes} {...listeners} onClick={e => e.stopPropagation()}
aria-label="Versleep om te sorteren"
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm">
</span>
@ -113,7 +115,10 @@ export function SprintBacklogLeft({ sprintId, stories, isDemo, onSelectStory, se
const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id)
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event

View file

@ -10,6 +10,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
import type { SprintStory } from './sprint-backlog'
@ -41,7 +43,8 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto
const [, goalFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintGoalAction(_prev, fd)
if (result?.success) setEditingGoal(false)
if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
return result
},
undefined
@ -59,8 +62,9 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto
})
startCompleting(async () => {
await completeSprintAction(sprint.id, finalDecisions)
setCompleteOpen(false)
const result = await completeSprintAction(sprint.id, finalDecisions)
if ('error' in result) toast.error(result.error ?? 'Sprint afronden mislukt')
else { toast.success('Sprint afgerond'); setCompleteOpen(false) }
})
}
@ -92,11 +96,11 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto
)}
</div>
{!isDemo && (
<Button size="sm" variant="outline" className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
Sprint afronden
</Button>
)}
</DemoTooltip>
</div>
{/* Complete sprint dialog */}

View file

@ -4,10 +4,11 @@ import { useState, useTransition, useEffect, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCenter,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import {
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
@ -97,7 +98,7 @@ function SortableTaskRow({
</p>
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
</div>
<button onClick={onStatusToggle} disabled={isDemo}>
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
<Badge className={cn('text-xs border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
@ -105,7 +106,7 @@ function SortableTaskRow({
{!isDemo && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
<button onClick={onDelete} className="text-xs text-muted-foreground hover:text-error">×</button>
<button onClick={onDelete} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error">×</button>
</div>
)}
</div>
@ -161,7 +162,10 @@ export function TaskList({ storyId, sprintId, productId, tasks, isDemo }: TaskLi
const doneCount = orderedTasks.filter(t => t.status === 'DONE').length
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
@ -184,7 +188,8 @@ export function TaskList({ storyId, sprintId, productId, tasks, isDemo }: TaskLi
function handleDelete(id: string) {
startTransition(async () => {
await deleteTaskAction(id)
const result = await deleteTaskAction(id)
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
})
}

View file

@ -1,8 +1,10 @@
'use client'
import { useState, useTransition, useActionState, useEffect, useRef } from 'react'
import { useState, useTransition, useActionState, useEffect, useRef, useCallback } from 'react'
import { useFormStatus } from 'react-dom'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -57,7 +59,9 @@ function QuickInput({ isDemo }: { isDemo: boolean }) {
className="flex-1"
autoComplete="off"
/>
<QuickSubmitButton isDemo={isDemo} />
<DemoTooltip show={isDemo}>
<QuickSubmitButton isDemo={isDemo} />
</DemoTooltip>
</form>
)
}
@ -77,10 +81,13 @@ function PromotePbiDialog({
products,
onClose,
}: { todo: Todo; products: Product[]; onClose: () => void }) {
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey) }, [handleKey])
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToPbiAction(_prev, fd)
if (result?.success) onClose()
if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() }
return result
},
undefined
@ -133,13 +140,16 @@ function PromoteStoryDialog({
products,
onClose,
}: { todo: Todo; products: Product[]; onClose: () => void }) {
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey) }, [handleKey])
const [selectedProductId, setSelectedProductId] = useState(products[0]?.id ?? '')
const selectedProduct = products.find(p => p.id === selectedProductId)
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToStoryAction(_prev, fd)
if (result?.success) onClose()
if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() }
return result
},
undefined
@ -218,7 +228,9 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
function handleArchive() {
startTransition(async () => {
await archiveCompletedTodosAction()
const result = await archiveCompletedTodosAction()
if (result && 'error' in result) toast.error(result.error ?? 'Archiveren mislukt')
else toast.success('Afgeronde todos gearchiveerd')
})
}