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:
parent
8bb8754d01
commit
d11b114fc1
27 changed files with 1858 additions and 67 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
25
components/shared/demo-tooltip.tsx
Normal file
25
components/shared/demo-tooltip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
components/shared/min-width-banner.tsx
Normal file
10
components/shared/min-width-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue