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:
parent
47d57a0963
commit
7b32fc60e6
7 changed files with 119 additions and 18 deletions
40
app/(mobile)/layout.tsx
Normal file
40
app/(mobile)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
app/(mobile)/m/pair/page.tsx
Normal file
27
app/(mobile)/m/pair/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
app/(mobile)/m/pair/pair-confirmation.tsx
Normal file
176
app/(mobile)/m/pair/pair-confirmation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue