From 3a90fa9d1328026968df68aea6c6c76c95b83726 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 23:21:10 +0200 Subject: [PATCH] feat(ST-1007): add QR login button on /login with SSE listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voltooit de desktop-zijde van de QR-pairing-flow. Gebruiker klikt "Inloggen via mobiel" naast het wachtwoord-formulier → krijgt een QR-code → telefoon scant en bevestigt → desktop wordt automatisch ingelogd zonder dat er ooit een wachtwoord is getypt op het publieke apparaat. app/(auth)/login/qr-login-button.tsx (Client Component): - Phase-state: idle | starting | showing | expired | claiming - klik → POST /api/auth/pair/start (credentials:'same-origin' voor s4m_pair) - QRCodeSVG met fragment-URL als value (level=M, 200px); aria-label - EventSource('/api/auth/pair/stream/', { withCredentials: true }) vereist voor cookie-auth — standaard verstuurt EventSource geen credentials - bij data.status === 'approved': es.close → POST /pair/claim → router.push('/dashboard') - aftellende timer (mm:ss); bij 0s → 'expired' state met Vernieuwen-knop - cleanup bij unmount: removeEventListener + close - A11y:
sectie toont fragment-URL als kopieerbare tekst voor screenreaders en gebruikers zonder camera app/(auth)/login/page.tsx: QrLoginButton onder het bestaande wachtwoord-form met "of"-divider, achter de bestaande surface-container-low styling. Dependency: qrcode.react ^4.2.0 (client-side SVG; geen extra round-trip; mobileSecret blijft op desktop in JS-geheugen). Quality gates: lint 0 errors, tsc clean, vitest 139/139, next build slaagt (login-route static, m/pair en pair/* dynamic). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(auth)/login/page.tsx | 9 ++ app/(auth)/login/qr-login-button.tsx | 200 +++++++++++++++++++++++++++ package-lock.json | 14 +- package.json | 1 + 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 app/(auth)/login/qr-login-button.tsx diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index bd8650f..4a225f9 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { loginAction } from '@/actions/auth' import { AuthForm } from '@/components/auth/auth-form' +import { QrLoginButton } from './qr-login-button' export default function LoginPage() { return ( @@ -17,6 +18,14 @@ export default function LoginPage() {
+ {/* M10 — Inloggen via mobiel zonder wachtwoord */} +
+
+ of +
+
+ +
Nog geen account?{' '} diff --git a/app/(auth)/login/qr-login-button.tsx b/app/(auth)/login/qr-login-button.tsx new file mode 100644 index 0000000..8869243 --- /dev/null +++ b/app/(auth)/login/qr-login-button.tsx @@ -0,0 +1,200 @@ +'use client' + +// ST-1007: Desktop-UI voor de QR-pairing-flow (M10). +// +// Klikt → POST /pair/start (cookie + body) → render QR die fragment-URL bevat +// → EventSource luistert naar /pair/stream/[id] met s4m_pair-cookie → bij +// approved-event POST /pair/claim → router.push('/dashboard'). +// +// mobileSecret blijft in JS-memory en in het QR-fragment; wordt nooit naar +// de server gestuurd vanuit deze browser. desktopToken zit alleen in de +// HttpOnly s4m_pair-cookie. fetch en EventSource sturen die cookie automatisch +// mee binnen de Path=/api/auth/pair-scope. + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { QRCodeSVG } from 'qrcode.react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' + +type Phase = + | { kind: 'idle' } + | { kind: 'starting' } + | { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number } + | { kind: 'expired' } + | { kind: 'claiming' } + +interface StartResponse { + pairingId: string + mobileSecret: string + expiresAt: string + qrUrl: string +} + +interface StreamMessage { + op?: 'I' | 'U' + status?: 'pending' | 'approved' | 'consumed' | 'cancelled' + pairing_id?: string +} + +export function QrLoginButton() { + const router = useRouter() + const [phase, setPhase] = useState({ kind: 'idle' }) + const sseRef = useRef(null) + const [secondsLeft, setSecondsLeft] = useState(0) + + async function start() { + setPhase({ kind: 'starting' }) + try { + const res = await fetch('/api/auth/pair/start', { + method: 'POST', + credentials: 'same-origin', + }) + if (!res.ok) throw new Error(`pair/start ${res.status}`) + const data = (await res.json()) as StartResponse + setPhase({ + kind: 'showing', + pairingId: data.pairingId, + qrUrl: data.qrUrl, + expiresAt: new Date(data.expiresAt).getTime(), + }) + } catch { + toast.error('Kon QR-code niet aanmaken — probeer opnieuw') + setPhase({ kind: 'idle' }) + } + } + + // Open SSE-stream zodra we in 'showing' zijn + useEffect(() => { + if (phase.kind !== 'showing') return + + const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, { + withCredentials: true, + }) + sseRef.current = es + + const onMessage = async (ev: MessageEvent) => { + let data: StreamMessage + try { + data = JSON.parse(ev.data) as StreamMessage + } catch { + return + } + if (data.status !== 'approved') return + + // Approved! Sluit SSE en claim de sessie + es.close() + sseRef.current = null + setPhase({ kind: 'claiming' }) + try { + 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) throw new Error(`pair/claim ${res.status}`) + router.push('/dashboard') + } catch { + toast.error('Inloggen mislukt — probeer opnieuw') + setPhase({ kind: 'idle' }) + } + } + + const onError = () => { + // EventSource probeert zelf opnieuw te verbinden bij netwerk-glitches. + // Geen actie nodig tenzij we definitief willen falen. + } + + es.addEventListener('message', onMessage) + es.addEventListener('error', onError) + + return () => { + es.removeEventListener('message', onMessage) + es.removeEventListener('error', onError) + es.close() + sseRef.current = null + } + }, [phase, router]) + + // Aftellen + auto-expire + useEffect(() => { + if (phase.kind !== 'showing') return + + const tick = () => { + const remaining = Math.max(0, Math.ceil((phase.expiresAt - Date.now()) / 1000)) + setSecondsLeft(remaining) + if (remaining === 0) { + sseRef.current?.close() + sseRef.current = null + setPhase({ kind: 'expired' }) + } + } + + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [phase]) + + if (phase.kind === 'idle' || phase.kind === 'starting') { + return ( + + ) + } + + if (phase.kind === 'expired') { + return ( +
+

+ QR-code verlopen. Maak een nieuwe aan om opnieuw te proberen. +

+ +
+ ) + } + + if (phase.kind === 'claiming') { + return ( +
+ Inloggen… +
+ ) + } + + // phase.kind === 'showing' + const minutes = Math.floor(secondsLeft / 60) + const seconds = String(secondsLeft % 60).padStart(2, '0') + + return ( +
+
+ +

+ Vervalt over {minutes}:{seconds} +

+
+
+ Werkt scannen niet? Toon link +

{phase.qrUrl}

+
+

+ Scan met een telefoon waar je al ingelogd bent. +

+
+ ) +} diff --git a/package-lock.json b/package-lock.json index bb7cc0c..ccbe18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me", - "version": "0.3.1", + "version": "0.4.0", "hasInstallScript": true, "dependencies": { "@base-ui/react": "^1.4.1", @@ -27,6 +27,7 @@ "next-themes": "^0.4.6", "pg": "^8.20.0", "prisma": "^7.8.0", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.4.0", @@ -13763,6 +13764,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", diff --git a/package.json b/package.json index 8e61cd9..a8f39df 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "next-themes": "^0.4.6", "pg": "^8.20.0", "prisma": "^7.8.0", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.4.0",