M10: Password-loze inlog via QR-pairing — backlog + implementatie-plan (#11)
* docs(ST-1001..1008): add M10 — QR-pairing login milestone to backlog Plant acht stories ST-1001..ST-1008 voor password-loze inlog via QR-pairing. Mobiele bevestiging met UA+IP, demo-blokkade, paired-sessie 8u TTL. Security-uitgangspunt: mobileSecret reist alleen via QR-fragment + POST-body, desktop-SSE/claim via HttpOnly pre-auth cookie — geheim materiaal nooit in URL-paden, querystrings, access logs of browsergeschiedenis. Twee gescheiden hashes in DB (secret_hash + desktop_token_hash). Bouwt voort op M8 LISTEN/NOTIFY- infra met eigen channel scrum4me_pairing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ST-1001..1008): teach backlog parser about M9 + M10 M9 (Actief Product Backlog) was bij eerdere merge per ongeluk overgeslagen in de drie milestone-maps; viel terug op fallbacks. Nu expliciet, samen met M10 (QR-pairing). Parser self-test toont 12 milestones / 118 stories / 190 tasks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-1001..1008): document QR-login flow in functional spec + persona Voeg F-01b (Inloggen via mobiel via QR-pairing) toe aan de functional spec met acceptatiecriteria, randgevallen en datamodel. Beveiligingsuitgangspunt expliciet: mobileSecret in URL-fragment en HttpOnly desktop-cookie zodat geheim materiaal nooit in URL-paden of access logs belandt. Lars-persona krijgt de bijbehorende use-case (publieke/geleende laptops bij klantbezoek of familie) zodat de feature een herkenbare aanleiding heeft in v1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-1001..1008): add M10 implementation plan + link from backlog Volledig implementatie-plan per story (Bestanden / Stappen / Aandachtspunten / Verificatie) in dezelfde stijl als M9. Citeert de patronen uit docs/patterns/iron-session.md, route-handler.md en server-action.md, en hergebruikt het LISTEN/NOTIFY-pattern uit app/api/realtime/solo/route.ts. Bevat ook commit/branch-strategie per laag, reseed-stap voor de MCP-context, en verificatie-acceptatie inclusief log-controle dat geheim materiaal niet in access logs belandt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: enforce one-branch-per-milestone policy to limit Vercel builds Vercel preview-deployments worden bij elke push naar een feature-branch getriggered en kosten op het Hobby-account budget. Voeg expliciete Branch & PR Strategy toe aan CLAUDE.md: één branch per milestone, commits accumuleren lokaal, push + PR pas na handmatige gebruiker-acceptatie. Uitzonderingen voor planning-only PR's (alleen docs) en hotfixes. Update tegelijk de branch/commit-strategie-tabel in het M10-implementatieplan zodat die de nieuwe policy weerspiegelt (één branch feat/M10-qr-login, chronologische commits per stap, push pas bij groene happy-path-acceptatie). Bevat een 'Wanneer aanpassen'-sectie zodat de regel makkelijk teruggedraaid kan worden zodra het account naar Pro gaat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9941d96846
commit
fb4d2e093f
6 changed files with 1030 additions and 0 deletions
885
docs/plans/M10-qr-pairing-login.md
Normal file
885
docs/plans/M10-qr-pairing-login.md
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
# M10 — Password-loze inlog via QR-pairing
|
||||
|
||||
Inloggen op een (publieke) desktop zonder wachtwoord: desktop toont QR, telefoon (al-ingelogd) scant en bevestigt expliciet, desktop is binnen 1–2 s ingelogd. Bouwt voort op M8 LISTEN/NOTIFY-infra met eigen channel `scrum4me_pairing`.
|
||||
|
||||
**Beveiligingsuitgangspunt:** geheim materiaal nooit in URL-paden, querystrings, access logs of browsergeschiedenis.
|
||||
- `mobileSecret` reist alleen via QR-fragment (`#s=…`) → mobile `location.hash` → POST-body
|
||||
- `desktopToken` reist alleen via HttpOnly cookie `s4m_pair` met `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`
|
||||
- Twee gescheiden hashes in DB scheiden mobiel-bewijs (`secret_hash`) van desktop-bewijs (`desktop_token_hash`)
|
||||
|
||||
Backlog-entries: zie [scrum4me-backlog.md § M10](../scrum4me-backlog.md#m10-password-loze-inlog-via-qr-pairing).
|
||||
Functional spec: zie [scrum4me-functional-spec.md § F-01b](../scrum4me-functional-spec.md#f-01b-inloggen-via-mobiel-qr-pairing).
|
||||
|
||||
**Implementatie-volgorde** (commit-strategy uit CLAUDE.md):
|
||||
|
||||
1. **DB-laag** — ST-1001 (schema + trigger)
|
||||
2. **Auth helpers + sessie-uitbreiding** — ST-1002
|
||||
3. **API-laag** — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim)
|
||||
4. **Server actions + mobile UI** — ST-1005
|
||||
5. **Desktop UI** — ST-1007
|
||||
6. **Documentatie + acceptatietest** — ST-1008
|
||||
|
||||
ST-1006 staat bij de API-laag (niet bij UI) omdat het een Route Handler is; ST-1005 levert tegelijk de server actions en de mobiele bevestigingspagina omdat die strak gekoppeld zijn.
|
||||
|
||||
---
|
||||
|
||||
## ST-1001 — LoginPairing schema + Postgres-trigger
|
||||
|
||||
**Bestanden**
|
||||
- `prisma/schema.prisma` — nieuw model `LoginPairing` + back-relation op `User`
|
||||
- `prisma/migrations/<timestamp>_add_login_pairing/migration.sql` — model + trigger
|
||||
- `vendor/scrum4me`-submodule in repo `scrum4me-mcp` — schema-sync ná merge
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Schema-uitbreiding**:
|
||||
|
||||
```prisma
|
||||
model LoginPairing {
|
||||
id String @id @default(cuid())
|
||||
secret_hash String // sha256 hex van mobileSecret
|
||||
desktop_token_hash String // sha256 hex van desktopToken (HttpOnly cookie)
|
||||
status String // 'pending' | 'approved' | 'consumed' | 'cancelled'
|
||||
user_id String?
|
||||
user User? @relation(fields: [user_id], references: [id], onDelete: SetNull)
|
||||
desktop_ua String? @db.VarChar(255)
|
||||
desktop_ip String? @db.VarChar(45) // IPv6 max
|
||||
created_at DateTime @default(now())
|
||||
expires_at DateTime
|
||||
approved_at DateTime?
|
||||
consumed_at DateTime?
|
||||
|
||||
@@index([expires_at])
|
||||
@@index([status, expires_at])
|
||||
@@map("login_pairings")
|
||||
}
|
||||
```
|
||||
|
||||
Op `User`: `login_pairings LoginPairing[]` toevoegen.
|
||||
|
||||
2. **Migratie-SQL** voegt naast de tabel ook trigger toe (mirror van `notify_solo_change` in `prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql`):
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION notify_pairing_change() RETURNS trigger AS $$
|
||||
DECLARE payload jsonb;
|
||||
BEGIN
|
||||
payload := jsonb_build_object(
|
||||
'op', CASE TG_OP WHEN 'INSERT' THEN 'I' WHEN 'UPDATE' THEN 'U' ELSE 'D' END,
|
||||
'pairing_id', COALESCE(NEW.id, OLD.id),
|
||||
'status', COALESCE(NEW.status, OLD.status)
|
||||
);
|
||||
PERFORM pg_notify('scrum4me_pairing', payload::text);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER login_pairings_notify
|
||||
AFTER INSERT OR UPDATE ON login_pairings
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_pairing_change();
|
||||
```
|
||||
|
||||
3. `npx prisma migrate dev --name add_login_pairing`.
|
||||
|
||||
**Aandachtspunten**
|
||||
- `desktop_ip` houdt op 45 tekens om IPv6 te accommoderen (`xxxx:xxxx:…:255.255.255.255`).
|
||||
- Geen index op `user_id` nodig voor v1 — er is geen lookup-pad "geef alle pairings van user X" (komt pas bij remote-revoke in M+1).
|
||||
- Trigger emit ook bij DELETE niet nodig — pairings worden niet gedelete'd, ze gaan naar `consumed`/`cancelled`.
|
||||
- `vendor/scrum4me`-submodule in scrum4me-mcp moet ná merge op `main` direct gesynced worden, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`). Dit was ook een aandachtspunt bij ST-901.
|
||||
|
||||
**Verificatie**
|
||||
- `npx prisma migrate dev` slaagt
|
||||
- `npx prisma validate` zonder fouten
|
||||
- `psql $DIRECT_URL -c "LISTEN scrum4me_pairing;"` toont payload bij `INSERT INTO login_pairings(...) VALUES(...)`
|
||||
- `prisma studio` toont tabel met beide hash-kolommen `NOT NULL`
|
||||
|
||||
---
|
||||
|
||||
## ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
|
||||
|
||||
**Bestanden**
|
||||
- `lib/auth/pairing.ts` — nieuw, secret/token-generatie en hash-helpers
|
||||
- `lib/auth/pair-cookie.ts` — nieuw, set/read/clear van `s4m_pair`-cookie
|
||||
- `lib/session.ts` — `SessionData` uitbreiden met `paired` en `pairedExpiresAt`
|
||||
- `app/(app)/layout.tsx` — extra guard op vervallen paired-sessie
|
||||
- `__tests__/lib/auth/pairing.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`lib/auth/pairing.ts`**:
|
||||
|
||||
```ts
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||
|
||||
export function generateMobileSecret(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function generateDesktopToken(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function verifyToken(token: string, hash: string): boolean {
|
||||
const a = Buffer.from(hashToken(token), 'hex')
|
||||
const b = Buffer.from(hash, 'hex')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
```
|
||||
|
||||
Twee aparte generators (niet één functie met arg) voorkomen dat dezelfde geheim per ongeluk twee keer wordt gebruikt.
|
||||
|
||||
2. **`lib/auth/pair-cookie.ts`**:
|
||||
|
||||
```ts
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
const COOKIE_NAME = 's4m_pair'
|
||||
const MAX_AGE = 120 // 2 min, gelijk aan pending-TTL van pairing
|
||||
|
||||
export async function setPairCookie(desktopToken: string) {
|
||||
const jar = await cookies()
|
||||
jar.set(COOKIE_NAME, desktopToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth/pair',
|
||||
maxAge: MAX_AGE,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readPairCookie(): Promise<string | null> {
|
||||
const jar = await cookies()
|
||||
return jar.get(COOKIE_NAME)?.value ?? null
|
||||
}
|
||||
|
||||
export async function clearPairCookie() {
|
||||
const jar = await cookies()
|
||||
jar.delete({ name: COOKIE_NAME, path: '/api/auth/pair' })
|
||||
}
|
||||
```
|
||||
|
||||
`Path=/api/auth/pair` zorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route.
|
||||
|
||||
3. **`lib/session.ts`** — `SessionData` interface:
|
||||
|
||||
```ts
|
||||
export interface SessionData {
|
||||
userId: string
|
||||
isDemo: boolean
|
||||
paired?: boolean // true als sessie is aangemaakt via QR-pairing
|
||||
pairedExpiresAt?: number // unix ms
|
||||
}
|
||||
```
|
||||
|
||||
Bestaande sessies blijven werken — beide velden zijn optioneel.
|
||||
|
||||
4. **`app/(app)/layout.tsx`** — guard ná de bestaande `if (!session.userId) redirect('/login')`:
|
||||
|
||||
```ts
|
||||
if (session.paired && session.pairedExpiresAt && session.pairedExpiresAt < Date.now()) {
|
||||
session.destroy()
|
||||
redirect('/login?notice=paired-expired')
|
||||
}
|
||||
```
|
||||
|
||||
Hergebruikt het bestaande `notice`-querystring-patroon van M9's `<NoticeToast />` voor de melding "Je sessie is verlopen, log opnieuw in".
|
||||
|
||||
5. **Tests** — `__tests__/lib/auth/pairing.test.ts`:
|
||||
- `generateMobileSecret()` produceert 43-karakter base64url (32 bytes)
|
||||
- `hashToken` is deterministisch
|
||||
- `verifyToken` is true voor geldig paar, false voor ongeldig
|
||||
- Twee verschillende `generateMobileSecret()`-calls geven verschillende waardes
|
||||
- Cookie helpers: HttpOnly bit gezet (via Next.js cookie-store mock)
|
||||
|
||||
**Aandachtspunten**
|
||||
- Geen middleware nodig voor de paired-expiry-check; layout-guard is voldoende. Middleware komt pas in beeld als `proxy.ts` herzien wordt (uit scope hier).
|
||||
- `cookies().delete({ name, path })` moet **dezelfde path** specificeren als bij set, anders blijft de cookie staan.
|
||||
- `crypto.randomBytes` is sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped
|
||||
- `document.cookie` op de pagina laat de cookie *niet* zien
|
||||
|
||||
---
|
||||
|
||||
## ST-1003 — `POST /api/auth/pair/start` (anon, sets pre-auth cookie)
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/auth/pair/start/route.ts` — nieuw
|
||||
- `lib/rate-limit.ts` — checken of bestaand (uit ST-608); anders helper toevoegen
|
||||
- `__tests__/api/pair-start.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Route Handler (vrij van `authenticateApiRequest` — dit is anon):
|
||||
|
||||
```ts
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
generateMobileSecret, generateDesktopToken, hashToken,
|
||||
} from '@/lib/auth/pairing'
|
||||
import { setPairCookie } from '@/lib/auth/pair-cookie'
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const PENDING_TTL_MS = 2 * 60 * 1000
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null
|
||||
const ua = request.headers.get('user-agent')?.slice(0, 255) ?? null
|
||||
|
||||
// Rate-limit per IP — 10/min (zelfde patroon als ST-608)
|
||||
const limited = await rateLimit(`pair-start:${ip ?? 'anon'}`, 10, 60_000)
|
||||
if (limited) {
|
||||
return Response.json({ error: 'Te veel verzoeken' }, { status: 429 })
|
||||
}
|
||||
|
||||
const mobileSecret = generateMobileSecret()
|
||||
const desktopToken = generateDesktopToken()
|
||||
|
||||
const pairing = await prisma.loginPairing.create({
|
||||
data: {
|
||||
secret_hash: hashToken(mobileSecret),
|
||||
desktop_token_hash: hashToken(desktopToken),
|
||||
status: 'pending',
|
||||
desktop_ua: ua,
|
||||
desktop_ip: ip,
|
||||
expires_at: new Date(Date.now() + PENDING_TTL_MS),
|
||||
},
|
||||
select: { id: true, expires_at: true },
|
||||
})
|
||||
|
||||
await setPairCookie(desktopToken)
|
||||
|
||||
const origin = request.nextUrl.origin
|
||||
const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}`
|
||||
|
||||
return Response.json({
|
||||
pairingId: pairing.id,
|
||||
mobileSecret,
|
||||
expiresAt: pairing.expires_at.toISOString(),
|
||||
qrUrl,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
2. **Rate-limit helper** — als `lib/rate-limit.ts` nog niet bestaat:
|
||||
- In-memory Map keyed op `key`, met sliding-window van timestamps; thread-safe genoeg voor v1 single-instance Vercel Functions.
|
||||
- Wordt ook door `actions/auth.ts` (login) gebruikt; verifieer met grep of die al bestaat — zo ja, hergebruik exact.
|
||||
|
||||
3. **Tests** — `__tests__/api/pair-start.test.ts`:
|
||||
- 200 response bevat `pairingId`, `mobileSecret`, `qrUrl` met fragment-syntax (`#id=…&s=…`)
|
||||
- `Set-Cookie`-header bevat `s4m_pair=...; HttpOnly; SameSite=Lax; Path=/api/auth/pair; Max-Age=120`
|
||||
- Database-rij heeft `secret_hash`, `desktop_token_hash`, geen plaintext
|
||||
- 11e call binnen 60s → 429
|
||||
- `desktop_ua` en `desktop_ip` worden opgeslagen bij aanwezigheid, anders `null`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `mobileSecret` mag in de JSON-response — die is HTTPS-encrypted en wordt niet door browsers naar logs geschreven. Kritisch is dat het niet in **request**-URLs of **server**-toegangslogs belandt.
|
||||
- `qrUrl` gebruikt `#` (fragment), niet `?`. Browsers strippen het fragment voordat ze de mobile pair-page ophalen — maar dat is uitvoerig getest in ST-1005 server-side: de page leest de URL niet, alleen de client component leest `window.location.hash`.
|
||||
- Geen idempotency-key nodig: een tweede start vanuit dezelfde tab maakt simpelweg een nieuwe pairing en cookie aan; oude cookie wordt overschreven.
|
||||
- Vercel-edge-of-fluid: `runtime: 'nodejs'` expliciet (niet 'edge') want we gebruiken `crypto.randomBytes`.
|
||||
|
||||
**Verificatie**
|
||||
- `curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jar` retourneert JSON + `Set-Cookie`
|
||||
- Body bevat `qrUrl` met `#id=…&s=…`
|
||||
- Rij in `login_pairings` heeft beide hashes ingevuld
|
||||
- Tests groen
|
||||
|
||||
---
|
||||
|
||||
## ST-1004 — SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth)
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/auth/pair/stream/[pairingId]/route.ts` — nieuw
|
||||
- `__tests__/api/pair-stream.test.ts` — nieuw (auth-test, geen full SSE-test)
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Routebestand** — exacte structuur uit `app/api/realtime/solo/route.ts` (incl. heartbeat, hard-close, abort-handler), maar:
|
||||
- Geen iron-session check; auth via `s4m_pair`-cookie:
|
||||
|
||||
```ts
|
||||
const desktopToken = await readPairCookie()
|
||||
if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { desktop_token_hash: true, status: true, expires_at: true },
|
||||
})
|
||||
if (!pairing) return Response.json({ error: 'Pairing niet gevonden' }, { status: 404 })
|
||||
if (pairing.expires_at < new Date()) return Response.json({ error: 'Pairing verlopen' }, { status: 410 })
|
||||
if (!verifyToken(desktopToken, pairing.desktop_token_hash)) {
|
||||
return Response.json({ error: 'Ongeldige cookie' }, { status: 401 })
|
||||
}
|
||||
```
|
||||
|
||||
- Channel = `'scrum4me_pairing'`
|
||||
- Filter `shouldEmit`: `payload.pairing_id === pairingId`
|
||||
- Auto-close ook bij payload `status` ∈ `{'consumed', 'cancelled'}` — niet alleen na 240 s
|
||||
- Geen sprint-resolve, geen userId-filter — eenvoudiger dan solo-route
|
||||
|
||||
2. **Initial-state-event** vlak na verbinden: query de pairing-status één keer uit DB en stuur als `event: state\ndata: {"status":"pending"}` zodat de desktop niet hoeft te wachten op het eerste pg_notify (handig als pairing al `approved` is voordat de SSE opent — race-conditie).
|
||||
|
||||
3. **Tests**:
|
||||
- GET zonder cookie → 401
|
||||
- GET met cookie maar onbekende `pairingId` → 404
|
||||
- GET met verlopen pairing → 410
|
||||
- GET met cookie die hasht naar een andere `desktop_token_hash` → 401
|
||||
|
||||
Geen full-stream-test — vereist een Postgres-event-mock die het niet waard is voor v1. Manuele test dekt dit (zie verificatie).
|
||||
|
||||
**Aandachtspunten**
|
||||
- `pairingId` in het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs.
|
||||
- `EventSource` op de client kan geen custom headers; cookie is daarom de enige praktische auth-methode. `withCredentials: true` op de client is verplicht.
|
||||
- Bij browser-tab-sluiten wordt `request.signal.abort` getriggered → cleanup zoals in solo-route.
|
||||
- Vermijd Prisma in de notification-handler; gebruik alleen `pg.Client` zoals solo-route. Status-check hierboven is de enige Prisma-call (vóór de stream start).
|
||||
|
||||
**Verificatie**
|
||||
- `curl -N --cookie /tmp/jar http://localhost:3000/api/auth/pair/stream/<id>` blijft open en print `: heartbeat` elke 25 s
|
||||
- Andere terminal: `psql $DIRECT_URL -c "UPDATE login_pairings SET status='approved' WHERE id='<id>'"` → curl-uitvoer toont event binnen 1 s
|
||||
- Manuele 401-test: `curl -N` zonder cookie → JSON 401
|
||||
|
||||
---
|
||||
|
||||
## ST-1005 — Server actions + mobiele bevestigingspagina
|
||||
|
||||
**Bestanden**
|
||||
- `actions/pairing.ts` — nieuw, drie Server Actions
|
||||
- `app/(app)/m/pair/page.tsx` — nieuw, Server Component
|
||||
- `app/(app)/m/pair/pair-confirmation.tsx` — nieuw, Client Component
|
||||
- `__tests__/actions/pairing.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`actions/pairing.ts`** — volgt `docs/patterns/server-action.md`:
|
||||
|
||||
```ts
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyToken } from '@/lib/auth/pairing'
|
||||
|
||||
const inputSchema = z.object({
|
||||
pairingId: z.string().cuid(),
|
||||
mobileSecret: z.string().min(40), // base64url van 32 bytes ≈ 43 chars
|
||||
})
|
||||
|
||||
export async function getPairingForApproval(pairingId: string, mobileSecret: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: {
|
||||
status: true, expires_at: true, secret_hash: true,
|
||||
desktop_ua: true, desktop_ip: true,
|
||||
},
|
||||
})
|
||||
if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
|
||||
if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
|
||||
if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
|
||||
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
|
||||
return { ok: false, error: 'Ongeldig pairing-geheim' } as const
|
||||
}
|
||||
|
||||
const me = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { username: true },
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
desktop_ua: pairing.desktop_ua,
|
||||
desktop_ip: pairing.desktop_ip,
|
||||
username: me?.username ?? '',
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function approvePairing(pairingId: string, mobileSecret: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
|
||||
if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } as const
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { status: true, expires_at: true, secret_hash: true },
|
||||
})
|
||||
if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
|
||||
if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
|
||||
if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
|
||||
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
|
||||
return { ok: false, error: 'Ongeldig pairing-geheim' } as const
|
||||
}
|
||||
|
||||
const APPROVED_TTL_MS = 5 * 60 * 1000
|
||||
await prisma.loginPairing.update({
|
||||
where: { id: pairingId },
|
||||
data: {
|
||||
status: 'approved',
|
||||
user_id: session.userId,
|
||||
approved_at: new Date(),
|
||||
expires_at: new Date(Date.now() + APPROVED_TTL_MS),
|
||||
},
|
||||
})
|
||||
// Trigger emit pg_notify automatisch — geen revalidatePath nodig
|
||||
return { ok: true } as const
|
||||
}
|
||||
|
||||
export async function cancelPairing(pairingId: string, mobileSecret: string) {
|
||||
// Vergelijkbaar met approvePairing maar status='cancelled', geen user_id; demo mag annuleren.
|
||||
}
|
||||
```
|
||||
|
||||
2. **`app/(app)/m/pair/page.tsx`** — Server Component, achter bestaande `(app)/layout.tsx` auth-guard:
|
||||
|
||||
```tsx
|
||||
import { PairConfirmation } from './pair-confirmation'
|
||||
|
||||
export default function PairPage() {
|
||||
return (
|
||||
<main className="container mx-auto max-w-md py-12">
|
||||
<h1 className="text-h2">Inloggen op desktop</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code toont.
|
||||
</p>
|
||||
<PairConfirmation />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Geen searchParams! De page leest de URL überhaupt niet — alleen het client-island doet dat client-side via `window.location.hash`.
|
||||
|
||||
3. **`app/(app)/m/pair/pair-confirmation.tsx`** — Client Component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
import { getPairingForApproval, approvePairing, cancelPairing } from '@/actions/pairing'
|
||||
|
||||
type State =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'invalid'; error: string }
|
||||
| { kind: 'ready'; pairingId: string; secret: string; ua: string | null; ip: string | null; username: string }
|
||||
| { kind: 'success' }
|
||||
| { kind: 'error'; error: string }
|
||||
|
||||
function parseHash(): { id: string; s: string } | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const params = new URLSearchParams(hash)
|
||||
const id = params.get('id'); const s = params.get('s')
|
||||
return id && s ? { id, s } : null
|
||||
}
|
||||
|
||||
export function PairConfirmation() {
|
||||
const [state, setState] = useState<State>({ kind: 'loading' })
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseHash()
|
||||
if (!parsed) {
|
||||
setState({ kind: 'invalid', error: 'Ongeldige pairing-link' })
|
||||
return
|
||||
}
|
||||
getPairingForApproval(parsed.id, parsed.s).then((res) => {
|
||||
if (!res.ok) setState({ kind: 'invalid', error: res.error })
|
||||
else setState({
|
||||
kind: 'ready',
|
||||
pairingId: parsed.id, secret: parsed.s,
|
||||
ua: res.desktop_ua, ip: res.desktop_ip, username: res.username,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
function onApprove() {
|
||||
if (state.kind !== 'ready') return
|
||||
startTransition(async () => {
|
||||
const res = await approvePairing(state.pairingId, state.secret)
|
||||
if (!res.ok) { toast.error(res.error); return }
|
||||
// Wist secret uit URL zodat back/forward 'm niet onthult
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
setState({ kind: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (state.kind !== 'ready') return
|
||||
startTransition(async () => {
|
||||
await cancelPairing(state.pairingId, state.secret)
|
||||
setState({ kind: 'invalid', error: 'Pairing geannuleerd' })
|
||||
})
|
||||
}
|
||||
|
||||
// Render-logica per state — kort:
|
||||
// loading → spinner
|
||||
// invalid → foutmelding + link "Terug naar dashboard"
|
||||
// ready → kaart met UA/IP/username + Bevestig/Annuleer
|
||||
// success → "Klaar — je kunt deze tab sluiten"
|
||||
// … (volledige JSX in implementation)
|
||||
}
|
||||
```
|
||||
|
||||
4. **Tests** — `__tests__/actions/pairing.test.ts`:
|
||||
- `getPairingForApproval` met `pending`-pairing → `ok: true` + ua/ip/username
|
||||
- `getPairingForApproval` met al-`approved` → `ok: false`
|
||||
- `getPairingForApproval` met verlopen → `ok: false`
|
||||
- `getPairingForApproval` met verkeerd secret → `ok: false`
|
||||
- `approvePairing` als demo-user → `ok: false` + DB onveranderd
|
||||
- `approvePairing` happy path → status `approved`, `user_id` gezet, `expires_at` bumped
|
||||
- `cancelPairing` happy path → status `cancelled`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `getPairingForApproval` mág door demo-users worden aangeroepen (om iets te zien); alleen `approvePairing` blokkeert demo's.
|
||||
- `pair-confirmation.tsx` gebruikt **geen** `useSearchParams` — die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien.
|
||||
- Na approve `window.history.replaceState` zonder de `#hash` zorgt dat browser-back de secret niet opnieuw onthult.
|
||||
- `revalidatePath` in `approvePairing` is niet nodig — de desktop hoort het via SSE, en de mobiele page heeft geen server-state om te ververversen.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatig: log in op telefoon-emulator → bezoek `/m/pair#id=…&s=…` → kaart toont UA/IP → Bevestig → succes-state, URL is `/m/pair` zonder hash
|
||||
|
||||
---
|
||||
|
||||
## ST-1006 — `POST /api/auth/pair/claim` (cookie-auth, schrijft iron-session)
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/auth/pair/claim/route.ts` — nieuw
|
||||
- `__tests__/api/pair-claim.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Route Handler:
|
||||
|
||||
```ts
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { cookies } from 'next/headers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { hashToken } from '@/lib/auth/pairing'
|
||||
import { readPairCookie, clearPairCookie } from '@/lib/auth/pair-cookie'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const PAIRED_TTL_MS = 8 * 60 * 60 * 1000 // 8 uur
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const desktopToken = await readPairCookie()
|
||||
if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
|
||||
|
||||
const body = await request.json().catch(() => null) as { pairingId?: string } | null
|
||||
const pairingId = body?.pairingId
|
||||
if (!pairingId) return Response.json({ error: 'pairingId vereist' }, { status: 400 })
|
||||
|
||||
const desktopTokenHash = hashToken(desktopToken)
|
||||
|
||||
// Atomic: WHERE status='approved' AND token-hash + expiry → consumed, RETURNING user_id
|
||||
const updated = await prisma.loginPairing.updateMany({
|
||||
where: {
|
||||
id: pairingId,
|
||||
status: 'approved',
|
||||
desktop_token_hash: desktopTokenHash,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
data: { status: 'consumed', consumed_at: new Date() },
|
||||
})
|
||||
if (updated.count !== 1) {
|
||||
// Was het wél een geldige cookie maar al consumed? → 410. Anders → 401.
|
||||
const exists = await prisma.loginPairing.findFirst({
|
||||
where: { id: pairingId, desktop_token_hash: desktopTokenHash },
|
||||
select: { status: true, expires_at: true },
|
||||
})
|
||||
await clearPairCookie()
|
||||
if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 })
|
||||
if (exists.status === 'consumed') return Response.json({ error: 'Al gebruikt' }, { status: 410 })
|
||||
return Response.json({ error: 'Niet beschikbaar' }, { status: 410 })
|
||||
}
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { user_id: true, user: { select: { is_demo: true } } },
|
||||
})
|
||||
if (!pairing?.user_id) {
|
||||
await clearPairCookie()
|
||||
return Response.json({ error: 'Pairing zonder user' }, { status: 500 })
|
||||
}
|
||||
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
session.userId = pairing.user_id
|
||||
session.isDemo = pairing.user?.is_demo ?? false
|
||||
session.paired = true
|
||||
session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS
|
||||
await session.save()
|
||||
|
||||
await clearPairCookie()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
```
|
||||
|
||||
2. **Tests**:
|
||||
- 200 + iron-session cookie + clear `s4m_pair` na succes
|
||||
- 410 op tweede claim met dezelfde cookie
|
||||
- 401 zonder cookie
|
||||
- 401 met cookie die hasht naar andere pairing
|
||||
- paired-sessie bevat `paired: true` en `pairedExpiresAt` rond `now + 8h`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `updateMany` gebruiken (niet `update`) want we hebben een composite WHERE met `status` + `desktop_token_hash` + `expires_at`; `update` kan alleen op unique keys.
|
||||
- Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in `count = 1` voor één caller en `count = 0` voor de ander.
|
||||
- `clearPairCookie` ook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch — `Max-Age=120` regelt het ook).
|
||||
- De `session.isDemo` check overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maar `is_demo` doorzetten is een extra vangnet.
|
||||
|
||||
**Verificatie**
|
||||
- Handmatig: na approve in mobiele tab, POST naar `/api/auth/pair/claim` met de cookie van start → 200 + `Set-Cookie: scrum4me-session=...`
|
||||
- `curl -X POST` zonder cookie → 401
|
||||
- Tweede claim → 410
|
||||
|
||||
---
|
||||
|
||||
## ST-1007 — Desktop UI: QR-render + SSE-listener op `/login`
|
||||
|
||||
**Bestanden**
|
||||
- `app/login/page.tsx` — bestaand, knop "Inloggen via mobiel" toevoegen
|
||||
- `app/login/qr-login-button.tsx` — nieuw, Client Component
|
||||
- `package.json` — `qrcode.react` toevoegen
|
||||
- `__tests__/components/qr-login-button.test.tsx` — minimale render-test
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Dependency**: `npm install qrcode.react` — direct in `dependencies` per CLAUDE.md-conventie.
|
||||
|
||||
2. **`qr-login-button.tsx`**:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting' }
|
||||
| { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number }
|
||||
| { kind: 'expired'; pairingId: string }
|
||||
| { kind: 'claiming' }
|
||||
|
||||
export function QrLoginButton() {
|
||||
const router = useRouter()
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
|
||||
const sseRef = useRef<EventSource | null>(null)
|
||||
|
||||
async function start() {
|
||||
setPhase({ kind: 'starting' })
|
||||
const res = await fetch('/api/auth/pair/start', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) { toast.error('Kon QR-code niet aanmaken'); setPhase({ kind: 'idle' }); return }
|
||||
const data = await res.json() as { pairingId: string; qrUrl: string; expiresAt: string }
|
||||
setPhase({
|
||||
kind: 'showing',
|
||||
pairingId: data.pairingId,
|
||||
qrUrl: data.qrUrl,
|
||||
expiresAt: new Date(data.expiresAt).getTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// SSE-koppeling
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'showing') return
|
||||
const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
sseRef.current = es
|
||||
|
||||
es.addEventListener('message', async (ev) => {
|
||||
const data = JSON.parse(ev.data) as { status?: string }
|
||||
if (data.status === 'approved') {
|
||||
es.close()
|
||||
setPhase({ kind: 'claiming' })
|
||||
const res = await fetch('/api/auth/pair/claim', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pairingId: phase.pairingId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Inloggen mislukt')
|
||||
setPhase({ kind: 'idle' }); return
|
||||
}
|
||||
router.push('/dashboard')
|
||||
}
|
||||
})
|
||||
es.addEventListener('error', () => { /* silent — laat reconnecten */ })
|
||||
|
||||
return () => { es.close() }
|
||||
}, [phase, router])
|
||||
|
||||
// Aftellende timer + auto-expire
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'showing') return
|
||||
const t = setInterval(() => {
|
||||
if (Date.now() > phase.expiresAt) {
|
||||
sseRef.current?.close()
|
||||
setPhase({ kind: 'expired', pairingId: phase.pairingId })
|
||||
clearInterval(t)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [phase])
|
||||
|
||||
// Render: knop / QR + countdown / "Vernieuwen" — JSX hier weggelaten voor brevity
|
||||
}
|
||||
```
|
||||
|
||||
3. **`app/login/page.tsx`** — knop toevoegen onder of naast het wachtwoord-formulier:
|
||||
|
||||
```tsx
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<div className="border-border h-px flex-1 border-t" />
|
||||
<span className="text-muted-foreground text-sm">of</span>
|
||||
<div className="border-border h-px flex-1 border-t" />
|
||||
</div>
|
||||
<QrLoginButton />
|
||||
```
|
||||
|
||||
MD3-tokens uit `docs/scrum4me-styling.md`; geen willekeurige Tailwind-kleuren.
|
||||
|
||||
4. **A11y**: QR-component krijgt `aria-label="QR-code voor mobiel inloggen"` en de URL wordt visueel als kopieer-bare tekst onder de QR getoond zodat screenreaders en gebruikers met cameraproblemen de URL handmatig kunnen openen.
|
||||
|
||||
**Aandachtspunten**
|
||||
- `EventSource({ withCredentials: true })` is verplicht zodat de browser de `s4m_pair`-cookie meestuurt; standaard verstuurt EventSource geen credentials.
|
||||
- Cleanup-volgorde: `es.close()` eerst, dán fetch claim. Anders kan een tweede `approved`-event tussen close-en-claim binnenkomen en een dubbele claim triggeren (server vangt het op met 410, maar netter is het netjes te sluiten).
|
||||
- `qrcode.react` exporteert zowel `QRCodeSVG` als `QRCodeCanvas`. Kies SVG: schaalbaarder voor printen/screenshots en kleinere bundle.
|
||||
- Geen `next/image` — QR is dynamisch en client-side.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar `/dashboard` met `session.paired === true`
|
||||
- DevTools Network-tab: geen URL bevat `s=` (alleen `Set-Cookie`/`Cookie` headers)
|
||||
- Speel met een verlopen QR: na 2 min toont knop "Vernieuwen", `Vernieuwen` start nieuwe pairing
|
||||
|
||||
---
|
||||
|
||||
## ST-1008 — Documentatie + acceptatietest
|
||||
|
||||
**Bestanden**
|
||||
- `docs/API.md` — drie nieuwe endpoints
|
||||
- `docs/scrum4me-architecture.md` — sectie "QR-pairing flow" + threat-model
|
||||
- `docs/patterns/qr-login.md` — nieuw pattern-doc
|
||||
- `CLAUDE.md` — verwijzing naar het pattern-doc in de patterns-tabel
|
||||
- `__tests__/integration/qr-pairing-e2e.test.ts` — optioneel, alleen als de test-infra het toelaat
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`docs/API.md`** — drie endpoints documenteren met request/response, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`. Voeg een sectie *"Cookie-mechaniek"* toe die uitlegt dat `s4m_pair` een tijdelijke pre-auth cookie is, anders dan de iron-session cookie.
|
||||
|
||||
2. **`docs/scrum4me-architecture.md`** — sectie *"QR-pairing flow"* met:
|
||||
- Sequence-diagram (mermaid of ASCII analoog aan M8)
|
||||
- Threat-model:
|
||||
- **Replay**: atomic `updateMany` met `status='approved'` voorkomt dubbele claim
|
||||
- **Phishing-QR**: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist
|
||||
- **Demo-block**: `approvePairing` early return op `session.isDemo`
|
||||
- **Rate-limit**: 10 starts per IP per minuut
|
||||
- **Secret-hashing**: alleen sha256-hashes in DB; secrets verlaten desktop alleen via QR-fragment + POST-body
|
||||
- **TTL-rationale**: 2 min pending vs. 5 min approved vs. 8 u paired-sessie — verschillen verklaren
|
||||
- **Subsectie "Waarom geen secret in URL"**: fragment-eigenschap (browsers sturen `#…` niet naar server); HttpOnly cookie voor desktop-bewijs; geen secret in access logs / reverse-proxy logs / observability / browsergeschiedenis
|
||||
|
||||
3. **`docs/patterns/qr-login.md`** — herbruikbaar patroon: "unauth-SSE-via-pre-auth-cookie". Toekomstige features die een pre-auth flow met realtime-updates willen kunnen dit kopiëren (bv. "ontvang webhook live", "long-running export"). Inclusief:
|
||||
- Wanneer dit patroon te gebruiken (er moet realtime-feedback zijn vóór de gebruiker is geauthenticeerd)
|
||||
- Verwijzingen naar `lib/auth/pair-cookie.ts` als sjabloon
|
||||
- Risico's en mitigaties
|
||||
|
||||
4. **`CLAUDE.md`** — in de *Implementatiepatronen*-tabel een rij `| QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |`.
|
||||
|
||||
5. **Acceptatie-scenario's** (handmatig, eventueel automatiseerbaar in v2):
|
||||
1. Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd
|
||||
2. Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen
|
||||
3. Replay — claim de pairing twee keer → tweede call 410
|
||||
4. Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen"
|
||||
5. Expiry tussen approve en claim — approve, wacht 6 min, claim → 410
|
||||
6. Ontbrekende cookie op SSE/claim — verwijder `s4m_pair` in DevTools, herhaal → 401
|
||||
7. **Secret niet in access logs** — controleer Vercel runtime-logs (via `mcp__a1fa0fcf-…__get_runtime_logs`) en lokale dev-logs; zoek op de secret-string en op `s=`-substrings; verwacht: 0 hits
|
||||
|
||||
**Aandachtspunten**
|
||||
- Zorg dat de runtime-logs MCP-controle in `docs/scrum4me-test-plan.md` belandt zodat hij bij elke release herhaalbaar is.
|
||||
- `docs/patterns/qr-login.md` mag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Alle zeven scenario's handmatig groen, beschreven in een test-rapport-sectie
|
||||
- `vendor/scrum4me`-submodule in scrum4me-mcp gesynced ná schema-merge
|
||||
|
||||
---
|
||||
|
||||
## Branch- en commit-strategie
|
||||
|
||||
Per [CLAUDE.md → Branch & PR Strategy](../../CLAUDE.md#branch--pr-strategy-strict--kostenbeheersing): **één branch voor de hele milestone**, PR pas na handmatige acceptatie door de gebruiker. Reden: elke push triggert een Vercel preview-build, en op het Hobby-account zijn die schaars.
|
||||
|
||||
**Branch:** `feat/M10-qr-login` — afgesplitst van `main` na merge van de planning-PR (#11). Alle ST-1001..ST-1008-werk landt op deze branch.
|
||||
|
||||
**Commits** in chronologische volgorde, één per stap, ST-code in de titel. Voorbeeld-progressie:
|
||||
|
||||
```
|
||||
feat(ST-1001): add LoginPairing model
|
||||
feat(ST-1001): add pg_notify trigger on scrum4me_pairing channel
|
||||
feat(ST-1002): add pairing helpers and pre-auth cookie
|
||||
feat(ST-1002): extend SessionData with paired flag
|
||||
feat(ST-1002): guard expired paired sessions in app layout
|
||||
feat(ST-1003): add /api/auth/pair/start with rate-limit and pre-auth cookie
|
||||
feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth
|
||||
feat(ST-1005): add pairing server actions
|
||||
feat(ST-1005): add mobile pair confirmation page with hash-fragment client island
|
||||
feat(ST-1006): add /api/auth/pair/claim with atomic consume
|
||||
chore(ST-1007): add qrcode.react dependency
|
||||
feat(ST-1007): add QR login button on /login with SSE listener
|
||||
docs(ST-1008): document QR-pairing endpoints in API.md
|
||||
docs(ST-1008): add QR-pairing flow and threat-model to architecture
|
||||
docs(ST-1008): add qr-login pattern doc
|
||||
```
|
||||
|
||||
**Push + PR**: pas nadat ST-1008-acceptatie-scenario 1 (happy path, end-to-end op localhost) handmatig groen is bevonden door de gebruiker. Tussentijdse "klaar voor jouw test"-momenten markeren we lokaal — niet met een push.
|
||||
|
||||
**Pre-merge gates** (uit CLAUDE.md DoD):
|
||||
- `npm run lint && npm test && npm run build` groen op CI
|
||||
- Schema-wijziging in ST-1001 → wekelijkse drift-check `trig_015FFUnxjz9WMuhhWNGBQKFD` mag niet rood staan; `vendor/scrum4me`-submodule in scrum4me-mcp meebewegen na merge
|
||||
|
||||
**Wanneer dit aanpassen:** zodra het Vercel-account naar Pro gaat — zie CLAUDE.md.
|
||||
|
||||
---
|
||||
|
||||
## Reseed-stap (eenmalig vóór ST-1001-implementatie)
|
||||
|
||||
De backlog-markdown bevat ST-1001..1008, maar de live database heeft die rijen nog niet. Voordat `mcp__scrum4me__get_claude_context` ze als next-story kan teruggeven:
|
||||
|
||||
```
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
Verifieer dat M10 en zijn 8 stories als `status=OPEN` in de DB staan. Daarna geeft `mcp__scrum4me__implement_next_story product_id=cmohfotr70000jwrt0hw4q020` automatisch ST-1001 als startpunt.
|
||||
|
||||
> **Let op:** seed kan bestaande dev-data overschrijven. Doe dit op een dev-DB, niet productie. Voor productie volstaat het om de stories handmatig of via een eenmalige migratie-script in te voegen — buiten scope hier.
|
||||
Loading…
Add table
Add a link
Reference in a new issue