feat(ST-1007): add QR login button on /login with SSE listener

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/<id>', { 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: <details> 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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 23:21:10 +02:00
parent c48e30df1f
commit 3a90fa9d13
4 changed files with 222 additions and 2 deletions

View file

@ -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() {
<div className="bg-surface-container-low rounded-xl p-6 space-y-4 border border-border">
<AuthForm action={loginAction} submitLabel="Inloggen" />
{/* M10 — Inloggen via mobiel zonder wachtwoord */}
<div className="flex items-center gap-2 py-1">
<div className="border-border h-px flex-1 border-t" />
<span className="text-muted-foreground text-xs">of</span>
<div className="border-border h-px flex-1 border-t" />
</div>
<QrLoginButton />
<div className="text-center text-sm text-muted-foreground">
Nog geen account?{' '}
<Link href="/register" className="text-primary hover:underline font-medium">

View file

@ -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<Phase>({ kind: 'idle' })
const sseRef = useRef<EventSource | null>(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 (
<Button
type="button"
variant="outline"
className="w-full"
onClick={start}
disabled={phase.kind === 'starting'}
>
{phase.kind === 'starting' ? 'Bezig…' : 'Inloggen via mobiel'}
</Button>
)
}
if (phase.kind === 'expired') {
return (
<div className="space-y-3 text-center">
<p className="text-muted-foreground text-sm">
QR-code verlopen. Maak een nieuwe aan om opnieuw te proberen.
</p>
<Button type="button" variant="outline" className="w-full" onClick={start}>
Vernieuwen
</Button>
</div>
)
}
if (phase.kind === 'claiming') {
return (
<div className="text-center text-sm" aria-live="polite">
Inloggen
</div>
)
}
// phase.kind === 'showing'
const minutes = Math.floor(secondsLeft / 60)
const seconds = String(secondsLeft % 60).padStart(2, '0')
return (
<div className="space-y-3">
<div className="bg-surface-container-low flex flex-col items-center gap-3 rounded-xl border border-border p-4">
<QRCodeSVG
value={phase.qrUrl}
size={200}
level="M"
aria-label="QR-code voor inloggen via mobiel"
/>
<p className="text-muted-foreground text-xs" aria-live="polite">
Vervalt over {minutes}:{seconds}
</p>
</div>
<details className="text-muted-foreground text-xs">
<summary className="cursor-pointer">Werkt scannen niet? Toon link</summary>
<p className="mt-2 break-all font-mono text-[11px]">{phase.qrUrl}</p>
</details>
<p className="text-muted-foreground text-center text-xs">
Scan met een telefoon waar je al ingelogd bent.
</p>
</div>
)
}

14
package-lock.json generated
View file

@ -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",

View file

@ -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",