/admin/* in the frontend (src/app/admin/). It is a separate, cl_admin_session-gated surface from the tenant console — see Proxy and sessions for the cookie mechanics both share. Every call this portal makes goes through two files:
src/lib/api/adminAuth.ts— sign-in/sign-out and the CSRF-awareadminSend()mutation helper every/admin/*write uses.src/lib/api/adminOnboarding.ts— every tenant-lifecycle operation: create, provision, ingest, train, activate, invite, go live.
apiGet/apiSend from src/lib/api/client.ts), so every table below names the query key or mutation the calling component uses, exactly as it appears in the source.
adminAuth.ts: every exported function
adminMe() being unused isn’t a bug in the wiring — the layout’s own direct-fetch approach exists because Next.js server components can’t use a browser-side useQuery hook, and the layout needs the identity check to happen before rendering anything (including redirecting to /admin/login). If a future client component needs to re-check identity after mount, adminMe() is the ready-made call for it.Auth gate: src/app/admin/(portal)/layout.tsx
Every route under /admin/(portal) — the tenant directory, the wizard, tenant detail, control plane, jobs — is wrapped by this server component. It reads the cl_admin_session cookie, forwards it as a raw Cookie header to ${BACKEND_URL}/admin/me (bypassing the client-side proxy entirely, since it runs server-side), and redirects to /admin/login if the cookie is missing or the backend rejects it. On success it renders <AdminRail employee={employee}> with the resolved EmployeeIdentity (id, email, full_name, control_role).
Tenant directory — src/app/admin/(portal)/page.tsx
The landing page after sign-in: a KPI strip plus the full tenant list.
The
Open portal → button that triggers impersonateTenant only renders when tenant.lifecycle_state === "live" — the backend independently enforces the same rule (409 Tenant is not live yet., see services/api/auth/tenant_auth.py), so this is a UX affordance, not the security boundary.
New client wizard — src/app/admin/(portal)/tenants/new/page.tsx
A six-step linear flow (Client → Environment → Data & PII → Sources → Model training → Activation) that walks a single tenant from creation to live. Every step after the first is gated on the previous step’s mutation having succeeded — the wizard holds tenantId in local state once step 0 resolves, and every subsequent call is scoped to that id.
The wizard’s
role_profile_key for the initial admin invite is hardcoded to "org_admin" — the profile whose users.manage permission maps to the "Risk Admin" display role in the tenant console (mapPermissionsToWorkspaceRole in src/lib/api/tenantAuth.ts).
Tenant detail — src/app/admin/(portal)/tenants/[tenantId]/page.tsx
The same lifecycle as the wizard, but re-entrant: any step can be revisited for an existing tenant, driven entirely by what getTenant() and the checkpoint/invitation queries already show as done.
The checkpoints-query invalidation on training success is worth calling out because it isn’t the mutation’s own
onSuccess doing it — training runs as an asynchronous background job (see Workers and jobs), so the moment that matters is the poll discovering succeeded, not the moment the POST /train call returns (which only means the job was queued):
Control plane — src/app/admin/(portal)/control-plane/page.tsx
A read-only, cross-tenant infrastructure view.
Jobs — src/app/admin/(portal)/jobs/page.tsx
The operational dashboard, refreshing every 5 seconds.
GET /metrics is the one product-API operation the JSON client layer can’t parse as JSON — fetchMetrics() in src/lib/api/adminMetrics.ts reads the body as text and parses metric_name{labels} value lines itself rather than going through apiGet(). (GET /remediation/export.xlsx is the other non-JSON response in the product allowlist — see Tenant console wiring — but it returns a binary XLSX stream rather than Prometheus text, and it isn’t parsed by this metrics code path.) services/api/metrics_api.py defines more metric families (including causeloop_tenants_total and causeloop_platform_job_duration_seconds_avg) than toJobDashboardData() actually consumes: the frontend reshapes only causeloop_pipeline_queue_depth, causeloop_platform_jobs_total, causeloop_ingest_jobs_total, and causeloop_llm_tokens_used_this_month into the job dashboard’s queue depth, platform jobs, ingest jobs, and token-usage sections.
Provisioning and training polls
Both the wizard and the tenant detail page poll the same shape of job-status resource (JobStatus in adminOnboarding.ts) for two different jobs — provision_tenant and train_tenant_model (see Workers and jobs for how these are durably queued):
refetchInterval callbacks (src/app/admin/(portal)/tenants/new/page.tsx) poll on "running" and "not_started" — not just "running" — at different intervals for the two jobs: 1200ms for provisioning, 1500ms for training.
"not_started": provisioning starts on a background thread, so a poll that lands before the job’s first database write sees "not_started" — without also polling on that status, the query would permanently stop polling before the job ever transitions to "running". The tenant detail page’s own polls (POLL_INTERVAL_MS = 2000 in src/app/admin/(portal)/tenants/[tenantId]/page.tsx) are simpler: they poll every 2 seconds only while "running", never on "not_started".
steps_completed (only populated for the provisioning job) drives the wizard’s own step-by-step checklist (create_schema, run_tenant_migrations, grant_app_rw, seed_role_profiles, create_storage_prefix, activate_onboarding) — the tenant detail page’s numbered Step sections are driven by getTenant()’s lifecycle/audit fields instead, not by steps_completed.
Upload flow (FormData)
BothuploadIngestFile() (staff-side historical dataset ingest) and its tenant-console counterpart uploadTenantSource() (covered in Tenant console wiring) follow the same shape: build a FormData, attach the CSRF header manually, and skip the JSON API client entirely because the body is multipart, not JSON.
Content-Type header. Setting one manually on a FormData body would omit the multipart boundary the browser computes automatically — fetch sets the correct multipart/form-data; boundary=... header itself only when Content-Type is left unset.
The response (IngestUploadResult) carries a preview, not a commit: upload_id, detected columns, a row_count, a 5-row preview, a suggested_mapping (column → canonical field), and detected_pii_columns (column → redaction strategy, matched by column-name heuristics on the backend). The wizard and tenant detail page both render the suggested mapping and PII warnings, then require a second, explicit commitIngest(tenantId, uploadId, mapping) call before the upload becomes a real dataset version — this two-step upload-then-commit split is what lets the operator review/correct the column mapping and see PII flags before anything lands in the tenant’s dataset.
Impersonation redirect
impersonateTenant(tenantId) is the one call in this portal that hands control to a different session type entirely:
Two properties are worth being explicit about, both verified in services/api/auth/tenant_auth.py:
- Which user gets impersonated is not a choice — the backend always selects the tenant’s earliest-created active
TenantUser, so a staff member can never target a specific individual through this call; it’s always “become the first real user this tenant has.” - The audit log write is unconditional, not best-effort — the
ControlAuditLogrow (actor_type="employee",action="employee_impersonation",resource_id=<impersonated user id>) is written immediately after the session is minted, in a separate control-plane transaction (with control_uow(), after thewith tenant_uow(tenant)block that mints the session has already committed). The two writes are not atomic with each other — a crash between the two commits would leave a minted session with no audit row — but every code path that successfully mints an impersonation session unconditionally proceeds to the audit write.
router.refresh() away from the tenant console because the new cl_tenant_session cookie was set by the backend’s response and forwarded verbatim by the proxy (see Proxy and sessions) — the frontend never mints or copies a session token itself.