the support case this exists for is "it does not work for them and it does for me". reproducing that with a password reset means locking somebody out of their own account in order to look at it, which is worse than the bug. THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and reaches every handler, because user-middleware puts the whole payload on the context. without it an impersonated action is indistinguishable from that person's own — in a log, in a transcript, in anything keyed on a user id — and a support tool that can forge activity as somebody else is a different and much worse thing than a support tool. ONE HOUR, not thirty days. a session lasts a month because a person uses their own account daily; impersonation is a task you finish. an hour is long enough to reproduce a problem and short enough that a forgotten tab stops being a live credential for somebody else's account by lunchtime. IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any token carrying `impersonatedBy`, so impersonating another Super Admin does NOT hand user management back — which would have made this a way to launder owner actions through somebody else's id. stated twice, in the gate and in the handler, because one file away is too far for this one. THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an owner session — a far more dangerous thing to own than this whole feature. the cost is that clearing browser storage strands you as the other person until the hour expires, and signing in again fixes that. the banner is loud and sits ABOVE the header, outside everything a fullscreen panel can hide. the failure it prevents is not a hole, it is forgetting: reading a mailbox or deleting something while believing it is yours. it asks the server who it is rather than decoding the token, so it cannot keep showing a name after the hour has run out. verified end to end against a throwaway account, since this host has one user: impersonate 200 with the target's id and impersonatedBy naming the owner, 60 minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining 403, user management while impersonating 403; one [SECURITY] line per start. the probe account was deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
84 lines
5.0 KiB
TypeScript
84 lines
5.0 KiB
TypeScript
import { useMemo, useRef } from 'react';
|
|
import { useLocation } from 'react-router';
|
|
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
|
import { usePermissions } from 'hooks/usePermissions';
|
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
|
import { Background } from './Background';
|
|
import { Header } from './Header';
|
|
import { Dock, useDockItems, defaultDockPaths } from './Dock';
|
|
import { Spotlight } from './Spotlight/Spotlight';
|
|
import { ShortcutsModal } from './ShortcutsModal';
|
|
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 } = usePermissions();
|
|
// 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 allDockItems = useDockItems(plugins);
|
|
const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
|
|
const { items: visibleItems } = useDock(
|
|
permitted,
|
|
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
|
);
|
|
const isTouch = useIsTouch();
|
|
const { pathname } = useLocation();
|
|
usePageTitleSync();
|
|
useChatNotifications();
|
|
// 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 nav dock measures its reveal boundary from this element, so it sits just above whatever is at the
|
|
// bottom of the content region. Written for the in-flow music bar, which used to be rendered here and
|
|
// left with `plugins/music/` on 2026-08-15; the measurement is not music-specific and still holds for
|
|
// anything a screen puts at its foot.
|
|
const regionRef = useRef<HTMLElement | null>(null);
|
|
|
|
return (
|
|
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
|
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
|
|
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
|
|
<ImpersonationBanner />
|
|
<Header dockItems={visibleItems} hidden={panelFullscreen} />
|
|
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
|
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
|
<Spotlight />
|
|
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
|
|
panel, an error boundary, or mid-navigation. */}
|
|
<ShortcutsModal />
|
|
{/* 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>
|
|
</div>
|
|
);
|
|
}
|