/Users/rushabhpatel/Desktop/causeloop.ai/frontend) is a single Next.js 15 / React 19 application with two distinct authenticated surfaces — the tenant console and the staff Onboarding Portal — plus a small set of public auth routes, all served from one src/app tree. There is no separate SPA build per surface; route groups and per-group layouts do the separation.
App Router layout
(console) and (portal) are route groups — the parentheses keep them out of the URL — used here purely so each surface gets its own server-rendered auth gate and shell without affecting routing. A request to /issues never touches admin/(portal)/layout.tsx and vice versa.
Both group layouts (
src/app/(console)/layout.tsx, src/app/admin/(portal)/layout.tsx) are async Server Components that read the relevant session cookie, call the backend’s /me or /admin/me directly (server-to-server, via BACKEND_URL, not through the /api/backend proxy — there is no browser request to intercept yet), and redirect() to the matching login page on any failure. This is the actual authorization boundary for both surfaces: client-side checks in session-context.tsx exist for convenience, not enforcement.Root layout and providers
src/app/layout.tsx is the one HTML document shell for the whole app: it loads three Google Fonts (Bricolage_Grotesque for display, Public_Sans for body, IBM_Plex_Mono for mono/tabular values) as CSS variables and imports globals.css. suppressHydrationWarning is set on <html> because the theme store (below) can add a data-theme attribute after mount.
src/app/providers.tsx creates one module-scoped QueryClient — deliberately outside the component so its cache survives re-renders and is shared by every useConsoleData() consumer instead of being reconstructed per mount. Defaults: staleTime: 5 * 60 * 1000, retry: 1, refetchOnWindowFocus: false. Both the console and admin layouts wrap their children in <Providers>.
The /api/backend/[...path] proxy
src/app/api/backend/[...path]/route.ts is the only path from the browser to FastAPI. The browser never calls the backend origin directly — every fetch() in src/lib/api/*.ts targets /api/backend/<path> (same origin, via the API_PREFIX constant in src/lib/api/client.ts), and this one catch-all route handler forwards it server-side to BACKEND_URL (src/lib/backendUrl.ts: process.env.CAUSELOOP_API_URL, defaulting to http://127.0.0.1:18000 for local hot reload).
What the proxy does on every hop, implemented as one shared proxy() function reused by the GET/POST/PATCH/PUT/DELETE exports:
1
Session gate
Checks for either
cl_admin_session or cl_tenant_session (src/lib/authCookies.ts) on the incoming request. If neither is present and the requested path isn’t in a small PUBLIC_PATHS allowlist (admin/login, login, accept-invite, the password-reset request/confirm pairs, and the three health* operations), it returns 401 {"detail": "no session"} without ever reaching the backend. The health paths are public deliberately — an external deployment smoke check only needs the frontend URL, not a session.2
Header sanitation
Copies inbound headers except a hop-by-hop set (
host, connection, content-length) and an inbound Forwarded/X-Forwarded-*/X-Real-Ip set, which are stripped rather than recomputed — the only header the proxy itself derives is x-causeloop-client-ip (below), so the backend never sees an X-Forwarded-For from the proxy. Critically, it also strips every inbound x-causeloop-* header before forwarding — a client-supplied X-CauseLoop-Tenant-Id or similar would otherwise pass straight through and could spoof backend-trusted identity headers.3
Trusted client IP injection
If
x-forwarded-for is present (set by the real ingress, not the browser), the proxy parses the last hop, validates it’s a real IP with Node’s net.isIP, and sets it as x-causeloop-client-ip. The backend only honors this header when CAUSELOOP_TRUST_FRONTEND_PROXY=true (services/api/auth/client_ip.py) — otherwise it falls back to the raw socket peer, which in a private network is the frontend container itself.4
Forward and stream back
fetch(targetUrl, { method, headers, body: request.body, duplex: "half", redirect: "manual" }). Response headers are copied with .append (not .set) specifically because Set-Cookie can appear more than once (e.g. login sets both the session cookie and the CSRF cookie) and fetch’s Headers doesn’t comma-join it like other headers — .set would silently drop all but the last cookie.502 {"detail": "backend unavailable"} instead of an opaque Next.js 500.
src/app/api/workspaces/route.ts is a second, narrower server route that is not part of the proxy: it calls BACKEND_URL/workspaces directly (this is the public, pre-login tenant picker used by the login page, so there’s no session to gate) and reshapes the response, degrading to { workspaces: [], degraded: true } on any backend error rather than surfacing a raw failure to a page a visitor hasn’t even logged into yet.
Session context
Both group layouts resolve identity server-side and pass it down as a plain value, not a client-side fetch:- Console:
(console)/layout.tsxfetches/mewith the tenant cookie forwarded manually, builds aSession(src/lib/session.ts) from the response —name,initials(computed fromfull_name), aWorkspaceRolemapped from the backend’s rawpermissionslist viamapPermissionsToWorkspaceRole(src/lib/api/tenantAuth.ts:users.manage→Risk Admin,sources.manage→Loop Owner,reviews.write→Analyst, elseAuditor) — and provides it throughSessionProvider(session-context.tsx), consumed client-side viauseSession(). - Admin portal:
admin/(portal)/layout.tsxfetches/admin/meand passes the resultingEmployeeIdentity(id,email,full_name,control_role) directly as a prop toAdminRail, with no separate context provider.
Session also carries two role-rank helpers, canReview() (Analyst and above) and canOperate() (Loop Owner and above), used to gate UI actions — the backend remains authoritative for every mutation regardless of what the console shows.
React-query data layer (src/lib/data, src/lib/api)
src/lib/api/*.ts is the request layer: each exported function wraps one backend operation through apiGet/apiSend (src/lib/api/client.ts), which prefix every call with /api/backend and throw a typed ApiError (status, detail, plus isNotFound/isConflict/isForbidden getters) on any non-2xx response. Mutating tenant calls go through tenantSend() (src/lib/api/tenantAuth.ts), which reads the non-httpOnly CSRF cookie (readCsrfToken(), src/lib/authCookies.ts) and attaches it as x-csrf-token — the backend’s double-submit check (services/api/auth/csrf.py) rejects any mutating request where the header doesn’t match the cookie.
src/lib/data/useConsoleData.ts is the console’s single source of truth for tenant insight data. It wraps one useQuery (key ["tenant-insights", session.clientId], queryFn: fetchClientInsights from src/lib/api/clientInsights.ts, which reads GET /insights/snapshot) and layers three pieces of behavior on top:
- Expensive adaptation runs inside
select, not the render body (selectClientAdapt) —adaptClientInsights()(src/lib/data/client-adapter.ts) walks all nine insight collections and is deliberately memoized by TanStack per(data, select)reference pair rather than reallocated on every re-render across the console’s ~10useConsoleData()consumers. - Live polling while generating:
refetchIntervalreturns4000while any collection’sstatusis"generating",falseonce all are settled. - A four-state result union —
loading,locked,ready(withdata, optionalpending: CollectionName[]for still-generating LLM collections, and an optionalnarrative), anderror(carrying a passthroughrefetch()).lockedis the state for a tenant onboarded withshare_onboarding_outputs=falseand nothing yet visible — ported from the legacy mockup’s soft “dashboards populate automatically” message, distinct fromloading, which would otherwise spin forever for that tenant.
summary, themes, issues, severity, lens — fast, deterministic) and four LLM collections (fishbone, theme_summaries, narratives, recommendations, plus cause_remediation tracked separately) that can still be "generating" after the model collections are ready; useConsoleData()’s pending field is exactly how a view knows to show a per-section skeleton instead of blocking the whole page.
State stores (zustand)
src/lib/stores/ holds four small, single-purpose zustand stores — no single global store, each owns one concern:
Component system
src/components/ is organized by role, not by page:
shell/— the chrome mounted once per surface:Rail(left nav),Pagebar(top bar),InsightsBanner,ThemeInit/ThemeToggle,Toaster,ErrorState,LockView,Skeletons.views/— one component per console route (HomeView,IssuesView,LoopsView,RemediationView,SourcesView,SettingsView) plus shared sub-views likeAggregationPanel.overlays/— the globally-mounted singletons driven byuseUi():CommandPalette,LoopDrawer,MoreModal,IssueModal,PlanModal. These are mounted once in(console)/layout.tsxrather than per-view specifically so opening one from anywhere in the console (e.g. a path-code click inside the Loops aggregation panel) always renders, regardless of which route triggered it.charts/—Fishbone,Constellation,BoneChart,BubbleMap,Donut,DriverBars,EngineGraphic,HazardWave,LoopHeat,ScoreHist,Sparkline.ui/— the primitive kit re-exported fromsrc/components/ui/index.ts:Avatar,Badge,BoardCard,Btn,Callout,Chip,Disclosure,FakeInput,Kpi,ListRow,LiveDot,Seg,Switch,Tabs, plus non-exported overlay primitivesModal,Scrim,TooltipLayer.onboarding/— styling shared by the admin portal’s tenant wizard.
Brand token system (src/styles/tokens.css)
Every color, shadow, and gradient the UI uses is a CSS custom property defined once on :root and overridden on [data-theme="dark"] — components never hardcode a hex value. The palette starts from six brand hue families (--cl-navy, --cl-mag-* magenta, --cl-peri-* periwinkle, --cl-amber-*, --cl-red-*, --cl-green-*) and derives semantic tokens from them: --bg, --surface/--surface-2/--surface-3, --ink/--body/--muted/--faint (text scale), --line/--line-soft/--line-hi (borders), --accent-text/--accent-fill/--accent-soft and matching triples for peri/amber/red/ok, plus --shadow/--shadow-lg/--inner-hi for elevation.
Theme switching is a single attribute flip: ThemeInit (mounted in the console layout) applies document.documentElement.dataset.theme = "dark" (or deletes it for light) from useTheme’s setTheme(), driven by a persisted localStorage key (cl-theme) read once on mount — never during SSR, which is what keeps first paint and hydration in sync. prefers-reduced-motion: reduce disables all animations/transitions globally as a base rule, independent of theme.
Typography also flows through CSS variables set by the root layout’s font loaders: --font-display (Bricolage Grotesque, headings), --font-body (Public Sans), --font-mono (IBM Plex Mono, used by .mono/.num/.kicker utility classes for tabular figures and label text).