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
28
app/(app)/error.tsx
Normal file
28
app/(app)/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
34
app/(app)/products/[id]/loading.tsx
Normal file
34
app/(app)/products/[id]/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
app/(app)/products/[id]/sprint/loading.tsx
Normal file
34
app/(app)/products/[id]/sprint/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
app/(app)/products/[id]/sprint/planning/loading.tsx
Normal file
34
app/(app)/products/[id]/sprint/planning/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue