feat(rbac): residential-partner route lockdown + role-aware mobile nav
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) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,28 @@ export default defineConfig({
|
|||||||
name: 'setup',
|
name: 'setup',
|
||||||
testMatch: /smoke\/global-setup\.ts/,
|
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',
|
name: 'smoke',
|
||||||
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
import { Topbar } from '@/components/layout/topbar';
|
||||||
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
|
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
|
||||||
@@ -112,6 +114,30 @@ export function AppShell({
|
|||||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||||
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
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(() => {
|
useEffect(() => {
|
||||||
const mqMobile = window.matchMedia(MOBILE_QUERY);
|
const mqMobile = window.matchMedia(MOBILE_QUERY);
|
||||||
const mqTablet = window.matchMedia(TABLET_QUERY);
|
const mqTablet = window.matchMedia(TABLET_QUERY);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 { cn } from '@/lib/utils';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
|
||||||
type TabSpec = {
|
type TabSpec = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,16 +13,21 @@ type TabSpec = {
|
|||||||
segment: string; // route segment after /[portSlug]/
|
segment: string; // route segment after /[portSlug]/
|
||||||
};
|
};
|
||||||
|
|
||||||
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
|
// Marina users: Dashboard, Clients | Berths. Search center, More right.
|
||||||
// Search occupies the center slot. Documents demoted to the MoreSheet -
|
const MARINA_TABS_LEFT: TabSpec[] = [
|
||||||
// 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[] = [
|
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
{ 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 {
|
interface MobileBottomTabsProps {
|
||||||
onMoreClick: () => void;
|
onMoreClick: () => void;
|
||||||
@@ -31,6 +37,11 @@ interface MobileBottomTabsProps {
|
|||||||
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
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 {
|
function isActive(segment: string): boolean {
|
||||||
return pathname.startsWith(`/${portSlug}/${segment}`);
|
return pathname.startsWith(`/${portSlug}/${segment}`);
|
||||||
@@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
|||||||
'flex items-end',
|
'flex items-end',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TABS_LEFT.map((tab) => (
|
{tabsLeft.map((tab) => (
|
||||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
|
|||||||
<span className="relative font-medium">Search</span>
|
<span className="relative font-medium">Search</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{TABS_RIGHT.map((tab) => (
|
{tabsRight.map((tab) => (
|
||||||
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
129
tests/e2e/matrix/responsive-overflow.spec.ts
Normal file
129
tests/e2e/matrix/responsive-overflow.spec.ts
Normal file
@@ -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<string>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
tests/e2e/matrix/role-access.spec.ts
Normal file
138
tests/e2e/matrix/role-access.spec.ts
Normal file
@@ -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<string, number | string> = { _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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,9 +25,10 @@ export async function login(page: Page, role: keyof typeof USERS = 'super_admin'
|
|||||||
const user = USERS[role];
|
const user = USERS[role];
|
||||||
|
|
||||||
await page.goto('/login');
|
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.fill('#password', user.password);
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user