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
46
.github/workflows/ci.yml
vendored
Normal file
46
.github/workflows/ci.yml
vendored
Normal 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
238
README.md
|
|
@ -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
|
```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
|
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)
|
||||||
|
|
|
||||||
153
__tests__/api/security.test.ts
Normal file
153
__tests__/api/security.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
146
__tests__/lars-flow-checklist.md
Normal file
146
__tests__/lars-flow-checklist.md
Normal 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
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies, headers } from 'next/headers'
|
||||||
import { getIronSession } from 'iron-session'
|
import { getIronSession } from 'iron-session'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { registerUser, verifyUser } from '@/lib/auth'
|
import { registerUser, verifyUser } from '@/lib/auth'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
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({
|
const registerSchema = z.object({
|
||||||
username: z.string().min(3, 'Gebruikersnaam moet minimaal 3 tekens bevatten').max(50),
|
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) {
|
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({
|
const parsed = registerSchema.safeParse({
|
||||||
username: formData.get('username'),
|
username: formData.get('username'),
|
||||||
password: formData.get('password'),
|
password: formData.get('password'),
|
||||||
|
|
@ -39,6 +50,11 @@ export async function registerAction(_prevState: unknown, formData: FormData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginAction(_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({
|
const parsed = loginSchema.safeParse({
|
||||||
username: formData.get('username'),
|
username: formData.get('username'),
|
||||||
password: formData.get('password'),
|
password: formData.get('password'),
|
||||||
|
|
|
||||||
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 { getIronSession } from 'iron-session'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { NavBar } from '@/components/shared/nav-bar'
|
import { NavBar } from '@/components/shared/nav-bar'
|
||||||
|
import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
|
@ -13,8 +14,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<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} />
|
<NavBar isDemo={session.isDemo} />
|
||||||
<main className="flex-1 flex flex-col">
|
<MinWidthBanner />
|
||||||
|
<main id="main-content" className="flex-1 flex flex-col">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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() {
|
export default async function SettingsPage() {
|
||||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
|
||||||
const userRoles = await prisma.userRole.findMany({
|
const [user, userRoles] = await Promise.all([
|
||||||
where: { user_id: session.userId },
|
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)
|
const currentRoles = userRoles.map(r => r.role as string)
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<h2 className="text-sm font-medium text-foreground">Account</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>}
|
{session.isDemo && <span className="ml-2 text-warning text-xs">(demo)</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
|
|
@ -40,7 +41,10 @@ export default function RootLayout({
|
||||||
lang="nl"
|
lang="nl"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
|
@ -18,10 +19,12 @@ import {
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
@ -96,6 +99,7 @@ function SortablePbiRow({
|
||||||
<span
|
<span
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
|
aria-label="Versleep om te sorteren"
|
||||||
className="mr-2 text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none"
|
className="mr-2 text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|
@ -129,7 +133,7 @@ function CreatePbiForm({
|
||||||
const [state, formAction] = useActionState(
|
const [state, formAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
const result = await createPbiAction(_prev, fd)
|
const result = await createPbiAction(_prev, fd)
|
||||||
if (result?.success) onDone()
|
if (result?.success) { toast.success('PBI aangemaakt'); onDone() }
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -198,7 +202,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
p => grouped[p].length > 0 || creatingForPriority === p
|
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) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
setActiveDragId(event.active.id as string)
|
setActiveDragId(event.active.id as string)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
useSortable,
|
useSortable,
|
||||||
horizontalListSortingStrategy,
|
horizontalListSortingStrategy,
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -135,7 +137,7 @@ function StoryDetailSheet({
|
||||||
const [state, formAction] = useActionState(
|
const [state, formAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
const result = await updateStoryAction(_prev, fd)
|
const result = await updateStoryAction(_prev, fd)
|
||||||
if (result?.success) onClose()
|
if (result?.success) { toast.success('Story opgeslagen'); onClose() }
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -143,7 +145,9 @@ function StoryDetailSheet({
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
startDeleteTransition(async () => {
|
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()
|
onClose()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -279,7 +283,7 @@ function CreateStoryForm({
|
||||||
const [state, formAction] = useActionState(
|
const [state, formAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
const result = await createStoryAction(_prev, fd)
|
const result = await createStoryAction(_prev, fd)
|
||||||
if (result?.success) onDone()
|
if (result?.success) { toast.success('Story aangemaakt'); onDone() }
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -346,7 +350,10 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
|
||||||
p => grouped[p].length > 0 || creatingPriority === p
|
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) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
setActiveDragId(event.active.id as string)
|
setActiveDragId(event.active.id as string)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTransition } from 'react'
|
import { useTransition } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { restoreProductAction } from '@/actions/products'
|
import { restoreProductAction } from '@/actions/products'
|
||||||
|
|
||||||
|
|
@ -25,8 +26,9 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL
|
||||||
|
|
||||||
function handleRestore(id: string) {
|
function handleRestore(id: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await restoreProductAction(id)
|
const result = await restoreProductAction(id)
|
||||||
router.refresh()
|
if ('error' in result) toast.error(result.error ?? 'Herstellen mislukt')
|
||||||
|
else { toast.success('Product hersteld'); router.refresh() }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { updateRolesAction } from '@/actions/todos'
|
import { updateRolesAction } from '@/actions/todos'
|
||||||
|
|
||||||
const ALL_ROLES = [
|
const ALL_ROLES = [
|
||||||
|
|
@ -38,8 +40,8 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) {
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateRolesAction([...selected])
|
const result = await updateRolesAction([...selected])
|
||||||
if (result.success) setSaved(true)
|
if (result.success) { setSaved(true); toast.success('Rollen opgeslagen') }
|
||||||
else setError(result.error ?? 'Opslaan mislukt')
|
else { setError(result.error ?? 'Opslaan mislukt'); toast.error(result.error ?? 'Opslaan mislukt') }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,9 +64,9 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-error">{error}</p>}
|
{error && <p className="text-xs text-error">{error}</p>}
|
||||||
{saved && <p className="text-xs text-success">Rollen opgeslagen.</p>}
|
{saved && <p className="text-xs text-success">Rollen opgeslagen.</p>}
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button size="sm" onClick={handleSave}>Opslaan</Button>
|
<Button size="sm" onClick={handleSave} disabled={isDemo}>Opslaan</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</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 { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
DndContext, DragEndEvent, DragOverEvent, DragStartEvent, DragOverlay,
|
DndContext, DragEndEvent, DragOverEvent, DragStartEvent, DragOverlay,
|
||||||
PointerSensor, useSensor, useSensors, closestCenter,
|
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import {
|
import {
|
||||||
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
|
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -68,6 +69,7 @@ function SortableSprintRow({
|
||||||
>
|
>
|
||||||
{!isDemo && (
|
{!isDemo && (
|
||||||
<span {...attributes} {...listeners} onClick={e => e.stopPropagation()}
|
<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">
|
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm">
|
||||||
⠿
|
⠿
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -113,7 +115,10 @@ export function SprintBacklogLeft({ sprintId, stories, isDemo, onSelectStory, se
|
||||||
const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id)
|
const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id)
|
||||||
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
|
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) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
|
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
|
||||||
import type { SprintStory } from './sprint-backlog'
|
import type { SprintStory } from './sprint-backlog'
|
||||||
|
|
||||||
|
|
@ -41,7 +43,8 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto
|
||||||
const [, goalFormAction] = useActionState(
|
const [, goalFormAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
const result = await updateSprintGoalAction(_prev, fd)
|
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
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -59,8 +62,9 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto
|
||||||
})
|
})
|
||||||
|
|
||||||
startCompleting(async () => {
|
startCompleting(async () => {
|
||||||
await completeSprintAction(sprint.id, finalDecisions)
|
const result = await completeSprintAction(sprint.id, finalDecisions)
|
||||||
setCompleteOpen(false)
|
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>
|
</div>
|
||||||
|
|
||||||
{!isDemo && (
|
<DemoTooltip show={isDemo}>
|
||||||
<Button size="sm" variant="outline" className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
<Button size="sm" variant="outline" disabled={isDemo} className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
||||||
Sprint afronden
|
Sprint afronden
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Complete sprint dialog */}
|
{/* Complete sprint dialog */}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { useState, useTransition, useEffect, useActionState } from 'react'
|
||||||
import { useFormStatus } from 'react-dom'
|
import { useFormStatus } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
|
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
|
||||||
PointerSensor, useSensor, useSensors, closestCenter,
|
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import {
|
import {
|
||||||
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
|
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -97,7 +98,7 @@ function SortableTaskRow({
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
|
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
|
||||||
</div>
|
</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])}>
|
<Badge className={cn('text-xs border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
|
||||||
{STATUS_LABELS[task.status]}
|
{STATUS_LABELS[task.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -105,7 +106,7 @@ function SortableTaskRow({
|
||||||
{!isDemo && (
|
{!isDemo && (
|
||||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
<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={() => 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>
|
||||||
)}
|
)}
|
||||||
</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 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) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
|
|
@ -184,7 +188,8 @@ export function TaskList({ storyId, sprintId, productId, tasks, isDemo }: TaskLi
|
||||||
|
|
||||||
function handleDelete(id: string) {
|
function handleDelete(id: string) {
|
||||||
startTransition(async () => {
|
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'
|
'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 { useFormStatus } from 'react-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
@ -57,7 +59,9 @@ function QuickInput({ isDemo }: { isDemo: boolean }) {
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
<QuickSubmitButton isDemo={isDemo} />
|
<QuickSubmitButton isDemo={isDemo} />
|
||||||
|
</DemoTooltip>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -77,10 +81,13 @@ function PromotePbiDialog({
|
||||||
products,
|
products,
|
||||||
onClose,
|
onClose,
|
||||||
}: { todo: Todo; products: Product[]; onClose: () => void }) {
|
}: { 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(
|
const [state, formAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
const result = await promoteTodoToPbiAction(_prev, fd)
|
const result = await promoteTodoToPbiAction(_prev, fd)
|
||||||
if (result?.success) onClose()
|
if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() }
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -133,13 +140,16 @@ function PromoteStoryDialog({
|
||||||
products,
|
products,
|
||||||
onClose,
|
onClose,
|
||||||
}: { todo: Todo; products: Product[]; onClose: () => void }) {
|
}: { 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 [selectedProductId, setSelectedProductId] = useState(products[0]?.id ?? '')
|
||||||
const selectedProduct = products.find(p => p.id === selectedProductId)
|
const selectedProduct = products.find(p => p.id === selectedProductId)
|
||||||
|
|
||||||
const [state, formAction] = useActionState(
|
const [state, formAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
const result = await promoteTodoToStoryAction(_prev, fd)
|
const result = await promoteTodoToStoryAction(_prev, fd)
|
||||||
if (result?.success) onClose()
|
if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() }
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -218,7 +228,9 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
|
|
||||||
function handleArchive() {
|
function handleArchive() {
|
||||||
startTransition(async () => {
|
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
36
lib/rate-limit.ts
Normal 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
951
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,9 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
|
|
@ -49,10 +51,12 @@
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,11 +62,21 @@ async function main() {
|
||||||
|
|
||||||
console.log(`Main user: ${user.username} (id: ${user.id})`)
|
console.log(`Main user: ${user.username} (id: ${user.id})`)
|
||||||
|
|
||||||
// Create the Scrum4Me product (using the product backlog doc data)
|
// Reset demo product data — delete in dependency order to avoid FK violations
|
||||||
const product = await prisma.product.upsert({
|
const existingProducts = await prisma.product.findMany({ where: { user_id: demo.id }, select: { id: true } })
|
||||||
where: { user_id_name: { user_id: demo.id, name: 'DevPlanner' } },
|
for (const p of existingProducts) {
|
||||||
update: {},
|
// Stories reference product_id directly (no cascade), so delete PBIs first (cascades to stories via pbi_id)
|
||||||
create: {
|
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,
|
user_id: demo.id,
|
||||||
name: 'DevPlanner',
|
name: 'DevPlanner',
|
||||||
description: 'Een lichtgewicht Scrum-gebaseerde projectplanner voor solo developers en kleine Scrum Teams.',
|
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
|
// PBI data from the product backlog document
|
||||||
const pbis = [
|
const pbis = [
|
||||||
|
|
|
||||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal 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, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue