Scrum4Me/app/(auth)/login/qr-login-button.tsx
Madhura68 d6e71f915c fix(ST-1007): listen for SSE 'state' event so approve-during-connect resolves
De SSE-route in ST-1004 stuurt de catch-up payload als `event: state\ndata: …`
om een race te dichten: tussen pair/start en SSE-open kan de mobiel approven,
de pg_notify fired vóór onze LISTEN actief is en gaat verloren (Postgres
queuet niet). De server compenseert door direct na connect een `state`-event
te sturen met de huidige status uit de DB.

Maar de client luisterde alleen op 'message'. EventSource routeert events met
`event: <name>` enkel naar listeners voor die exacte naam — het catch-up event
werd dus genegeerd. Gevolg bij een (zeldzame) race: QR blijft hangen tot
expiry omdat noch de notify noch de catch-up doorkomt.

Fix: dezelfde onMessage-handler ook aan 'state' binden (en netjes
unsubscriben bij cleanup). Geen server-side wijziging nodig — protocol bleef
bewust om de semantische scheiding 'initial state' vs 'live notify' te
behouden voor toekomstige clients die er onderscheid in willen maken.

Severity: middel-laag — kleine race-window, geen data/security-impact, alleen
"QR doet niks" tot user op Vernieuwen klikt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:53:50 +02:00

209 lines
6.4 KiB
TypeScript

'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.
}
// De server stuurt direct na connect een `event: state`-payload met de
// huidige pairing-status (catch-up voor de race tussen pair/start en de
// SSE-open: als de mobiel net daarvoor approvet komt de notify door
// vóórdat onze LISTEN actief is en wordt 'ie verloren). EventSource
// routeert events met `event: <name>` alleen naar listeners voor die
// naam — niet naar 'message'. Dezelfde handler aan beide hangen vangt
// de catch-up én reguliere notifies op.
es.addEventListener('message', onMessage)
es.addEventListener('state', onMessage as unknown as EventListener)
es.addEventListener('error', onError)
return () => {
es.removeEventListener('message', onMessage)
es.removeEventListener('state', onMessage as unknown as EventListener)
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>
)
}