* docs(dialog-pattern): add generic entity-dialog spec Introduceert docs/patterns/dialog.md als bron-of-truth voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject. Bevat 14 secties: uitgangspunten, stack, component- architectuur, layout, validatie, drielaagse demo-policy, submission, dialog-gedrag, theming, footer, triggers/URL-state, per-entiteit profile-template, out-of-scope, en een verificatie-checklist. Registreert het patroon in CLAUDE.md "Implementatiepatronen"-tabel zodat Claude (en mensen) de spec verplicht raadplegen voor elke nieuwe dialog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(dialog-pattern): convert task spec + add pbi/story entity-profiles Reduceert docs/scrum4me-task-dialog.md van 507 naar ~140 regels: alle gedeelde regels verhuisd naar docs/patterns/dialog.md, dit document bevat nu alleen Task-specifieke velden, URL-pattern, status-veld, server actions, triggers en bewuste out-of-scope-keuzes. Voegt twee nieuwe entity-profielen toe voor bestaande dialogen: - docs/scrum4me-pbi-dialog.md (PbiDialog: state-based, code+title-rij, PbiStatusSelect, geen delete in v1) - docs/scrum4me-story-dialog.md (StoryDialog: state-based, header met status/priority badges, inline activity-log, demo-readonly-fallback, inline-delete-confirm i.p.v. AlertDialog) Beide profielen documenteren expliciet de "Bekende gaps t.o.v. generieke spec" zodat opvolgende PR's de afwijkingen kunnen rechtzetten of bewust kunnen accorderen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Added pdevelopment docs * docs(plans): add docs-restructure plan for AI-optimized lookup Audit of existing 39 doc files (~10.700 lines) and a phased restructure proposal aimed at minimising the tokens an AI agent has to read to find the right reference. Captures resolved decisions on language (English), ADR template (Nygard default with MADR escape-hatch), index generator (node script), and folder taxonomy. Proposal status — fase 1 to follow. * docs(adr): add ADR scaffolding (templates, README, meta-ADR) Set up docs/adr/ as the canonical home for architecture decisions: - templates/nygard.md — default four-section format (Status, Context, Decision, Consequences) for one-way-door decisions. - templates/madr.md — MADR v4 with YAML front-matter and explicit Considered Options for decisions where rejected alternatives matter. - README.md — naming convention (NNNN-kebab-case), template-selection guidance (Nygard default; MADR for auth, queue mechanics, agent integration), status lifecycle, and ADR roster. - 0000-record-architecture-decisions.md — meta-ADR establishing the practice itself, in Nygard format. Backfilling existing implicit decisions (base-ui-over-radix, float sort_order, demo-user three-layer policy, etc.) is fase 6 of the docs-restructure plan. * feat(docs): add docs index generator + initial INDEX.md scripts/generate-docs-index.mjs walks docs/**/*.md, parses YAML front-matter (or first H1 fallback) and a Nygard-style ## Status section, then writes docs/INDEX.md with grouped tables for ADRs, Specs, Plans (with archive subsection), Patterns, and Other. Pure Node 20 (no external deps); idempotent — running it twice produces byte-identical output. Excludes adr/templates/, the ADR README, INDEX.md itself, and any *_*.md sidecar file. Wire-up: - package.json: docs:index → node scripts/generate-docs-index.mjs Initial run indexed 35 docs across the existing structure; the generated INDEX.md is committed so the table is reviewable in the PR before hooking generation into a pre-commit step. * chore: ignore Obsidian vault and personal sidecar files Add .obsidian/ (Obsidian vault config) and _*.md (personal sidecar notes) to .gitignore so the docs/ tree can serve as canonical source of truth while still being usable as an Obsidian vault for personal authoring. The docs index generator already excludes the same _*.md pattern from INDEX.md. * docs(plans): add PBI bulk-create spec for docs-restructure Machine-parseable spec for an executor that calls the scrum4me MCP (create_pbi → create_story → create_task) to seed the docs-restructure work into the DB. - Section 1 (Context) is the PBI description; serves as task-context via mcp__scrum4me__get_claude_context. - Section 2 lists the 6 resolved decisions (English, MD3+styling merged, solo-paneel merged, .Plans archived, Nygard ADR default, node index script). - Section 3 records what already shipped on this branch so the executor doesn't duplicate the ADR scaffolding or index generator. - Section 4 carries the structured YAML graph: 1 PBI, 8 stories (one per phase), 39 tasks. product_id is REPLACE_ME — fill before running. - YAML validated with PyYAML; field schema sanity-checked. * docs(junk-cleanup): remove stub patterns/test.md * docs(junk-cleanup): archive .Plans/ to docs/plans/archive/ * docs(front-matter): add YAML front-matter to docs/ root * docs(front-matter): add YAML front-matter to patterns/ * docs(front-matter): add YAML front-matter to plans + agent files * docs(index): regenerate INDEX.md after front-matter pass * docs(naming): drop scrum4me- prefix from doc filenames * docs(naming): lowercase API.md and MD3 filenames * docs(naming): rename plan file to kebab-case ASCII * docs(naming): rename middleware.md to proxy.md (next 16) * docs(naming): polish CLAUDE.md doc-index after renames * docs(taxonomy): scaffold topical folders under docs/ * docs(taxonomy): move spec files into docs/specs/ * docs(taxonomy): move design/api/qa/backlog/assets into folders * docs(taxonomy): move agent-instruction-audit into decisions/ * docs(split): break architecture.md into 6 topical files * docs(split): merge solo-paneel-spec into specs/functional.md * docs(split): merge md3-color-scheme into design/styling * docs(trim): extract branch/commit rules into runbook * docs(trim): extract MCP integration into runbook * docs(adr): add 0001-base-ui-over-radix * docs(adr): add 0002-float-sort-order * docs(adr): add 0003-one-branch-per-milestone * docs(adr): add 0004-status-enum-mapping * docs(adr): add 0005-iron-session-over-nextauth * docs(adr): add 0006-demo-user-three-layer-policy * docs(adr): add 0007-claude-question-channel-design * docs(adr): add 0008-agent-instructions-in-claude-md + update README index * docs(index): regenerate after ADR 0001-0008 * docs(glossary): add docs/glossary.md * chore(docs): regenerate INDEX.md in pre-commit hook * docs(readme): link INDEX + glossary + agent instructions * feat(docs): add doc-link checker script * chore(docs): wire docs:check-links and docs npm scripts * ci(docs): block merge on broken doc links * docs(links): fix broken cross-references after restructure --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
39 KiB
| title | status | audience | language | last_updated | applies_to | |||
|---|---|---|---|---|---|---|---|---|
| M10 — Password-loze inlog via QR-pairing | active |
|
nl | 2026-05-03 |
|
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.
mobileSecretreist alleen via QR-fragment (#s=…) → mobilelocation.hash→ POST-bodydesktopTokenreist alleen via HttpOnly cookies4m_pairmetPath=/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 backlog.md § M10. Functional spec: zie functional.md § F-01b.
Implementatie-volgorde (commit-strategy uit CLAUDE.md):
- DB-laag — ST-1001 (schema + trigger)
- Auth helpers + sessie-uitbreiding — ST-1002
- API-laag — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim)
- Server actions + mobile UI — ST-1005
- Desktop UI — ST-1007
- 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 modelLoginPairing+ back-relation opUserprisma/migrations/<timestamp>_add_login_pairing/migration.sql— model + triggervendor/scrum4me-submodule in repomcp— schema-sync ná merge
Stappen
-
Schema-uitbreiding:
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. -
Migratie-SQL voegt naast de tabel ook trigger toe (mirror van
notify_solo_changeinprisma/migrations/20260426230316_add_solo_realtime_triggers/migration.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(); -
npx prisma migrate dev --name add_login_pairing.
Aandachtspunten
desktop_iphoudt op 45 tekens om IPv6 te accommoderen (xxxx:xxxx:…:255.255.255.255).- Geen index op
user_idnodig 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 mcp moet ná merge opmaindirect gesynced worden, anders breekt de wekelijkse drift-check (trig_015FFUnxjz9WMuhhWNGBQKFD). Dit was ook een aandachtspunt bij ST-901.
Verificatie
npx prisma migrate devslaagtnpx prisma validatezonder foutenpsql $DIRECT_URL -c "LISTEN scrum4me_pairing;"toont payload bijINSERT INTO login_pairings(...) VALUES(...)prisma studiotoont tabel met beide hash-kolommenNOT NULL
ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
Bestanden
lib/auth/pairing.ts— nieuw, secret/token-generatie en hash-helperslib/auth/pair-cookie.ts— nieuw, set/read/clear vans4m_pair-cookielib/session.ts—SessionDatauitbreiden metpairedenpairedExpiresAtapp/(app)/layout.tsx— extra guard op vervallen paired-sessie__tests__/lib/auth/pairing.test.ts— nieuw
Stappen
-
lib/auth/pairing.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.
-
lib/auth/pair-cookie.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/pairzorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route. -
lib/session.ts—SessionDatainterface: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.
-
app/(app)/layout.tsx— guard ná de bestaandeif (!session.userId) redirect('/login'):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". -
Tests —
__tests__/lib/auth/pairing.test.ts:generateMobileSecret()produceert 43-karakter base64url (32 bytes)hashTokenis deterministischverifyTokenis 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.tsherzien wordt (uit scope hier). cookies().delete({ name, path })moet dezelfde path specificeren als bij set, anders blijft de cookie staan.crypto.randomBytesis sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig.
Verificatie
npm run lint && npx tsc --noEmit && npm test && npm run buildgroen- Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped
document.cookieop 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— nieuwlib/rate-limit.ts— checken of bestaand (uit ST-608); anders helper toevoegen__tests__/api/pair-start.test.ts— nieuw
Stappen
-
Route Handler (vrij van
authenticateApiRequest— dit is anon):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, }) } -
Rate-limit helper — als
lib/rate-limit.tsnog 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.
- In-memory Map keyed op
-
Tests —
__tests__/api/pair-start.test.ts:- 200 response bevat
pairingId,mobileSecret,qrUrlmet fragment-syntax (#id=…&s=…) Set-Cookie-header bevats4m_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_uaendesktop_ipworden opgeslagen bij aanwezigheid, andersnull
- 200 response bevat
Aandachtspunten
mobileSecretmag 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.qrUrlgebruikt#(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 leestwindow.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 gebruikencrypto.randomBytes.
Verificatie
curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jarretourneert JSON +Set-Cookie- Body bevat
qrUrlmet#id=…&s=… - Rij in
login_pairingsheeft 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
-
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: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
-
-
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 alapprovedis voordat de SSE opent — race-conditie). -
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
pairingIdin het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs.EventSourceop de client kan geen custom headers; cookie is daarom de enige praktische auth-methode.withCredentials: trueop de client is verplicht.- Bij browser-tab-sluiten wordt
request.signal.abortgetriggered → cleanup zoals in solo-route. - Vermijd Prisma in de notification-handler; gebruik alleen
pg.Clientzoals 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: heartbeatelke 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 -Nzonder cookie → JSON 401
ST-1005 — Server actions + mobiele bevestigingspagina
Bestanden
actions/pairing.ts— nieuw, drie Server Actionsapp/(app)/m/pair/page.tsx— nieuw, Server Componentapp/(app)/m/pair/pair-confirmation.tsx— nieuw, Client Component__tests__/actions/pairing.test.ts— nieuw
Stappen
-
actions/pairing.ts— volgtdocs/patterns/server-action.md:'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. } -
app/(app)/m/pair/page.tsx— Server Component, achter bestaande(app)/layout.tsxauth-guard: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. -
app/(app)/m/pair/pair-confirmation.tsx— Client Component:'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) } -
Tests —
__tests__/actions/pairing.test.ts:getPairingForApprovalmetpending-pairing →ok: true+ ua/ip/usernamegetPairingForApprovalmet al-approved→ok: falsegetPairingForApprovalmet verlopen →ok: falsegetPairingForApprovalmet verkeerd secret →ok: falseapprovePairingals demo-user →ok: false+ DB onveranderdapprovePairinghappy path → statusapproved,user_idgezet,expires_atbumpedcancelPairinghappy path → statuscancelled
Aandachtspunten
getPairingForApprovalmág door demo-users worden aangeroepen (om iets te zien); alleenapprovePairingblokkeert demo's.pair-confirmation.tsxgebruikt geenuseSearchParams— die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien.- Na approve
window.history.replaceStatezonder de#hashzorgt dat browser-back de secret niet opnieuw onthult. revalidatePathinapprovePairingis 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 buildgroen- Handmatig: log in op telefoon-emulator → bezoek
/m/pair#id=…&s=…→ kaart toont UA/IP → Bevestig → succes-state, URL is/m/pairzonder 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
-
Route Handler:
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 }) } -
Tests:
- 200 + iron-session cookie + clear
s4m_pairna succes - 410 op tweede claim met dezelfde cookie
- 401 zonder cookie
- 401 met cookie die hasht naar andere pairing
- paired-sessie bevat
paired: trueenpairedExpiresAtrondnow + 8h
- 200 + iron-session cookie + clear
Aandachtspunten
updateManygebruiken (nietupdate) want we hebben een composite WHERE metstatus+desktop_token_hash+expires_at;updatekan alleen op unique keys.- Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in
count = 1voor één caller encount = 0voor de ander. clearPairCookieook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch —Max-Age=120regelt het ook).- De
session.isDemocheck overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maaris_demodoorzetten is een extra vangnet.
Verificatie
- Handmatig: na approve in mobiele tab, POST naar
/api/auth/pair/claimmet de cookie van start → 200 +Set-Cookie: session=... curl -X POSTzonder 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" toevoegenapp/login/qr-login-button.tsx— nieuw, Client Componentpackage.json—qrcode.reacttoevoegen__tests__/components/qr-login-button.test.tsx— minimale render-test
Stappen
-
Dependency:
npm install qrcode.react— direct independenciesper CLAUDE.md-conventie. -
qr-login-button.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 } -
app/login/page.tsx— knop toevoegen onder of naast het wachtwoord-formulier:<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/design/styling.md; geen willekeurige Tailwind-kleuren. -
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 des4m_pair-cookie meestuurt; standaard verstuurt EventSource geen credentials.- Cleanup-volgorde:
es.close()eerst, dán fetch claim. Anders kan een tweedeapproved-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.reactexporteert zowelQRCodeSVGalsQRCodeCanvas. 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 buildgroen- Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar
/dashboardmetsession.paired === true - DevTools Network-tab: geen URL bevat
s=(alleenSet-Cookie/Cookieheaders) - Speel met een verlopen QR: na 2 min toont knop "Vernieuwen",
Vernieuwenstart nieuwe pairing
ST-1008 — Documentatie + acceptatietest
Bestanden
docs/api/rest-contract.md— drie nieuwe endpointsdocs/architecture.md— sectie "QR-pairing flow" + threat-modeldocs/patterns/qr-login.md— nieuw pattern-docCLAUDE.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
-
docs/api/rest-contract.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 dats4m_paireen tijdelijke pre-auth cookie is, anders dan de iron-session cookie. -
docs/architecture.md— sectie "QR-pairing flow" met:- Sequence-diagram (mermaid of ASCII analoog aan M8)
- Threat-model:
- Replay: atomic
updateManymetstatus='approved'voorkomt dubbele claim - Phishing-QR: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist
- Demo-block:
approvePairingearly return opsession.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
- Replay: atomic
-
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.tsals sjabloon - Risico's en mitigaties
-
CLAUDE.md— in de Implementatiepatronen-tabel een rij| QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |. -
Acceptatie-scenario's (handmatig, eventueel automatiseerbaar in v2):
- Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd
- Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen
- Replay — claim de pairing twee keer → tweede call 410
- Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen"
- Expiry tussen approve en claim — approve, wacht 6 min, claim → 410
- Ontbrekende cookie op SSE/claim — verwijder
s4m_pairin DevTools, herhaal → 401 - 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 ops=-substrings; verwacht: 0 hits
Aandachtspunten
- Zorg dat de runtime-logs MCP-controle in
docs/qa/api-test-plan.mdbelandt zodat hij bij elke release herhaalbaar is. docs/patterns/qr-login.mdmag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren.
Verificatie
npm run lint && npx tsc --noEmit && npm test && npm run buildgroen- Alle zeven scenario's handmatig groen, beschreven in een test-rapport-sectie
vendor/scrum4me-submodule in mcp gesynced ná schema-merge
Branch- en commit-strategie
Per Branch & PR Strategy: éé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 buildgroen op CI- Schema-wijziging in ST-1001 → wekelijkse drift-check
trig_015FFUnxjz9WMuhhWNGBQKFDmag niet rood staan;vendor/scrum4me-submodule in 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.