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>
300 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|