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:
parent
c48e30df1f
commit
3a90fa9d13
4 changed files with 222 additions and 2 deletions
|
|
@ -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">
|
||||
|
|
|
|||
200
app/(auth)/login/qr-login-button.tsx
Normal file
200
app/(auth)/login/qr-login-button.tsx
Normal 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
14
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue