Architecture overview
This page explains the portal chrome every other flow runs inside: the route tree and guard nesting (src/app/routes.tsx), the two layout shells (src/components/layout/app-sidebar.tsx, src/components/layout/admin-sidebar.tsx), the three reads the shell makes before any page's own queries, and the global wiring (src/main.tsx). Per-flow mechanics live elsewhere: Authentication architecture covers the guard/token details and the post-auth landing matrix, Onboarding architecture the wizard and its state gate, Link creation pipeline the link write path. Operational traps for testing the shell live in the test-stack runbook.
Source-checked against optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea and optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (FLOW-003 harvest; B-017 re-verification pass). No live re-audit was recorded for this flow; open questions are collected in
docs/FLOW-003.md§11.
Route tree
src/app/routes.tsx nests guards around layout routes:
portal pages RequireAuth → RequireOrg → AppLayout
admin pages RequireAuth → RequireOrg → RequireAdmin → AdminLayout
/login/*, /register/* AuthRedirect (splats, cover Clerk's step sub-URLs)
/onboarding RequireAuth only
* (splat) NotFoundPage — ungated, public
/ref-page ungated dev design reference
The /login/* and /register/* splats exist so Clerk's multi-step sub-URLs resolve inside the SPA instead of falling into the 404 catch-all. They are declared before the splat, which is what gives them priority.
Back-compat redirects are router loaders, so they fire before any guard:
/getting-started→/dashboard/analytics/templates→/analytics/breakdowns?by=template(query params preserved)/templates,/templates/create,/templates/:id,/templates/:id/edit→/settings?tab=templatesvariants
Route path constants live in src/lib/constants.ts (ROUTES, API_PATHS).
Guard chain
Files: src/lib/route-guards.tsx (RequireAuth, RequireOrg, AuthRedirect, GuardErrorScreen at L38) and src/lib/admin-route-guard.tsx (RequireAdmin). Roles always come from Clerk session claims via useAuth; nothing reads the database to authorize.
RequireAuthwaits for Clerk to resolve (branded splash), then bounces signed-out users to/login?redirect=<path+search>. The redirect value is validated bysafeRedirectPath(src/lib/utils.ts)://hostand absolute-URL forms are refused. Where each role lands after sign-in is FLOW-001's matrix; this page doesn't restate it.RequireOrgconsumesuseNeedsOnboarding(src/hooks/use-onboarding.tsL73-93). It trips when the user has an org, state has nobypass, and app config is missing or no plan is selected, redirecting to/onboarding. On a state-read error it rendersGuardErrorScreen(error banner + Retry) and never redirects. That's the FB-2 invariant: a failed read must not fabricate routing.RequireAdminadmits only the platform ADMIN persona (ops-org membership).
/onboarding sits under RequireAuth only, so it stays reachable in the orgless state by design.
The two layouts
Both shells share the same bones:
- A "Skip to main content" link as the first focusable element.
useRouteFocus(src/hooks/use-route-focus.ts) focuses#main-content(tabIndex -1,preventScroll) on pathname change only, skipped on initial mount. The focus move is the screen-reader announcement; there is no live region.- A
<main>capped atmax-w-[1440px] mx-auto, withmin-w-0on both main columns (FLOW-004 FB-3, mobile overflow).
AppLayout redirects admins. A user whose Clerk role is ADMIN is sent to /admin unless impersonatedOrgId is set in src/stores/admin-passthrough-store.ts (the only Zustand store; src/stores/sidebar-store.ts is deleted). While impersonating, the header shows the impersonated org's name and hides the member count (UX-4: Clerk's active org is still the admin's own, so the real count would be wrong).
AdminLayout owns the impersonation-exit handshake (FB-4). The exit is deliberately two steps to defeat a redirect race. The banner (passthrough-banner.tsx) navigates to /admin/organizations with replace: true and location state { passthroughExit: true } while the store is still set; once AdminLayout mounts, a mount-only effect clears the passthrough store and calls queryClient.clear(). Clearing the store first would let AppLayout's admin redirect win the race and land on /admin. Any refactor must keep navigate-then-clear. Side effect: queryClient.clear() leaves every cached query cold, so pages show loading states immediately after Exit.
Content-scroll asymmetry. The org-side <main> deliberately has no overflow-auto: an overflow ancestor becomes the scrollport and kills position: sticky descendants (the link-preview rail). The admin-side <main> still carries overflow-auto; whether admin pages have sticky descendants it could break is unverified (docs/FLOW-003.md §11). Don't "normalize" the org side to match.
Sidebar mechanics
- Nav data. Org: six items in
NAV_ITEMS(Home, Analytics, Links, Custom domains, API Keys, Settings). Admin: eight inADMIN_NAV_ITEMS(Dashboard, Organizations, Users, Domains, Audit Log, System, Billing, Sandbox); adevOnlyfilter drops Sandbox whenimport.meta.env.MODE === "production". There is no Getting Started item and no header section label; both were removed, though the FLOW-003 fix-pass record still claims arouteSection()label (commitfbdd304dropped it). - Active state.
SidebarNavLink(sidebar-nav-link.tsx) derivesisActivefromuseLocation().pathname(endprop ? exact match : exact-or-prefix), feeds it toSidebarMenuButtonasdata-active, and callssetOpenMobile(false)on every click so the mobile drawer closes (a no-op on desktop). The/dashboardand/adminitems requireend; without it the parent item lights up on every child page. - Collapse persistence.
SidebarProvider(src/components/ui/sidebar.tsx) writes thesidebar_statecookie (path=/; max-age=604800) on every toggle (L99);getSidebarDefaultOpen()reads it at mount (L40-46,false= collapsed), and both layouts pass it asdefaultOpen.
The three shell reads
Before any page's own queries, the shell issues up to three GETs:
| Read | Caller | Hook | Roles |
|---|---|---|---|
GET /portal/onboarding/state | RequireOrg | useNeedsOnboarding | any org role (no @Roles) |
GET /portal/team/members | Header | useMembers (use-team.ts L17) | any org role |
GET /portal/billing/subscription | Header + UserMenu | useSubscription (use-subscription.ts L25, query key ["subscription"]) | any org role |
All three controllers mount ClerkAuthGuard → RolesGuard → RateLimitGuard and none declares @Roles, so every org role can read them. GET /portal/team/members (src/team/team.controller.ts L58) proxies clerkBackend.listMembers; a null subscription means Starter with no record (src/portal/portal-billing.controller.ts L544). The old sidebar gate (GET /portal/links?limit=1 on every page) is gone: no useLinks import remains in any layout file, though the stale comment at src/hooks/use-links.ts L32 still mentions "the sidebar gate".
Upgrade-pill decision (showUpgrade in Header): render only when the subscription query is settled without error, status is not PAST_DUE, and isSubscribed(subscription) is false. isSubscribed (src/lib/plans.ts L317) is status === "ACTIVE" && plan !== "STARTER". Two suppressions are deliberate:
- Query error hides the pill (FB-2, FLOW-012): an errored read is not "not subscribed".
PAST_DUEhides it (FLOW-015 UX-1): don't offer Upgrade to an org whose real problem is a failing payment.
The pill is also hidden below sm (640px) because it forced a 390px overflow (FLOW-013 UX-5); mobile keeps the upgrade path via Settings → Billing.
Error posture and query defaults
There is no error boundary anywhere in the app. Shell reads therefore degrade silently: a failed members read renders a blank count, a failed subscription read hides the Upgrade pill, no toast, no banner. The one surfaced failure is the guard reads: RequireOrg/AuthRedirect render GuardErrorScreen with Retry and never redirect.
src/lib/query-client.ts defaults: staleTime 30s, gcTime 5m, retry 1, refetchOnWindowFocus: false; mutations retry 0. Header data is therefore at most 30s stale and never refetches on focus.
Provider stack
src/main.tsx mounts, in order: StrictMode → ThemeProvider (class strategy, defaultTheme="dark", enableSystem={false}) → ClerkProvider (routerPush/routerReplace wired to the router, appearance from src/lib/clerk-appearance.ts, signInUrl/signUpUrl) → QueryClientProvider → ClerkTokenBridge → TooltipProvider → RouterProvider.
Env consumed at this level: VITE_CLERK_PUBLISHABLE_KEY (required), VITE_API_BASE_URL (optional, default ""), VITE_SDK_DOCS_URL / VITE_PRODUCT_DOCS_URL (default to the public docs site), and VITE_CLERK_OPS_ORG_ID (ops-persona detection in src/hooks/use-auth.ts).
Why the nav is not role-filtered
Viewer and Analyst see the same six items as Owner. This is safe by construction: every nav destination's GET is role-ungated server-side, and writes are gated at page level with hasMinimumRole (src/lib/roles.ts, ladder viewer < analyst < developer < admin < owner; platform ADMIN = ops-org membership). Filtering the sidebar would add a second role source without removing the page-level checks.
Gotchas
- The stale comment at
src/hooks/use-links.tsL32 still references the removed sidebar gate. docs/screenshots/FLOW-003/*show the pre-fix shell (placeholder brand, no active highlight, silent 404). Don't use them to illustrate current behavior.- The light theme has had no dedicated contrast pass; the audit checked dark only.
- E2E: focus jumps to
#main-contenton every SPA navigation, so assertions that assume focus stays on the clicked nav item fail.