From 459c68a2c3b6f910bccb2f64c847500b7c05ebe4 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 22 Jun 2026 15:53:22 +0200 Subject: [PATCH] feat(rbac): residential-partner route lockdown + role-aware mobile nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAT (residential partners must have zero access to anything non-residential; no marina dashboard). Server-side their permission map already 403s every marina domain — this locks the client surface to match: - AppShell: a residential-only user (residential_clients.view && !clients.view, non-super-admin) is redirected off ANY non-residential route to /residential/clients. Blocks the marina dashboard + every marina page in one place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they no longer land on a marina dashboard of 403-ing empty widgets.) - Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role; now role-aware — residential-only users get Residential Clients/Interests instead of marina tabs they 403 on. (Fixes F5.) - e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real reason the smoke auth specs fail independent of the dev-server OOM. - New crash-safe `matrix` Playwright project (role×viewport access matrix + responsive overflow sweep) — lean alternative to the full suite which OOM-crashes next dev locally. Verified: matrix run shows residential_partner redirected to residential + residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- playwright.config.ts | 22 +++ src/components/layout/app-shell.tsx | 26 ++++ .../layout/mobile/mobile-bottom-tabs.tsx | 29 ++-- tests/e2e/matrix/responsive-overflow.spec.ts | 129 ++++++++++++++++ tests/e2e/matrix/role-access.spec.ts | 138 ++++++++++++++++++ tests/e2e/smoke/helpers.ts | 5 +- 6 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/matrix/responsive-overflow.spec.ts create mode 100644 tests/e2e/matrix/role-access.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 9591a0cd..54fd008a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,6 +24,28 @@ export default defineConfig({ name: 'setup', testMatch: /smoke\/global-setup\.ts/, }, + { + // Permission-matrix UX sweep. Users + roles are seeded separately via + // `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global + // setup dependency — relies on the already-seeded dev DB). + name: 'permissions', + testMatch: /permissions\/.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + }, + }, + { + // Lean role × viewport access matrix. Users pre-seeded (admin/director/ + // sales/viewer/residential_partner) — no global-setup dependency. Few + // route compilations, so it stays under the dev-server OOM threshold. + name: 'matrix', + testMatch: /matrix\/.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + }, + }, { name: 'smoke', testMatch: /smoke\/\d{2}-.*\.spec\.ts/, diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 3b550af5..fd299561 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -1,8 +1,10 @@ 'use client'; import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; import { cn } from '@/lib/utils'; +import { usePermissions } from '@/hooks/use-permissions'; import { Sidebar } from '@/components/layout/sidebar'; import { Topbar } from '@/components/layout/topbar'; import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker'; @@ -112,6 +114,30 @@ export function AppShell({ const currentPortId = useUIStore((s) => s.currentPortId); const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null; + // Residential lockdown: a residential-only user (residential access, no + // marina `clients.view`) must never see marina pages — including the marina + // dashboard. The API already 403s their data; this guard blocks the *routes*, + // redirecting any non-residential path to their residential home. Personal + // surfaces (settings, inbox) stay reachable. + const pathname = usePathname(); + const router = useRouter(); + const { can } = usePermissions(); + const residentialOnly = + !isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view'); + useEffect(() => { + if (!residentialOnly || !pathname) return; + const [portSeg, ...rest] = pathname.split('/').filter(Boolean); + const sub = rest.join('/'); + const allowed = + sub === '' || + sub.startsWith('residential') || + sub.startsWith('settings') || + sub.startsWith('inbox'); + if (!allowed && portSeg) { + router.replace(`/${portSeg}/residential/clients`); + } + }, [residentialOnly, pathname, router]); + useEffect(() => { const mqMobile = window.matchMedia(MOBILE_QUERY); const mqTablet = window.matchMedia(TABLET_QUERY); diff --git a/src/components/layout/mobile/mobile-bottom-tabs.tsx b/src/components/layout/mobile/mobile-bottom-tabs.tsx index f84506df..d5f20eb1 100644 --- a/src/components/layout/mobile/mobile-bottom-tabs.tsx +++ b/src/components/layout/mobile/mobile-bottom-tabs.tsx @@ -2,9 +2,10 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react'; +import { Anchor, ClipboardList, LayoutDashboard, Menu, Search, Users } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { usePermissions } from '@/hooks/use-permissions'; type TabSpec = { label: string; @@ -12,16 +13,21 @@ type TabSpec = { segment: string; // route segment after /[portSlug]/ }; -// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More. -// Search occupies the center slot. Documents demoted to the MoreSheet - -// reps reach docs less often than berths during a walking inventory check, -// and pinned-to-client documents are accessed via the client detail anyway. -const TABS_LEFT: TabSpec[] = [ +// Marina users: Dashboard, Clients | Berths. Search center, More right. +const MARINA_TABS_LEFT: TabSpec[] = [ { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, { label: 'Clients', icon: Users, segment: 'clients' }, ]; +const MARINA_TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }]; -const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }]; +// Residential-only users (e.g. residential partners) never have marina access, +// so the bottom tabs mirror their residential-only sidebar instead of showing +// Clients/Berths they 403 on (matches the AppShell route lockdown). +const RESIDENTIAL_TABS_LEFT: TabSpec[] = [ + { label: 'Clients', icon: Users, segment: 'residential/clients' }, + { label: 'Interests', icon: ClipboardList, segment: 'residential/interests' }, +]; +const RESIDENTIAL_TABS_RIGHT: TabSpec[] = []; interface MobileBottomTabsProps { onMoreClick: () => void; @@ -31,6 +37,11 @@ interface MobileBottomTabsProps { export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) { const pathname = usePathname(); const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; + const { can, isSuperAdmin } = usePermissions(); + const residentialOnly = + !isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view'); + const tabsLeft = residentialOnly ? RESIDENTIAL_TABS_LEFT : MARINA_TABS_LEFT; + const tabsRight = residentialOnly ? RESIDENTIAL_TABS_RIGHT : MARINA_TABS_RIGHT; function isActive(segment: string): boolean { return pathname.startsWith(`/${portSlug}/${segment}`); @@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab 'flex items-end', )} > - {TABS_LEFT.map((tab) => ( + {tabsLeft.map((tab) => ( ))} @@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab Search - {TABS_RIGHT.map((tab) => ( + {tabsRight.map((tab) => ( ))} diff --git a/tests/e2e/matrix/responsive-overflow.spec.ts b/tests/e2e/matrix/responsive-overflow.spec.ts new file mode 100644 index 00000000..c8333dc8 --- /dev/null +++ b/tests/e2e/matrix/responsive-overflow.spec.ts @@ -0,0 +1,129 @@ +/** + * Responsive overflow / cutoff sweep. + * + * Walks the key pages at desktop / tablet / mobile / small-mobile viewports and + * programmatically flags layout bugs the eye looks for on small screens: + * - horizontal overflow (document wider than the viewport → off-screen content, + * a horizontal scrollbar), + * - individual elements whose right edge runs past the viewport (clipped / + * off-screen buttons + text), + * - elements overflowing the BOTTOM of their own box (cut-off text). + * Captures a full-page screenshot per page/viewport for eyeball QC. + * + * Runs as `admin` (sees every page). Layout is role-independent, so one broad + * role surfaces the responsive issues; role-specific nav scoping is covered by + * role-access.spec.ts. + */ +import { test, expect } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const PORT = 'port-nimara'; +const OUT = join(process.cwd(), '.audit', 'responsive'); + +const ADMIN = { email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' }; + +const VIEWPORTS = [ + { name: 'desktop', width: 1440, height: 900 }, + { name: 'tablet', width: 820, height: 1180 }, + { name: 'mobile', width: 390, height: 844 }, + { name: 'small', width: 360, height: 740 }, +] as const; + +const PAGES = [ + 'dashboard', + 'clients', + 'interests', + 'inquiries', + 'berths', + 'yachts', + 'companies', + 'reports', + 'reports/financial', + 'documents', + 'expenses', + 'inbox', + 'settings', + 'admin', + 'admin/users', +]; + +test.describe('Responsive overflow sweep', () => { + test('admin — every key page at every viewport, flag overflow + cutoff', async ({ page }) => { + test.setTimeout(600_000); + mkdirSync(OUT, { recursive: true }); + + const res = await page.request.post('/api/auth/sign-in/email', { + data: { email: ADMIN.email, password: ADMIN.pw }, + headers: { 'content-type': 'application/json' }, + }); + expect(res.ok()).toBeTruthy(); + + const findings: string[] = []; + + for (const vp of VIEWPORTS) { + await page.setViewportSize({ width: vp.width, height: vp.height }); + for (const p of PAGES) { + const url = `/${PORT}/${p}`; + const slug = p.replace(/\//g, '_'); + try { + await page.goto(url, { waitUntil: 'domcontentloaded' }); + } catch { + findings.push(`NAV-FAIL ${vp.name.padEnd(7)} ${p}`); + continue; + } + // let layout settle + data paint + await page.waitForTimeout(1800); + + const report = await page.evaluate((vpWidth) => { + const docW = document.documentElement.scrollWidth; + const innerW = window.innerWidth; + const horizOverflow = docW - innerW; + // Elements whose right edge runs past the viewport by > 2px and are + // actually visible (have size, not display:none). + const offscreen: { tag: string; cls: string; right: number; text: string }[] = []; + const els = document.querySelectorAll('body *'); + for (const el of els) { + const r = (el as HTMLElement).getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + if (r.right > vpWidth + 2 && r.left < vpWidth) { + // overflowing the right edge (partially clipped) + const tag = el.tagName.toLowerCase(); + const cls = ((el as HTMLElement).className || '').toString().slice(0, 40); + const text = (el.textContent || '').trim().slice(0, 30); + offscreen.push({ tag, cls, right: Math.round(r.right), text }); + } + } + // de-dupe by tag+text, cap + const seen = new Set(); + const uniq = offscreen + .filter((o) => { + const k = `${o.tag}:${o.text}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }) + .slice(0, 6); + return { horizOverflow, docW, innerW, offscreen: uniq }; + }, vp.width); + + await page + .screenshot({ path: join(OUT, `admin-${vp.name}-${slug}.png`), fullPage: true }) + .catch(() => {}); + + const flagged = report.horizOverflow > 3 || report.offscreen.length > 0; + const line = `${flagged ? 'OVERFLOW' : 'ok '} ${vp.name.padEnd(7)} ${p.padEnd(18)} hScroll=${report.horizOverflow}px doc=${report.docW}/${report.innerW}`; + console.log(line); + if (report.offscreen.length) { + for (const o of report.offscreen) { + console.log(` ↳ off-right ${o.tag} right=${o.right} "${o.text}" .${o.cls}`); + } + } + if (flagged) findings.push(line); + } + } + + console.log(`\n=== OVERFLOW FINDINGS (${findings.length}) ===`); + for (const f of findings) console.log(f); + }); +}); diff --git a/tests/e2e/matrix/role-access.spec.ts b/tests/e2e/matrix/role-access.spec.ts new file mode 100644 index 00000000..a1208660 --- /dev/null +++ b/tests/e2e/matrix/role-access.spec.ts @@ -0,0 +1,138 @@ +/** + * Role × viewport access matrix. + * + * A LEAN, crash-safe alternative to running the full 162-test smoke suite + * (which OOM-crashes `next dev` locally). For each of the 5 core roles it: + * - logs in (UI), + * - probes a fixed set of API endpoints in the authenticated session and + * records the HTTP status (the read/permission matrix), + * - records which sidebar nav sections are visible, + * - screenshots the dashboard at desktop / tablet / mobile viewports. + * + * Few route compilations per run, so the dev server stays up. Users are + * pre-seeded (admin/director/sales/viewer/residential_partner); no global + * setup dependency. + */ +import { test, expect } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const PORT = 'port-nimara'; +const OUT = join(process.cwd(), '.audit', 'matrix'); + +const ROLES = [ + { key: 'super_admin', email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' }, + { key: 'director', email: 'director@portnimara.test', pw: 'DirectorUser12345!' }, + { key: 'sales', email: 'mpciaccio13@verizon.net', pw: 'SallySales12345!' }, + { key: 'viewer', email: 'viewer@portnimara.test', pw: 'ViewerUser12345!' }, + { key: 'residential_partner', email: 'respartner@portnimara.test', pw: 'ResPartner12345!' }, +] as const; + +const VIEWPORTS = [ + { name: 'desktop', width: 1440, height: 900 }, + { name: 'tablet', width: 820, height: 1180 }, + { name: 'mobile', width: 390, height: 844 }, +] as const; + +// GET probes — expected status varies by role; we just record what we get. +const PROBES: { label: string; path: string }[] = [ + { label: 'clients.view', path: '/api/v1/clients?limit=1' }, + { label: 'interests.view', path: '/api/v1/interests?limit=1' }, + { label: 'yachts.view', path: '/api/v1/yachts?limit=1' }, + { label: 'reports.financial', path: '/api/v1/reports/financial' }, + { label: 'alerts(interests.view)', path: '/api/v1/alerts?status=open' }, + { label: 'residential.clients', path: '/api/v1/residential/clients?limit=1' }, + { label: 'admin.users', path: '/api/v1/admin/users' }, + { label: 'admin.audit', path: '/api/v1/admin/audit?limit=1' }, + { label: 'admin.onboarding', path: '/api/v1/admin/onboarding/status' }, +]; + +async function login(page: import('@playwright/test').Page, email: string, pw: string) { + // Authenticate via the API (better-auth sign-in) rather than the UI: the + // dev-mode login page hydrates slowly and a pre-hydration click submits the + // form as a native GET. page.request shares the cookie jar with the page + // context, so after this the page's navigations + fetches are authenticated. + const res = await page.request.post('/api/auth/sign-in/email', { + data: { email, password: pw }, + headers: { 'content-type': 'application/json' }, + }); + if (!res.ok()) { + throw new Error(`API login failed for ${email}: ${res.status()} ${await res.text()}`); + } +} + +test.describe('Role × viewport access matrix', () => { + // Independent tests — a flake in one role must not skip the others. + for (const role of ROLES) { + test(`${role.key} — access matrix + nav + viewport renders`, async ({ page }) => { + mkdirSync(OUT, { recursive: true }); + test.setTimeout(120_000); + + await login(page, role.email, role.pw); + + // Land on the app (authenticated via the shared cookie) so in-page + // fetch() has the right origin + the sidebar nav is present. + await page.goto(`/${PORT}/dashboard`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + + // 1. API access matrix (authenticated fetch in-page). Non-super-admins + // need the X-Port-Id header (apiFetch adds it) or every route 400s on + // "Port context required" — resolve it via /me/ports first. + const matrix = await page.evaluate(async (probes) => { + let portId = ''; + try { + const pr = await fetch('/api/v1/me/ports', { headers: { accept: 'application/json' } }); + const pj = (await pr.json()) as { data?: { id: string; slug: string }[] }; + portId = + (pj.data ?? []).find((p) => p.slug === 'port-nimara')?.id ?? + (pj.data ?? [])[0]?.id ?? + ''; + } catch { + /* leave empty */ + } + const out: Record = { _port: portId ? 'ok' : 'MISSING' }; + for (const p of probes) { + try { + const r = await fetch(p.path, { + headers: { accept: 'application/json', 'X-Port-Id': portId }, + }); + out[p.label] = r.status; + } catch { + out[p.label] = -1; + } + } + return out; + }, PROBES); + + // 2. Visible nav sections + const nav = await page.evaluate(() => + [...document.querySelectorAll('nav a')].map((a) => a.getAttribute('href')).filter(Boolean), + ); + const hasAdminNav = nav.some((h) => h?.includes('/admin')); + const hasResidentialNav = nav.some((h) => h?.includes('/residential')); + + console.log(`\n=== ROLE: ${role.key} ===`); + console.log(' access:', JSON.stringify(matrix)); + console.log( + ` nav: adminSection=${hasAdminNav} residentialSection=${hasResidentialNav} count=${nav.length}`, + ); + + // 3. Viewport renders — dashboard + clients at each size + for (const vp of VIEWPORTS) { + await page.setViewportSize({ width: vp.width, height: vp.height }); + for (const path of [`/${PORT}/dashboard`, `/${PORT}/clients`]) { + const slug = path.split('/').pop(); + const resp = await page.goto(path, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(800); + await page + .screenshot({ path: join(OUT, `${role.key}-${vp.name}-${slug}.png`), fullPage: false }) + .catch(() => {}); + console.log(` render ${vp.name} ${slug}: http=${resp?.status()}`); + } + } + + // Sanity: every role can at least reach its landing without a hard error. + expect(matrix['clients.view'] === 200 || matrix['residential.clients'] === 200).toBeTruthy(); + }); + } +}); diff --git a/tests/e2e/smoke/helpers.ts b/tests/e2e/smoke/helpers.ts index 35f41f95..3e4bdf40 100644 --- a/tests/e2e/smoke/helpers.ts +++ b/tests/e2e/smoke/helpers.ts @@ -25,9 +25,10 @@ export async function login(page: Page, role: keyof typeof USERS = 'super_admin' const user = USERS[role]; await page.goto('/login'); - await page.waitForSelector('#email', { state: 'visible' }); + // The email/username field id is `identifier` (accepts either). + await page.waitForSelector('#identifier', { state: 'visible' }); - await page.fill('#email', user.email); + await page.fill('#identifier', user.email); await page.fill('#password', user.password); await page.click('button[type="submit"]');