From 7b32fc60e60f28726473a788334183c07f3fad33 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 09:55:18 +0200 Subject: [PATCH] feat(ST-1134): (mobile) route group + auth-guard helper + manifest (T-321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- __tests__/lib/auth-guard.test.ts | 49 +++++++++++++++++++ app/(app)/layout.tsx | 18 +------ app/(mobile)/layout.tsx | 40 +++++++++++++++ app/{(app) => (mobile)}/m/pair/page.tsx | 5 +- .../m/pair/pair-confirmation.tsx | 0 lib/auth-guard.ts | 24 +++++++++ public/manifest.json | 1 + 7 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 __tests__/lib/auth-guard.test.ts create mode 100644 app/(mobile)/layout.tsx rename app/{(app) => (mobile)}/m/pair/page.tsx (79%) rename app/{(app) => (mobile)}/m/pair/pair-confirmation.tsx (100%) create mode 100644 lib/auth-guard.ts diff --git a/__tests__/lib/auth-guard.test.ts b/__tests__/lib/auth-guard.test.ts new file mode 100644 index 0000000..b162921 --- /dev/null +++ b/__tests__/lib/auth-guard.test.ts @@ -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() + }) +}) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 384828b..8f55e55 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,8 +1,5 @@ import { redirect } from 'next/navigation' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { SessionData, sessionOptions } from '@/lib/session' -import { isPairedSessionExpired } from '@/lib/auth/pairing' +import { requireSession } from '@/lib/auth-guard' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import { NavBar } from '@/components/shared/nav-bar' @@ -14,18 +11,7 @@ import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' export default async function AppLayout({ children }: { children: React.ReactNode }) { - const session = await getIronSession(await cookies(), sessionOptions) - - 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 session = await requireSession() const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ diff --git a/app/(mobile)/layout.tsx b/app/(mobile)/layout.tsx new file mode 100644 index 0000000..2f067dc --- /dev/null +++ b/app/(mobile)/layout.tsx @@ -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 ( +
+ +
+ {children} +
+ +
+
+ ) +} diff --git a/app/(app)/m/pair/page.tsx b/app/(mobile)/m/pair/page.tsx similarity index 79% rename from app/(app)/m/pair/page.tsx rename to app/(mobile)/m/pair/page.tsx index ee665c2..ba7430a 100644 --- a/app/(app)/m/pair/page.tsx +++ b/app/(mobile)/m/pair/page.tsx @@ -1,7 +1,8 @@ // ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10). // -// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende -// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams +// 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. diff --git a/app/(app)/m/pair/pair-confirmation.tsx b/app/(mobile)/m/pair/pair-confirmation.tsx similarity index 100% rename from app/(app)/m/pair/pair-confirmation.tsx rename to app/(mobile)/m/pair/pair-confirmation.tsx diff --git a/lib/auth-guard.ts b/lib/auth-guard.ts new file mode 100644 index 0000000..8b6baf5 --- /dev/null +++ b/lib/auth-guard.ts @@ -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 +} diff --git a/public/manifest.json b/public/manifest.json index b21a14f..aeb6cf0 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -4,6 +4,7 @@ "description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams", "start_url": "/dashboard", "display": "standalone", + "orientation": "landscape", "background_color": "#0d0a14", "theme_color": "#7c3aed", "icons": [