Skip to main content
The frontend (/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.
A backend connection failure (refused, reset, DNS failure) is caught and turned into a stable 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.tsx fetches /me with the tenant cookie forwarded manually, builds a Session (src/lib/session.ts) from the response — name, initials (computed from full_name), a WorkspaceRole mapped from the backend’s raw permissions list via mapPermissionsToWorkspaceRole (src/lib/api/tenantAuth.ts: users.manageRisk Admin, sources.manageLoop Owner, reviews.writeAnalyst, else Auditor) — and provides it through SessionProvider (session-context.tsx), consumed client-side via useSession().
  • Admin portal: admin/(portal)/layout.tsx fetches /admin/me and passes the resulting EmployeeIdentity (id, email, full_name, control_role) directly as a prop to AdminRail, 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:
  1. 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 ~10 useConsoleData() consumers.
  2. Live polling while generating: refetchInterval returns 4000 while any collection’s status is "generating", false once all are settled.
  3. A four-state result unionloading, locked, ready (with data, optional pending: CollectionName[] for still-generating LLM collections, and an optional narrative), and error (carrying a passthrough refetch()). locked is the state for a tenant onboarded with share_onboarding_outputs=false and nothing yet visible — ported from the legacy mockup’s soft “dashboards populate automatically” message, distinct from loading, which would otherwise spin forever for that tenant.
The nine collections split into five model collections (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 like AggregationPanel.
  • overlays/ — the globally-mounted singletons driven by useUi(): CommandPalette, LoopDrawer, MoreModal, IssueModal, PlanModal. These are mounted once in (console)/layout.tsx rather 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 from src/components/ui/index.ts: Avatar, Badge, BoardCard, Btn, Callout, Chip, Disclosure, FakeInput, Kpi, ListRow, LiveDot, Seg, Switch, Tabs, plus non-exported overlay primitives Modal, 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).