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"]');