Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
T
pastilhasandClaude Opus 5 b4f88ec161 routes refuse at the route, and a new home is empty
Three things, from a member sitting on /music with no music capability on a server with
no music sidecar: an empty library, and 403s in the console.

PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was
hidden and the route was wide open — typing the path, following an old link or restoring
a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside
the error boundary.

It does not redirect. Sending someone to `/` erases what they asked for and reads as a
bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so
a reload after installing the thing just works.

And it says which of the two reasons applies, because they need different screens and send
the reader to different places. `not-installed` is a fact about the SERVER — the owner gets
a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can
change it. Presenting either as the other sends you looking in the wrong place.

ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers
"held but its sidecar is not installed", so an uninstalled feature has no tile AND no
screen. The dock, the Permissions list and the routes now agree because they read one
answer.

NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that
made them — the member's provisioning and, older and worse, `/ls`, which created folders in
somebody's home as a side effect of LOOKING at it. A listing that invents its own contents
is a listing you cannot trust, and the platform has no standing to choose a person's folder
layout. A new home is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:42:39 +00:00

70 lines
4.1 KiB
TypeScript

import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, MusicPlayerHost, usePanelFullscreen } from 'officerdev';
import { useCapabilities } from 'hooks/useCapabilities';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
type DashboardLayoutProps = {
children?: React.ReactNode;
};
export function DashboardLayout({ children }: DashboardLayoutProps) {
const { canVisit, plugins } = useCapabilities();
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
// either way — every one of these routes is refused server-side too — but an app that offers a door it
// will then slam is worse than one that never showed it.
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo(
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
[canVisit, plugins],
);
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
const isTouch = useIsTouch();
const { pathname } = useLocation();
usePageTitleSync();
// A panel can maximize into the content region on its own, but it cannot paint over this header — the
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
const panelFullscreen = usePanelFullscreen();
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
const regionRef = useRef<HTMLElement | null>(null);
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
the focused element: content slid up under the fixed header and the hidden dock slid into view.
`clip` clips identically but is not scrollable, so nothing can ever scroll it. */}
<section ref={regionRef} className="relative min-h-0 flex-1 snap-start overflow-clip">
<Background />
{!isTouch && <Dock items={visibleItems} boundaryRef={regionRef} className="hidden md:flex" />}
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
{/* Chrome stays outside: whatever broke, the dock and the header still navigate you off it.
Keyed on the pathname so leaving a broken screen is itself a recovery. */}
<ErrorBoundary
resetKeys={[pathname]}
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
>
{/* Inside the boundary and around every screen, so one place decides whether a route exists for
this account on this server. Filtering the dock was never enough: the tile was hidden and the
route still rendered for anyone who typed it, followed an old link or restored a tab. */}
<RouteGate>{children}</RouteGate>
</ErrorBoundary>
</div>
</section>
<MusicPlayerHost />
</div>
);
}