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>
209 lines
6.4 KiB
TypeScript
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>
|
|
)
|
|
}
|