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

46
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
name: Lint, Typecheck, Test & Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npx tsc --noEmit
- name: Prisma validate
run: npx prisma validate
env:
DATABASE_URL: file:./dev.db
- name: Test
run: npm test
- name: Build
run: npm run build
env:
DATABASE_URL: file:./dev.db
DIRECT_URL: file:./dev.db
SESSION_SECRET: ${{ secrets.SESSION_SECRET || 'ci-placeholder-secret-must-be-32-chars-min' }}

238
README.md
View file

@ -1,36 +1,234 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Scrum4Me
## Getting Started
Lichtgewicht Scrum-planner voor solo developers en kleine teams die meerdere softwareprojecten parallel beheren.
First, run the development server:
**Functies:**
- Hiërarchisch werkbeheer: Product → PBI → Story → Taak
- Gesplitste planningsschermen met drag-and-drop
- Sprint backlog en planning
- REST API voor integratie met Claude Code
- Demo-modus (alleen lezen)
---
## Lokale quickstart
### Vereisten
- Node.js 20+
- npm
### Stappen
```bash
# 1. Clone de repository
git clone <repo-url>
cd scrum4me
# 2. Installeer dependencies
npm install
# 3. Configureer omgevingsvariabelen
cp .env.example .env.local
# Bewerk .env.local en vul SESSION_SECRET in:
# openssl rand -base64 32
# 4. Database initialiseren (SQLite lokaal)
npx prisma db push
# 5. Testdata inladen
npx prisma db seed
# 6. Start de ontwikkelserver
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://localhost:3000](http://localhost:3000).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
**Demo-account:** gebruikersnaam `demo` / wachtwoord `demo1234` (alleen lezen)
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
---
## Learn More
## Omgevingsvariabelen
To learn more about Next.js, take a look at the following resources:
| Variabele | Beschrijving |
|---|---|
| `DATABASE_URL` | SQLite: `file:./dev.db` · PostgreSQL: zie Neon-sectie |
| `DIRECT_URL` | Alleen bij PostgreSQL met connection pooling (Neon) |
| `SESSION_SECRET` | Minimaal 32 tekens — genereer met `openssl rand -base64 32` |
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
---
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Cloud deployment (Vercel + Neon)
## Deploy on Vercel
### 1. Database aanmaken op Neon
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
1. Maak een account op [neon.tech](https://neon.tech)
2. Maak een nieuw project en database
3. Kopieer de connection strings:
- **DATABASE_URL**: de pooled connection string
- **DIRECT_URL**: de directe (niet-gepoolde) connection string
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### 2. Deployen op Vercel
1. Push de code naar GitHub
2. Importeer het project in [vercel.com](https://vercel.com)
3. Voeg de volgende environment variables toe in Vercel:
- `DATABASE_URL` (Neon pooled URL)
- `DIRECT_URL` (Neon direct URL)
- `SESSION_SECRET` (random string >= 32 tekens)
4. Deploy
### 3. Database migraties uitvoeren
```bash
# Eenmalig na deploy:
npx prisma migrate deploy
npx prisma db seed
```
---
## REST API
Alle endpoints vereisen `Authorization: Bearer <token>`.
Maak een token aan via **Instellingen -> API Tokens** in de app.
### Endpoints
#### `GET /api/products`
Haal alle actieve producten op.
```bash
curl -H "Authorization: Bearer <token>" \
https://your-app.vercel.app/api/products
```
**Response:**
```json
[
{ "id": "clx...", "name": "Mijn Product", "repo_url": "https://github.com/..." }
]
```
---
#### `GET /api/products/:id/next-story`
Haal de hoogst geprioriteerde open story uit de actieve sprint op.
```bash
curl -H "Authorization: Bearer <token>" \
https://your-app.vercel.app/api/products/<product-id>/next-story
```
**Response:** story-object inclusief taken.
---
#### `GET /api/sprints/:id/tasks?limit=10`
Haal de eerste N taken uit de sprint op (standaard 10, max 50).
```bash
curl -H "Authorization: Bearer <token>" \
"https://your-app.vercel.app/api/sprints/<sprint-id>/tasks?limit=5"
```
---
#### `POST /api/stories/:id/log`
Voeg een logvermelding toe aan een story.
```bash
# Implementatieplan:
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type":"IMPLEMENTATION_PLAN","content":"Aanpak: ..."}' \
https://your-app.vercel.app/api/stories/<story-id>/log
# Testresultaat:
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type":"TEST_RESULT","content":"Alle tests geslaagd","status":"PASSED"}' \
https://your-app.vercel.app/api/stories/<story-id>/log
# Commit:
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type":"COMMIT","content":"feat: ST-001","commit_hash":"abc123","commit_message":"feat: ST-001 scaffolding"}' \
https://your-app.vercel.app/api/stories/<story-id>/log
```
---
#### `PATCH /api/stories/:id/tasks/reorder`
Pas de taakvolgorde aan binnen een story.
```bash
curl -X PATCH -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"task_ids":["id-1","id-2","id-3"]}' \
https://your-app.vercel.app/api/stories/<story-id>/tasks/reorder
```
---
#### `PATCH /api/tasks/:id`
Werk de status van een taak bij.
```bash
curl -X PATCH -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"status":"IN_PROGRESS"}' \
https://your-app.vercel.app/api/tasks/<task-id>
```
**Status waarden:** `TO_DO` - `IN_PROGRESS` - `DONE`
---
#### `POST /api/todos`
Maak een todo aan.
```bash
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title":"Mijn nieuwe todo"}' \
https://your-app.vercel.app/api/todos
```
---
## Claude Code integratie
Scrum4Me integreert met [Claude Code](https://claude.ai/claude-code) via de REST API.
1. Maak een API token aan via **Instellingen -> API Tokens**
2. Gebruik de API om implementatieplannen, testresultaten en commits automatisch te loggen in stories
---
## Scripts
| Script | Beschrijving |
|---|---|
| `npm run dev` | Lokale ontwikkelserver |
| `npm run build` | Productie-build |
| `npm run lint` | ESLint |
| `npm test` | Beveiligingstests uitvoeren |
| `npx tsc --noEmit` | TypeScript-check |
| `npx prisma db push` | Database schema synchroniseren |
| `npx prisma db seed` | Testdata inladen |
---
## Tech stack
- **Next.js 15** (App Router) + **React 19**
- **TypeScript** strict
- **Tailwind CSS** + **shadcn/ui** (Base UI)
- **Zustand** (client state)
- **dnd-kit** (drag-and-drop)
- **Prisma v7** (ORM)
- **PostgreSQL** (Neon) / **SQLite** (lokaal)
- **iron-session** (auth)
- **Sonner** (toasts)
- **Zod** (validatie)

View file

@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock prisma
vi.mock('@/lib/prisma', () => ({
prisma: {
product: {
findMany: vi.fn(),
},
task: {
findFirst: vi.fn(),
update: vi.fn(),
},
apiToken: {
findUnique: vi.fn(),
},
},
}))
// Mock api-auth to control which user is "authenticated"
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { GET as getProducts } from '@/app/api/products/route'
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
const mockPrisma = prisma as unknown as {
product: { findMany: ReturnType<typeof vi.fn> }
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
function makeRequest(method = 'GET', body?: unknown): Request {
return new Request('http://localhost/api/test', {
method,
headers: { 'Authorization': 'Bearer test-token', 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
}
describe('Security: cross-user access', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/products', () => {
it('returns only the authenticated user\'s products', async () => {
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.product.findMany.mockResolvedValue([
{ id: 'prod-1', name: 'Product A', repo_url: null },
])
const response = await getProducts(makeRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveLength(1)
// Verify the query filtered by user_id
expect(mockPrisma.product.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ user_id: 'user-1' }),
})
)
})
it('returns 401 when no valid token provided', async () => {
mockAuth.mockResolvedValue({ error: 'Unauthorized', status: 401 })
const response = await getProducts(makeRequest())
expect(response.status).toBe(401)
})
})
describe('PATCH /api/tasks/:id', () => {
it('returns 403 when task belongs to a different user', async () => {
// User 2 is authenticated but the task belongs to user 1
mockAuth.mockResolvedValue({ userId: 'user-2', isDemo: false })
mockPrisma.task.findFirst.mockResolvedValue({
id: 'task-1',
story: {
product: {
user_id: 'user-1', // different user!
},
},
})
const response = await patchTask(
makeRequest('PATCH', { status: 'DONE' }),
{ params: Promise.resolve({ id: 'task-1' }) }
)
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBeTruthy()
})
it('returns 403 for demo users', async () => {
mockAuth.mockResolvedValue({ userId: 'demo-user', isDemo: true })
const response = await patchTask(
makeRequest('PATCH', { status: 'DONE' }),
{ params: Promise.resolve({ id: 'task-1' }) }
)
expect(response.status).toBe(403)
})
it('allows update when task belongs to the authenticated user', async () => {
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.task.findFirst.mockResolvedValue({
id: 'task-1',
story: {
product: {
user_id: 'user-1', // same user
},
},
})
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' })
const response = await patchTask(
makeRequest('PATCH', { status: 'DONE' }),
{ params: Promise.resolve({ id: 'task-1' }) }
)
expect(response.status).toBe(200)
})
it('returns 404 when task does not exist', async () => {
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.task.findFirst.mockResolvedValue(null)
const response = await patchTask(
makeRequest('PATCH', { status: 'DONE' }),
{ params: Promise.resolve({ id: 'nonexistent' }) }
)
expect(response.status).toBe(404)
})
it('returns 401 when no valid token', async () => {
mockAuth.mockResolvedValue({ error: 'Unauthorized', status: 401 })
const response = await patchTask(
makeRequest('PATCH', { status: 'DONE' }),
{ params: Promise.resolve({ id: 'task-1' }) }
)
expect(response.status).toBe(401)
})
})
})

View file

@ -0,0 +1,146 @@
# ST-612 — Lars-flow acceptatietest
Handmatige checklist voor de volledige Lars-flow. Doorloop alle stappen zonder handleiding.
## Voorbereiding
```bash
npx prisma db push
npx prisma db seed
npm run dev
```
Navigeer naar [http://localhost:3000](http://localhost:3000).
---
## Stap 1: Registreren en inloggen
- [ ] Registreer een nieuw account (gebruikersnaam + wachtwoord ≥ 8 tekens)
- [ ] Je wordt automatisch doorgestuurd naar het dashboard
- [ ] Log uit en log opnieuw in — sessie werkt correct
---
## Stap 2: Product aanmaken
- [ ] Klik op "+ Nieuw product"
- [ ] Vul naam, beschrijving, repo URL en Definition of Done in
- [ ] Sla op — je wordt doorgestuurd naar de Product Backlog pagina
---
## Stap 3: Product Backlog opbouwen
- [ ] Klik op "+ PBI" en maak minimaal 3 PBI's aan met verschillende prioriteiten
- [ ] Sleep een PBI naar een andere positie — volgorde blijft behouden na page refresh
- [ ] Sleep een PBI naar een andere prioriteitsgroep — prioriteit verandert
- [ ] Selecteer een PBI — het rechterpaneel toont "Nog geen stories"
- [ ] Maak minimaal 2 stories aan voor de geselecteerde PBI
- [ ] Klik op een story-blok — een slide-over opent met bewerkingsformulier
- [ ] Pas de titel en acceptatiecriteria aan en sla op
- [ ] Sluit de slide-over (Escape of klik buiten)
---
## Stap 4: Sprint aanmaken en plannen
- [ ] Navigeer naar de Sprint Backlog (knop in de header)
- [ ] Klik op "Sprint starten" en voer een Sprint Goal in
- [ ] In het rechterpaneel zijn de PBI's en stories zichtbaar
- [ ] Klik op een story om deze toe te voegen aan de Sprint
- [ ] Voeg minimaal 2 stories toe
- [ ] Sleep stories in het linkerpaneel om de volgorde te bepalen
- [ ] Navigeer naar Sprint Planning
- [ ] Selecteer een story in de linkerkolom — taken verschijnen rechts
- [ ] Maak minimaal 2 taken aan voor de story
- [ ] Verander de taakstatus door op de badge te klikken (To Do → In Progress → Done)
---
## Stap 5: API token aanmaken
- [ ] Ga naar Instellingen → API Tokens
- [ ] Klik op "+ Token aanmaken" en geef het een naam
- [ ] Kopieer het token (wordt slechts eenmalig getoond)
- [ ] Sla het token tijdelijk op voor de curl-tests
---
## Stap 6: API-endpoints testen via curl
Vervang `<token>`, `<product-id>` en `<story-id>` met echte waarden.
```bash
# 1. Producten ophalen
curl -H "Authorization: Bearer <token>" \
http://localhost:3000/api/products
# Verwacht: JSON-array met het aangemaakte product
# 2. Volgende story ophalen
curl -H "Authorization: Bearer <token>" \
http://localhost:3000/api/products/<product-id>/next-story
# Verwacht: story-object met taken
# 3. Implementatieplan loggen
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type":"IMPLEMENTATION_PLAN","content":"Aanpak: component-first, dan server action"}' \
http://localhost:3000/api/stories/<story-id>/log
# Verwacht: {"id":"...","type":"IMPLEMENTATION_PLAN"}
# 4. Testresultaat loggen
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type":"TEST_RESULT","content":"Alle unit tests geslaagd","status":"PASSED"}' \
http://localhost:3000/api/stories/<story-id>/log
# Verwacht: {"id":"...","type":"TEST_RESULT"}
# 5. Commit loggen
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type":"COMMIT","content":"feat: story afgerond","commit_hash":"abc1234","commit_message":"feat: ST-XXX story afgerond"}' \
http://localhost:3000/api/stories/<story-id>/log
# Verwacht: {"id":"...","type":"COMMIT"}
```
---
## Stap 7: Activiteitenlog controleren in de UI
- [ ] Open de story in de Product Backlog (klik op het story-blok)
- [ ] Scroll naar het activiteitenlog onderaan de slide-over
- [ ] Verifieer dat het implementatieplan (blauw), testresultaat (groen) en commit (paars) zichtbaar zijn
---
## Stap 8: Sprint afronden
- [ ] Navigeer naar de Sprint Backlog
- [ ] Klik op "Sprint afronden"
- [ ] Besluit per story: Done of Terugplaatsen
- [ ] Klik "Sprint afronden" — sprint wordt afgesloten
---
## Stap 9: Demo-gebruiker controleren
- [ ] Log uit
- [ ] Log in als `demo` / `demo1234`
- [ ] Verifieer dat geen enkel formulier of knop schrijfrechten heeft
- [ ] Tooltip "Niet beschikbaar in demo-modus" zichtbaar bij hover op uitgeschakelde knoppen
---
## Stap 10: Responsive / kleine schermen
- [ ] Verklein het browservenster tot < 1024px breed
- [ ] Banner "Scrum4Me is ontworpen voor schermen van minimaal 1024px" verschijnt
---
## Resultaat
- [ ] Alle stappen doorlopen zonder fouten
- [ ] Alle API-responses zijn correct JSON
- [ ] Geen console-errors of crashes

View file

@ -1,11 +1,17 @@
'use server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { cookies, headers } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { registerUser, verifyUser } from '@/lib/auth'
import { SessionData, sessionOptions } from '@/lib/session'
import { checkRateLimit } from '@/lib/rate-limit'
async function getClientIp(): Promise<string> {
const h = await headers()
return h.get('x-forwarded-for')?.split(',')[0].trim() ?? h.get('x-real-ip') ?? 'unknown'
}
const registerSchema = z.object({
username: z.string().min(3, 'Gebruikersnaam moet minimaal 3 tekens bevatten').max(50),
@ -18,6 +24,11 @@ const loginSchema = z.object({
})
export async function registerAction(_prevState: unknown, formData: FormData) {
const ip = await getClientIp()
if (!checkRateLimit(`register:${ip}`)) {
return { error: 'Te veel pogingen. Probeer het over een minuut opnieuw.' }
}
const parsed = registerSchema.safeParse({
username: formData.get('username'),
password: formData.get('password'),
@ -39,6 +50,11 @@ export async function registerAction(_prevState: unknown, formData: FormData) {
}
export async function loginAction(_prevState: unknown, formData: FormData) {
const ip = await getClientIp()
if (!checkRateLimit(`login:${ip}`)) {
return { error: 'Te veel inlogpogingen. Probeer het over een minuut opnieuw.' }
}
const parsed = loginSchema.safeParse({
username: formData.get('username'),
password: formData.get('password'),

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>
);
}

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')
})
}

36
lib/rate-limit.ts Normal file
View file

@ -0,0 +1,36 @@
// Simple in-memory rate limiter.
// Note: resets on server restart and does not share state across multiple processes.
// Suitable for MVP; replace with Redis for production scale-out.
interface RateLimitConfig {
windowMs: number
max: number
}
const CONFIGS: Record<string, RateLimitConfig> = {
login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute
register: { windowMs: 3_600_000, max: 5 }, // 5 attempts per hour
}
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }
const store = new Map<string, { count: number; resetAt: number }>()
export function checkRateLimit(key: string): boolean {
const prefix = key.split(':')[0]
const config = CONFIGS[prefix] ?? DEFAULT_CONFIG
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + config.windowMs })
return true
}
if (entry.count >= config.max) {
return false
}
entry.count++
return true
}

951
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
@ -49,10 +51,12 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.5"
}
}

View file

@ -62,11 +62,21 @@ async function main() {
console.log(`Main user: ${user.username} (id: ${user.id})`)
// Create the Scrum4Me product (using the product backlog doc data)
const product = await prisma.product.upsert({
where: { user_id_name: { user_id: demo.id, name: 'DevPlanner' } },
update: {},
create: {
// Reset demo product data — delete in dependency order to avoid FK violations
const existingProducts = await prisma.product.findMany({ where: { user_id: demo.id }, select: { id: true } })
for (const p of existingProducts) {
// Stories reference product_id directly (no cascade), so delete PBIs first (cascades to stories via pbi_id)
const existingPbis = await prisma.pbi.findMany({ where: { product_id: p.id }, select: { id: true } })
for (const pbi of existingPbis) {
await prisma.story.deleteMany({ where: { pbi_id: pbi.id } })
}
await prisma.pbi.deleteMany({ where: { product_id: p.id } })
await prisma.sprint.deleteMany({ where: { product_id: p.id } })
}
await prisma.product.deleteMany({ where: { user_id: demo.id } })
const product = await prisma.product.create({
data: {
user_id: demo.id,
name: 'DevPlanner',
description: 'Een lichtgewicht Scrum-gebaseerde projectplanner voor solo developers en kleine Scrum Teams.',
@ -76,7 +86,7 @@ async function main() {
},
})
console.log(`Product created: ${product.name} (id: ${product.id})`)
console.log(`Product created: ${product.name} (id: ${product.id}, owner: demo)`)
// PBI data from the product backlog document
const pbis = [

14
vitest.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
})