- lib/auth-guard.ts (nieuw): requireSession() — gedeelde auth+paired-expiry guard, hergebruikt door (app)/layout.tsx - (app)/layout.tsx: refactor naar requireSession() (gedraagt zich identiek) - (mobile)/layout.tsx (nieuw): minimal layout met LandscapeGuard + MobileTabBar; geen NavBar/StatusBar/MinWidthBanner/bridges - /m/pair filesystem-move van (app)/ naar (mobile)/ — URL onveranderd - public/manifest.json: orientation landscape - Tests: requireSession-helper (3 paden) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
'use client'
|
|
|
|
// ST-1005: Mobiele bevestigings-island voor de QR-pairing-flow (M10).
|
|
//
|
|
// De QR-URL is /m/pair#id=…&s=… — de fragment wordt door browsers nooit naar
|
|
// de server gestuurd, dus alleen client-side leesbaar via location.hash. Hier
|
|
// halen we 'm op, doen via Server Action de bevestigings-roundtrip, en wissen
|
|
// de hash zodra de approve gelukt is zodat back/forward de secret niet meer
|
|
// onthult.
|
|
|
|
import { useEffect, useState, useTransition } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
getPairingForApproval,
|
|
approvePairing,
|
|
cancelPairing,
|
|
} from '@/actions/pairing'
|
|
|
|
type State =
|
|
| { kind: 'loading' }
|
|
| { kind: 'invalid'; error: string }
|
|
| {
|
|
kind: 'ready'
|
|
pairingId: string
|
|
mobileSecret: string
|
|
desktop_ua: string | null
|
|
desktop_ip: string | null
|
|
username: string
|
|
}
|
|
| { kind: 'approved'; username: string }
|
|
| { kind: 'cancelled' }
|
|
|
|
function parseHash(): { id: string; s: string } | null {
|
|
if (typeof window === 'undefined') return null
|
|
const raw = window.location.hash.replace(/^#/, '')
|
|
if (!raw) return null
|
|
const params = new URLSearchParams(raw)
|
|
const id = params.get('id')
|
|
const s = params.get('s')
|
|
return id && s ? { id, s } : null
|
|
}
|
|
|
|
function clearHash() {
|
|
if (typeof window === 'undefined') return
|
|
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
|
}
|
|
|
|
export function PairConfirmation() {
|
|
const [state, setState] = useState<State>({ kind: 'loading' })
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
useEffect(() => {
|
|
const parsed = parseHash()
|
|
if (!parsed) {
|
|
queueMicrotask(() => {
|
|
setState({ kind: 'invalid', error: 'Ongeldige of ontbrekende pairing-link' })
|
|
})
|
|
return
|
|
}
|
|
void getPairingForApproval(parsed.id, parsed.s).then((res) => {
|
|
if (!res.ok) {
|
|
setState({ kind: 'invalid', error: res.error })
|
|
return
|
|
}
|
|
setState({
|
|
kind: 'ready',
|
|
pairingId: parsed.id,
|
|
mobileSecret: parsed.s,
|
|
desktop_ua: res.desktop_ua,
|
|
desktop_ip: res.desktop_ip,
|
|
username: res.username,
|
|
})
|
|
})
|
|
}, [])
|
|
|
|
function onApprove() {
|
|
if (state.kind !== 'ready') return
|
|
startTransition(async () => {
|
|
const res = await approvePairing(state.pairingId, state.mobileSecret)
|
|
if (!res.ok) {
|
|
toast.error(res.error)
|
|
return
|
|
}
|
|
clearHash()
|
|
setState({ kind: 'approved', username: state.username })
|
|
})
|
|
}
|
|
|
|
function onCancel() {
|
|
if (state.kind !== 'ready') return
|
|
startTransition(async () => {
|
|
const res = await cancelPairing(state.pairingId, state.mobileSecret)
|
|
if (!res.ok) {
|
|
toast.error(res.error)
|
|
return
|
|
}
|
|
clearHash()
|
|
setState({ kind: 'cancelled' })
|
|
})
|
|
}
|
|
|
|
if (state.kind === 'loading') {
|
|
return (
|
|
<div className="text-muted-foreground mt-6 text-sm" aria-live="polite">
|
|
Pairing controleren…
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state.kind === 'invalid') {
|
|
return (
|
|
<div className="bg-error-container text-error-container-foreground border-error mt-6 rounded-md border-l-4 p-4">
|
|
<p className="font-medium">Kan deze QR-code niet gebruiken</p>
|
|
<p className="text-sm opacity-90">{state.error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state.kind === 'approved') {
|
|
return (
|
|
<div className="bg-success-container text-success-container-foreground border-success mt-6 rounded-md border-l-4 p-4">
|
|
<p className="font-medium">Klaar — je kunt deze tab sluiten.</p>
|
|
<p className="text-sm opacity-90">
|
|
Het apparaat met de QR-code is nu ingelogd als <strong>{state.username}</strong>.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state.kind === 'cancelled') {
|
|
return (
|
|
<div className="bg-surface-container-high text-foreground mt-6 rounded-md p-4">
|
|
<p className="font-medium">Geannuleerd</p>
|
|
<p className="text-muted-foreground text-sm">
|
|
Er is geen sessie aangemaakt op het andere apparaat.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bg-card mt-6 rounded-md border p-4">
|
|
<p>
|
|
Wil je inloggen als <strong>{state.username}</strong> op dit apparaat?
|
|
</p>
|
|
<dl className="text-muted-foreground mt-3 space-y-1 text-sm">
|
|
<div className="flex gap-2">
|
|
<dt className="w-16 shrink-0">Browser:</dt>
|
|
<dd className="font-mono text-xs">{state.desktop_ua ?? 'onbekend'}</dd>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<dt className="w-16 shrink-0">IP:</dt>
|
|
<dd className="font-mono text-xs">{state.desktop_ip ?? 'onbekend'}</dd>
|
|
</div>
|
|
</dl>
|
|
<p className="text-muted-foreground mt-3 text-xs">
|
|
Bevestig alleen als je deze QR-code zelf op een eigen scherm ziet — geen
|
|
screenshot of foto van iemand anders.
|
|
</p>
|
|
<div className="mt-4 flex gap-2">
|
|
<Button onClick={onApprove} disabled={pending} className="flex-1">
|
|
Bevestig
|
|
</Button>
|
|
<Button
|
|
onClick={onCancel}
|
|
disabled={pending}
|
|
variant="secondary"
|
|
className="flex-1"
|
|
>
|
|
Annuleer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|