Files
pn-new-crm/src/components/layout/app-shell.tsx
Matt 459c68a2c3
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m0s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
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>
2026-06-22 15:53:22 +02:00

300 lines
12 KiB
TypeScript

'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';
import { MobileLayoutProvider } from '@/components/layout/mobile/mobile-layout-provider';
import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar';
import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs';
import { MoreSheet } from '@/components/layout/mobile/more-sheet';
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useUIStore } from '@/stores/ui-store';
type SidebarProps = ComponentProps<typeof Sidebar>;
type TopbarProps = ComponentProps<typeof Topbar>;
interface AppShellProps {
portRoles: SidebarProps['portRoles'];
isSuperAdmin: boolean;
user: NonNullable<SidebarProps['user']>;
ports: TopbarProps['ports'];
/** Per-port logo URLs resolved server-side. Sidebar picks the entry
* matching the currently-active port from the UI store. */
portLogoUrls: Record<string, string | null>;
/** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies
* sidebar entry SSR-side so the nav doesn't flicker in/out. */
tenanciesModuleByPort: Record<string, boolean>;
/** Per-port `expenses_module_enabled` resolution. Gates the Expenses
* + How-to-upload-receipts sidebar entries SSR-side. Defaults to
* true so existing ports keep the feature. */
expensesModuleByPort: Record<string, boolean>;
/** Per-port `residential_module_enabled` resolution. Gates the
* "Residential" sidebar section + mobile entry SSR-side. Defaults to
* true so existing ports keep the feature. */
residentialModuleByPort: Record<string, boolean>;
/**
* Server-rendered form-factor hint (from the request User-Agent). The
* shell mounts the matching tree on first render so we never paint the
* wrong shell, and only switches if the runtime viewport matchMedia
* disagrees (e.g. desktop browser resized below lg).
*/
initialFormFactor: 'mobile' | 'desktop';
children: ReactNode;
}
// Three tiers, aligned with Tailwind and the `useViewportTier` hook:
// mobile : < 768px (sm and below) → mobile shell (bottom tabs, slide-over more sheet)
// tablet : 768-1023 (md) → desktop shell, sidebar wrapped in Sheet, logo trigger in topbar
// desktop : >= 1024 (lg and up) → desktop shell, sidebar always visible
//
// Previously the app used a binary `(max-width: 1023.98px)` split, which
// rendered the mobile shell on iPad portrait AND on a half-screen 13"
// browser. Neither is really "mobile"; the tablet tier fills that gap.
const MOBILE_QUERY = '(max-width: 767.98px)';
const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)';
type Tier = 'mobile' | 'tablet' | 'desktop';
function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier {
// Server UA classification is binary; we can only distinguish "looks
// like a phone/tablet UA" from "looks like a desktop UA." Tablets get
// classified as 'mobile' by the UA token check (iPad/Android), so
// initialFormFactor=mobile on first paint covers both mobile + tablet.
// The matchMedia subscription below corrects to the precise tier on
// hydration without a flash because useSyncExternalStore is mismatch-safe.
return initialFormFactor === 'mobile' ? 'mobile' : 'desktop';
}
/**
* #26 + H-09: single-tree responsive shell with stable children subtree.
*
* The shell renders ONE `<main>` and ONE `<MobileLayoutProvider>` at all
* viewports - only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs
* vs tablet's hidden-sidebar-via-Sheet) conditionally renders. Three payoffs:
*
* - #26 / first ship: no double-mount of chrome subtrees.
* - H-09: `{children}` stays mounted across viewport flips. A rep
* editing an inline field on desktop who resizes through the mobile
* breakpoint no longer loses the draft mid-edit.
* - Tier-aware sidebar: tablet width gets the desktop shell with
* sidebar hidden behind a Sheet (slide-over from left, opened by
* the topbar logo button) instead of falling all the way back to
* the mobile shell. Closes the half-screen-on-13"-Mac usability gap.
*
* The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only
* mount in the mobile branch - they have no desktop counterpart.
*
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
* the first client render uses the same value so hydration matches. After
* mount, a matchMedia subscription overrides to the precise tier.
*/
export function AppShell({
portRoles,
isSuperAdmin,
user,
ports,
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
initialFormFactor,
children,
}: AppShellProps) {
const [tier, setTier] = useState<Tier>(computeInitialTier(initialFormFactor));
const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
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);
const update = () => {
const next: Tier = mqMobile.matches ? 'mobile' : mqTablet.matches ? 'tablet' : 'desktop';
setTier(next);
// Persist for the next SSR pass so the server renders the
// matching shell on first paint - eliminates the chrome flicker
// on refresh when UA-based classification disagrees with the
// actual viewport (most common on macOS Safari at sub-1024
// widths). 1-year expiry; SameSite=Lax is fine since the cookie
// is read by our own server only.
document.cookie = `pn-crm.viewport-tier=${next}; path=/; max-age=31536000; SameSite=Lax`;
};
update();
mqMobile.addEventListener('change', update);
mqTablet.addEventListener('change', update);
return () => {
mqMobile.removeEventListener('change', update);
mqTablet.removeEventListener('change', update);
};
}, []);
const isMobile = tier === 'mobile';
const isTablet = tier === 'tablet';
// Close the tablet sheet when crossing breakpoints so it doesn't stay
// "open" after a resize back to desktop (Sheet keeps its open prop).
useEffect(() => {
if (!isTablet && tabletSidebarOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTabletSidebarOpen(false);
}
}, [isTablet, tabletSidebarOpen]);
const sidebarProps: SidebarProps = {
portRoles,
isSuperAdmin,
user,
ports,
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
};
// Resolve the current port's residential flag for the mobile More sheet
// (the sidebar resolves its own copy internally from the by-port map).
const residentialModuleEnabled = currentPortId
? (residentialModuleByPort[currentPortId] ?? true)
: true;
// Chrome subtree per tier.
let chrome: ReactNode = null;
if (isMobile) {
chrome = <MobileTopbar />;
} else if (isTablet) {
// Tablet: sidebar lives inside a left-side Sheet, opened by the
// topbar's leading logo button. SheetContent has its own width;
// override to match the inline Sidebar's width so the layout
// reads consistent when the sheet is open.
chrome = (
<Sheet open={tabletSidebarOpen} onOpenChange={setTabletSidebarOpen}>
<SheetContent side="left" className="p-0 w-[var(--width-sidebar)]">
<Sidebar {...sidebarProps} />
</SheetContent>
</Sheet>
);
} else {
chrome = <Sidebar {...sidebarProps} />;
}
const footer = isMobile ? (
<>
<MobileBottomTabs
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet
open={moreOpen}
onOpenChange={setMoreOpen}
residentialModuleEnabled={residentialModuleEnabled}
/>
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</>
) : null;
// Desktop topbar; on tablet it gains a leading logo button that
// opens the sidebar Sheet.
const desktopTopbar = !isMobile ? (
<Topbar
ports={ports}
user={user}
leadingSlot={
isTablet ? (
<button
type="button"
aria-label="Open menu"
onClick={() => setTabletSidebarOpen(true)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
{logoUrl ? (
<img src={logoUrl} alt="" className="h-6 w-6 object-contain" />
) : (
// Neutral fallback when the port has no branding logo yet -
// a three-bar menu icon keeps the affordance discoverable.
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>
) : null
}
/>
) : null;
return (
<MobileLayoutProvider>
{/* Records every in-app navigation so useSmartBack can return the
rep to the page they were actually on (e.g. Sarah Doe -> Yacht
-> Back -> Sarah) instead of always falling back to the
logical URL parent. Renders nothing. */}
<NavigationHistoryTracker />
<div
className={cn(
'bg-background',
isMobile ? 'min-h-[100dvh]' : 'flex h-screen overflow-hidden',
)}
>
{chrome}
<div className={cn(isMobile ? 'contents' : 'flex-1 flex flex-col overflow-hidden min-w-0')}>
{desktopTopbar}
<main
className={cn(
isMobile
? 'px-4 min-h-[100dvh] pt-[calc(56px+env(safe-area-inset-top)+1rem)] pb-[calc(56px+env(safe-area-inset-bottom)+2rem)]'
: 'flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6',
)}
>
{children}
</main>
</div>
{footer}
</div>
</MobileLayoutProvider>
);
}