feat(auth): self-contained QR-pairing login voor Ops-dashboard #90

Merged
janpeter merged 20 commits from feat/qr-pairing-login into main 2026-06-15 19:19:51 +02:00
Owner

Doel

QR-login voor het Ops-dashboard (naast email/wachtwoord): scan op /login met een telefoon die in het Ops-dashboard is ingelogd → de desktop krijgt een Ops-sessie. Self-contained binnen ops_dashboard.

Aanpak

Eigen LoginPairing-tabel in de Ops-DB; /m/pair is een publieke shell (auth in de same-origin info/approve-calls via getCurrentUser — werkt ondanks SameSite=Strict op de eerste cross-site QR-GET); claim consumeert atomair en mint een Ops-Session via createSession(…, tx) + ops_session-cookie. Geen iron-session, geen scrum4me-DB, geen role/demo-gate (één identiteitsruimte). Alle POST's via apiFetch (CSRF double-submit).

Wat is toegevoegd (additief)

  • routes app/api/auth/pair/{start,status,info,approve,claim}
  • lib/pairing.ts, lib/pair-cookie.ts, lib/qr-poll.ts; createSession(…, db=prisma) tx-param
  • LoginPairing model + PairingStatus enum + hand-authored migratie
  • proxy.ts: /m/pair publiek (slash-aware match)
  • UI: app/login/qr-login.tsx + app/m/pair/{page,pair-confirm}.tsx

Reviews & gates

  • Ontwerp (spec v2) en plan (plan v2) door codex gereviewd; beide verdicts verwerkt (publieke shell + SameSite-fix, atomic createSession(tx), PairingStatus enum, info als POST, approve expires_at-guard, proxy slash-aware, fail-fast INSTANCE_BASE_URL).
  • Adversariële review van de claim-gate: alle 7 garanties houden; holle test-assertie gefixt.
  • Pre-merge code-review door codex: alle fixes bevestigd aanwezig/correct; één desktop-poll expiry-race gevonden → gefixt (status wint van lokale expiry, pure nextPollAction + unit-test).
  • npm run typecheck && npm test groen: 121 tests (25 files).

Geen

Geen wijziging aan scrum4me-web/workers; geen scrum4me-DB-writes. Geen nieuwe env (INSTANCE_BASE_URL bestaat al).

Deploy

  • Migratie op de ops_dashboard-DB via prisma migrate deploy + prisma migrate status (Ops = migrator van zijn eigen DB).
  • Handmatige e2e (telefoon ingelogd op https://ops.jp-visser.nl) is een post-deploy check.

Spec/plan: docs/superpowers/{specs,plans}/2026-06-15-ops-dashboard-qr-pairing*.md. Scrum4Me: Sprint S-2026-06-15-1, PBI-7, Story ST-017, taken T-55…T-67.

🤖 Generated with Claude Code

## Doel QR-login voor het Ops-dashboard (naast email/wachtwoord): scan op `/login` met een telefoon die in het Ops-dashboard is ingelogd → de desktop krijgt een Ops-sessie. **Self-contained** binnen `ops_dashboard`. ## Aanpak Eigen `LoginPairing`-tabel in de Ops-DB; `/m/pair` is een **publieke shell** (auth in de same-origin `info`/`approve`-calls via `getCurrentUser` — werkt ondanks `SameSite=Strict` op de eerste cross-site QR-GET); `claim` consumeert atomair en mint een Ops-`Session` via `createSession(…, tx)` + `ops_session`-cookie. Geen iron-session, geen scrum4me-DB, geen role/demo-gate (één identiteitsruimte). Alle POST's via `apiFetch` (CSRF double-submit). ## Wat is toegevoegd (additief) - routes `app/api/auth/pair/{start,status,info,approve,claim}` - `lib/pairing.ts`, `lib/pair-cookie.ts`, `lib/qr-poll.ts`; `createSession(…, db=prisma)` tx-param - `LoginPairing` model + `PairingStatus` enum + hand-authored migratie - `proxy.ts`: `/m/pair` publiek (slash-aware match) - UI: `app/login/qr-login.tsx` + `app/m/pair/{page,pair-confirm}.tsx` ## Reviews & gates - **Ontwerp** (spec v2) en **plan** (plan v2) door **codex** gereviewd; beide verdicts verwerkt (publieke shell + SameSite-fix, atomic `createSession(tx)`, `PairingStatus` enum, `info` als POST, approve `expires_at`-guard, proxy slash-aware, fail-fast `INSTANCE_BASE_URL`). - **Adversariële review** van de claim-gate: alle 7 garanties houden; holle test-assertie gefixt. - **Pre-merge code-review** door codex: alle fixes bevestigd aanwezig/correct; één desktop-poll expiry-race gevonden → gefixt (status wint van lokale expiry, pure `nextPollAction` + unit-test). - `npm run typecheck && npm test` groen: **121 tests (25 files)**. ## Geen Geen wijziging aan scrum4me-web/workers; geen scrum4me-DB-writes. Geen nieuwe env (`INSTANCE_BASE_URL` bestaat al). ## Deploy - **Migratie** op de ops_dashboard-DB via `prisma migrate deploy` + `prisma migrate status` (Ops = migrator van zijn eigen DB). - Handmatige e2e (telefoon ingelogd op `https://ops.jp-visser.nl`) is een **post-deploy** check. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-15-ops-dashboard-qr-pairing*.md`. Scrum4Me: Sprint S-2026-06-15-1, PBI-7, Story ST-017, taken T-55…T-67. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Self-contained binnen ops_dashboard: eigen LoginPairing-migratie, eigen /m/pair approve (telefoon met ops_session), claim mint ops-Session via createSession. Cross-app verworpen (read-only scrum4me-link + identiteitsmismatch). Ops-specifiek: CSRF via apiFetch, geen proxy-wijziging, geen demo/role-gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blockers opgelost: /m/pair wordt publieke shell (SameSite=Strict laat ops_session niet mee op eerste cross-site QR-GET; auth in same-origin info/approve via getCurrentUser); createSession(userId,token,db=prisma) accepteert tx-client zodat claim consume+mint atomair is. Refinements: PairingStatus enum, info als POST (geen secret in URL), expliciete ops_pair-cookie-attributen, 403-recovery in UI. Self-contained vs cross-app bevestigd terecht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-contained: LoginPairing-migratie (hand-authored SQL + prisma generate, geen lokale DB), crypto/cookie, createSession(tx)-refactor, start/status/info/approve/claim routes, proxy /m/pair publiek, UI via Ops-stijl guard-tests (geen jsdom). Alle codex-review-punten verwerkt. Gate: npm run typecheck && npm test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claim: één response.cookies-API (set ops_session + wis ops_pair maxAge0), test op res.cookies + createSession-mock. createSession typed als Pick<typeof prisma,'session'>. Task1: prisma validate + migrate status deploy-gate. start: 500 bij ontbrekende INSTANCE_BASE_URL. approve.updateMany conditioneert op niet-verlopen. proxy public-match slash-aware (+ edge-tests /m/pair/foo, /m/pair-extra). info read-only + expired/non-pending tests. /m/pair 401-UX vereenvoudigd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plain Request is the repo convention (see messages/route.ts) and passes
TypeScript strict — NextRequest is assignable to Request but not vice-
versa, causing typecheck errors when tests pass plain Request objects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add '/m/pair' to PUBLIC_AUTH_PATHS
- Make public-path match slash-aware (pathname === p || startsWith(p + '/'))
- Limit already-logged-in redirect to /login only (not all public paths)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app/login/qr-login.tsx: client component with idle/starting/showing/claiming/error
  phases, QRCodeSVG display, polling /api/auth/pair/status every 2000ms,
  claim via apiFetch /api/auth/pair/claim on approved, router.push('/') on success
- app/login/page.tsx: add QrLogin after </form> with "of"-divider, inside card
- app/m/pair/page.tsx: public server shell, no auth guard, renders PairConfirm in card
- app/m/pair/pair-confirm.tsx: client component parsing window.location.hash,
  apiFetch /api/auth/pair/info, 401→unauthenticated state with /login link,
  Approve button for pending+!expired, apiFetch /api/auth/pair/approve → confirmed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Race: approval net vóór de oorspronkelijke QR-deadline kon op de desktop alsnog als 'verlopen' eindigen omdat de poll de lokale deadline checkte vóór de status-fetch — terwijl approve het server-side claim-window verlengt. Fix: pure nextPollAction(status, now, expiryMs) waarin approved/terminal vóór expiry gaan; poll haalt nu eerst de status op. Unit-test dekt de race + fallback-gevallen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
janpeter/Ops-dashboard!90
No description provided.