feat(ST-1134): (mobile) route group + auth-guard helper + manifest (T-321)

- 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>
This commit is contained in:
Janpeter Visser 2026-05-04 09:55:18 +02:00
parent 47d57a0963
commit 7b32fc60e6
7 changed files with 119 additions and 18 deletions

40
app/(mobile)/layout.tsx Normal file
View file

@ -0,0 +1,40 @@
// PBI-11 / ST-1134: Mobile shell-layout. Eigen route group (mobile) — nested
// layout in (app)/ kan parent NavBar/StatusBar/MinWidthBanner niet onderdrukken.
// Auth via gedeelde requireSession() (lib/auth-guard.ts), hergebruikt door
// (app)/layout.tsx.
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { requireSession } from '@/lib/auth-guard'
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
export default async function MobileLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession()
// Active product nodig voor de tab-bar (Backlog/Solo-tabs verbergen als geen actief product).
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { active_product_id: true },
})
let activeProductId: string | null = null
if (user?.active_product_id) {
const product = await prisma.product.findFirst({
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
select: { id: true },
})
activeProductId = product?.id ?? null
}
return (
<div className="h-screen bg-background flex flex-col overflow-hidden">
<LandscapeGuard>
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0 pb-14">
{children}
</main>
<MobileTabBar activeProductId={activeProductId} />
</LandscapeGuard>
</div>
)
}

View file

@ -0,0 +1,27 @@
// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10).
//
// Server Component achter de (mobile)/layout.tsx auth-guard (route group
// (mobile) per ST-1134/PBI-11) — onbekende mobielen worden eerst naar /login
// gestuurd. Bewust géén searchParams
// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen
// client-side leesbaar is. De Client Component PairConfirmation parseert
// location.hash en doet de Server Action-calls.
import { PairConfirmation } from './pair-confirmation'
export const metadata = {
title: 'Inloggen op desktop',
}
export default function PairPage() {
return (
<main className="container mx-auto max-w-md py-12">
<h1 className="text-2xl font-semibold">Inloggen op desktop</h1>
<p className="text-muted-foreground mt-2">
Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code
toont.
</p>
<PairConfirmation />
</main>
)
}

View file

@ -0,0 +1,176 @@
'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>
)
}