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 */}
+
+
+
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",