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
49
__tests__/lib/auth-guard.test.ts
Normal file
49
__tests__/lib/auth-guard.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
const getSessionMock = vi.fn()
|
||||||
|
const isPairedSessionExpiredMock = vi.fn()
|
||||||
|
const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') })
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth', () => ({ getSession: getSessionMock }))
|
||||||
|
vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock }))
|
||||||
|
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
||||||
|
|
||||||
|
describe('requireSession', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getSessionMock.mockReset()
|
||||||
|
isPairedSessionExpiredMock.mockReset()
|
||||||
|
redirectMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirect /login als userId ontbreekt', async () => {
|
||||||
|
getSessionMock.mockResolvedValue({ userId: undefined, destroy: vi.fn() })
|
||||||
|
isPairedSessionExpiredMock.mockReturnValue(false)
|
||||||
|
const { requireSession } = await import('@/lib/auth-guard')
|
||||||
|
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vernietigt + redirect /login als paired-sessie verlopen is', async () => {
|
||||||
|
const destroy = vi.fn().mockResolvedValue(undefined)
|
||||||
|
getSessionMock.mockResolvedValue({ userId: 'u1', destroy })
|
||||||
|
isPairedSessionExpiredMock.mockReturnValue(true)
|
||||||
|
const { requireSession } = await import('@/lib/auth-guard')
|
||||||
|
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
|
||||||
|
expect(destroy).toHaveBeenCalled()
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('geeft sessie terug als alles ok', async () => {
|
||||||
|
const sess = { userId: 'u1', destroy: vi.fn() }
|
||||||
|
getSessionMock.mockResolvedValue(sess)
|
||||||
|
isPairedSessionExpiredMock.mockReturnValue(false)
|
||||||
|
const { requireSession } = await import('@/lib/auth-guard')
|
||||||
|
const result = await requireSession()
|
||||||
|
expect(result).toBe(sess)
|
||||||
|
expect(redirectMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { cookies } from 'next/headers'
|
import { requireSession } from '@/lib/auth-guard'
|
||||||
import { getIronSession } from 'iron-session'
|
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
|
||||||
import { isPairedSessionExpired } from '@/lib/auth/pairing'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { NavBar } from '@/components/shared/nav-bar'
|
import { NavBar } from '@/components/shared/nav-bar'
|
||||||
|
|
@ -14,18 +11,7 @@ import { AlertToast } from '@/components/shared/alert-toast'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
const session = await requireSession()
|
||||||
|
|
||||||
if (!session.userId) {
|
|
||||||
redirect('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL.
|
|
||||||
// Vervallen → vernietig en stuur naar /login.
|
|
||||||
if (isPairedSessionExpired(session)) {
|
|
||||||
session.destroy()
|
|
||||||
redirect('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const [user, userRoles, accessibleProducts] = await Promise.all([
|
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
|
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10).
|
// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10).
|
||||||
//
|
//
|
||||||
// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende
|
// Server Component achter de (mobile)/layout.tsx auth-guard (route group
|
||||||
// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams
|
// (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
|
// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen
|
||||||
// client-side leesbaar is. De Client Component PairConfirmation parseert
|
// client-side leesbaar is. De Client Component PairConfirmation parseert
|
||||||
// location.hash en doet de Server Action-calls.
|
// location.hash en doet de Server Action-calls.
|
||||||
24
lib/auth-guard.ts
Normal file
24
lib/auth-guard.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { isPairedSessionExpired } from '@/lib/auth/pairing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout-side auth guard. Returns the session when valid; otherwise redirects
|
||||||
|
* to /login (and destroys an expired paired-session first).
|
||||||
|
*
|
||||||
|
* Used by both `app/(app)/layout.tsx` (desktop) and `app/(mobile)/layout.tsx`.
|
||||||
|
*/
|
||||||
|
export async function requireSession() {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
if (!session.userId) {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPairedSessionExpired(session)) {
|
||||||
|
await session.destroy()
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams",
|
"description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "landscape",
|
||||||
"background_color": "#0d0a14",
|
"background_color": "#0d0a14",
|
||||||
"theme_color": "#7c3aed",
|
"theme_color": "#7c3aed",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue