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

28
app/(app)/error.tsx Normal file
View file

@ -0,0 +1,28 @@
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div className="flex flex-col items-center justify-center flex-1 gap-4 p-6">
<div className="text-center space-y-2">
<h2 className="text-lg font-medium text-foreground">Er is iets misgegaan</h2>
<p className="text-sm text-muted-foreground max-w-sm">
{error.message || 'Er is een onverwachte fout opgetreden. Probeer het opnieuw.'}
</p>
</div>
<Button onClick={reset} variant="outline">Probeer opnieuw</Button>
</div>
)
}

View file

@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { NavBar } from '@/components/shared/nav-bar'
import { MinWidthBanner } from '@/components/shared/min-width-banner'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
@ -13,8 +14,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
return (
<div className="min-h-screen bg-background flex flex-col">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm">
Ga naar inhoud
</a>
<NavBar isDemo={session.isDemo} />
<main className="flex-1 flex flex-col">
<MinWidthBanner />
<main id="main-content" className="flex-1 flex flex-col">
{children}
</main>
</div>

View file

@ -0,0 +1,34 @@
export default function Loading() {
return (
<div className="flex flex-col h-full animate-pulse">
{/* Header skeleton */}
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between">
<div className="space-y-1.5">
<div className="h-4 w-32 bg-border rounded" />
<div className="h-3 w-48 bg-border/60 rounded" />
</div>
<div className="h-7 w-24 bg-border rounded" />
</div>
{/* Split pane skeleton */}
<div className="flex-1 flex overflow-hidden">
{/* Left */}
<div className="w-2/5 border-r border-border p-4 space-y-3">
<div className="h-4 w-24 bg-border rounded" />
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-8 bg-border/50 rounded" />
))}
</div>
{/* Right */}
<div className="flex-1 p-4 space-y-3">
<div className="h-4 w-16 bg-border rounded" />
<div className="flex gap-2 flex-wrap">
{[1, 2, 3].map(i => (
<div key={i} className="w-28 h-24 bg-border/50 rounded-lg" />
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
export default function Loading() {
return (
<div className="flex flex-col h-full animate-pulse">
{/* Header skeleton */}
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between">
<div className="space-y-1.5">
<div className="h-4 w-32 bg-border rounded" />
<div className="h-3 w-48 bg-border/60 rounded" />
</div>
<div className="h-7 w-24 bg-border rounded" />
</div>
{/* Split pane skeleton */}
<div className="flex-1 flex overflow-hidden">
{/* Left */}
<div className="w-2/5 border-r border-border p-4 space-y-3">
<div className="h-4 w-24 bg-border rounded" />
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-8 bg-border/50 rounded" />
))}
</div>
{/* Right */}
<div className="flex-1 p-4 space-y-3">
<div className="h-4 w-16 bg-border rounded" />
<div className="flex gap-2 flex-wrap">
{[1, 2, 3].map(i => (
<div key={i} className="w-28 h-24 bg-border/50 rounded-lg" />
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
export default function Loading() {
return (
<div className="flex flex-col h-full animate-pulse">
{/* Header skeleton */}
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between">
<div className="space-y-1.5">
<div className="h-4 w-32 bg-border rounded" />
<div className="h-3 w-48 bg-border/60 rounded" />
</div>
<div className="h-7 w-24 bg-border rounded" />
</div>
{/* Split pane skeleton */}
<div className="flex-1 flex overflow-hidden">
{/* Left */}
<div className="w-2/5 border-r border-border p-4 space-y-3">
<div className="h-4 w-24 bg-border rounded" />
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-8 bg-border/50 rounded" />
))}
</div>
{/* Right */}
<div className="flex-1 p-4 space-y-3">
<div className="h-4 w-16 bg-border rounded" />
<div className="flex gap-2 flex-wrap">
{[1, 2, 3].map(i => (
<div key={i} className="w-28 h-24 bg-border/50 rounded-lg" />
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -8,9 +8,10 @@ import Link from 'next/link'
export default async function SettingsPage() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const userRoles = await prisma.userRole.findMany({
where: { user_id: session.userId },
})
const [user, userRoles] = await Promise.all([
prisma.user.findUnique({ where: { id: session.userId }, select: { username: true } }),
prisma.userRole.findMany({ where: { user_id: session.userId } }),
])
const currentRoles = userRoles.map(r => r.role as string)
return (
@ -20,7 +21,7 @@ export default async function SettingsPage() {
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
<h2 className="text-sm font-medium text-foreground">Account</h2>
<p className="text-sm text-muted-foreground">
Ingelogd als <span className="text-foreground font-medium">{session.userId}</span>
Ingelogd als <span className="text-foreground font-medium">{user?.username ?? session.userId}</span>
{session.isDemo && <span className="ml-2 text-warning text-xs">(demo)</span>}
</p>
</div>

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
const geistSans = Geist({
@ -40,7 +41,10 @@ export default function RootLayout({
lang="nl"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
{children}
<Toaster richColors position="bottom-right" />
</body>
</html>
);
}