frontend/src/app/admin/(portal)/ — a separate route group from the tenant console, gated by its own session cookie (cl_admin_session, services/api/auth/employee_auth.py). AdminPortalLayout (layout.tsx) resolves the calling employee server-side by calling GET /admin/me with that cookie and redirects to /admin/login if it’s missing or invalid; there is no client-side auth check duplicated on top of it. Every employee identity carries a control_role of either onboarding_admin or support_readonly — accounts are seeded by scripts/create_employee.py (there is no self-signup route for staff, unlike tenant users invited through the pipeline).
Screenshots aren’t available for this page — every UI element below is read directly from the page components (
page.tsx files under admin/(portal)/), not from a mockup or a visual pass.Sign-in and roles
/admin/login (admin/login/page.tsx) posts {email, password} to POST /admin/login via adminLogin(). A 429 (the shared login-rate-limit path — see Security model) shows “Too many attempts. Wait a few minutes and try again.”; a 401 shows “Invalid email or password.”; anything else falls back to “Sign-in failed — is the app server running?”. On success it redirects to /admin and refreshes the router so AdminPortalLayout’s server-side GET /admin/me check re-runs against the new cl_admin_session cookie. AdminLogoutButton calls POST /admin/logout, then unconditionally redirects to /admin/login in a finally block — logout always lands you back at the login screen even if the backend call itself fails.
Both control roles can authenticate into the same portal; what differs is which mutations succeed:
Every route in
onboarding_router requires some authenticated employee (Depends(get_current_employee) at the router level); mutating routes additionally depend on require_employee_role("onboarding_admin") (aliased _MUTATE). A support_readonly employee sees the identical UI — nothing is hidden or grayed out client-side — but every mutating button’s request comes back 403 from the backend.
Navigation rail
AdminRail.tsx renders four nav items under an “Operations” section header: Clients (/admin, with a live tenant-count badge sourced from the same listTenants() query the directory page uses — displays — while loading), New client (/admin/tenants/new), Control plane (/admin/control-plane), and Jobs (/admin/jobs). The workspace switcher at the top of the rail is fixed to a single label — “causeloop.ai” with subtitle “Onboarding · internal” — there is no multi-workspace switching in this portal. The footer shows the signed-in employee’s full_name, a static “Causeloop Ops” line, a logout button, and the theme toggle.
AdminPagebar.tsx titles each page from a static map (Clients, New client, Control plane, Jobs) except on a tenant detail page, where it reads the tenant’s name from the already-cached ["admin-tenant", tenantId] query (no extra network round trip). A ”+ Onboard new client” button linking to the wizard is always present in the pagebar, regardless of which page is open.
Tenant directory (/admin)
The landing page (admin/(portal)/page.tsx) opens with a KPI row computed client-side from one GET /admin/tenants call: Tenants (total count), Live (count where lifecycle_state === "live", styled with an accent tone), Onboarding (total − live — every non-live tenant is bucketed here regardless of its actual state), and a static Isolation model tile reading “Schema / tenant”. Below that, a section header reads “Client environments” with the hint “every tenant runs in an isolated schema — PII never crosses tenants.”
The tenant list is a five-column table:
Clicking a tenant row’s name/subtitle navigates to its detail page. Clicking Open portal → (rendered only when
lifecycle_state === "live") calls POST /admin/tenants/{tenant_id}/impersonate, then navigates to / — the tenant console root, now authenticated as the impersonated tenant user via the freshly-minted cl_tenant_session cookie the backend set on that response. See Invitations and impersonation for what that call actually does and its “no active users to impersonate” failure mode.
New client wizard (/admin/tenants/new)
A six-step wizard (Client → Environment → Data & PII → Sources → Model training → Activation) that drives the same /admin/tenants/* endpoints the client onboarding pipeline describes, one HTTP call per step rather than one combined submission.
1
Client
Organization name, an auto-derived Slug field (lowercased, non-alphanumeric runs collapsed to a single hyphen, truncated to 40 characters, falling back to the literal string
"client" if empty — the frontend’s own slugify() helper, looser than the backend’s rule), Industry (Banking / Insurance / Asset Management / Healthcare), Region (three fixed options), and a primary admin email. The hint here is explicit: “Registration creates the tenant record in the control plane only — no data plane exists until the environment step provisions it.” Continuing calls createTenant(), i.e. POST /admin/tenants — the backend’s validate_tenant_slug() (see below) is the actual authority on whether the slug is accepted; a slug the client-side slugify() produced can still be rejected with a 422 if it happens to violate the stricter server pattern. The client-side rules are not a strict subset of the server’s: slugify() lowercases, collapses non-alphanumeric runs to a single hyphen, strips leading/trailing hyphens, then truncates to 40 characters (falling back to "client" if empty) — a short name like "AB" slugifies to "ab", two characters, under the server’s 3-character minimum, and the truncation step runs after the hyphen-stripping step, so it can reintroduce a trailing hyphen the server’s pattern rejects.2
Environment
Shows the six provisioning steps as a checklist with live/done/pending dots, polling
GET .../provision/status while running. One row’s label doesn’t match its backend counterpart: the wizard’s fifth row is keyed create_storage_prefix (“Provision object storage prefix”), but the backend’s actual step name (services/api/tenancy/provisioner.py’s STEPS tuple) is verify_object_storage. Since the row’s “done” state checks stepsCompleted.includes(key) by exact string match, that specific row can never show as complete even after the real step finishes — the other five rows’ keys match exactly and behave correctly. This is a display-only quirk; the provisioning job itself is unaffected.3
Data & PII
Data residency (US/EU/APAC), retention (5/7/10 years), and a “Redact PII before model calls” checkbox (checked by default) — these three fields are held in local component state and only sent via
PUT .../data-policy when the operator clicks Continue. Below that, the same upload-then-commit ingest calls as the tenant detail page, but with an abridged preview: once the upload response comes back the wizard shows only “{row_count} rows detected in {filename}” and a Commit dataset v1 button — the column-mapping table and detected-PII-columns warning that POST .../ingest/upload also returns are rendered only on the tenant detail page’s ingest step, not here.4
Sources
Four selectable source-kind tiles — File upload, Amazon S3, Database, Kafka — each with an emoji, name, and one-line description.
file_upload is pre-selected. Continuing calls POST .../sources/seed with the selected kinds, pre-registering placeholder sources rows (kind + a default name, no credentials) that the tenant’s own admin finishes configuring after go-live.5
Model training
Embedding provider is a fixed, disabled “hash (deterministic, offline)” field; clustering algorithm is a select (
hdbscan / kmeans / mixed_graph_greedy_modularity), locked once training has run. “Run initial training” saves the training config, then trains; on success it shows the resulting cluster count, noise percentage, and silhouette score.6
Activation
A share-outputs choice (“client sees the full trained run on first login” vs. “dashboards stay empty until new ingestion”), and a read-only preview of the one initial user this step will invite — the admin email from step one, labeled Risk Admin with an
org_admin role chip. The final Go live → button is disabled until a checkpoint exists, and on click runs activateCheckpoint() → inviteUser() → goLive() as three sequential calls (not one atomic operation — see Client onboarding pipeline for what happens if one of the three fails partway through), then redirects to the tenant’s detail page.Slug validation rule
Every tenant slug — from the wizard, from a directPOST /admin/tenants call, or from the (unpublished) rename endpoint — is validated server-side against exactly one pattern, services/api/tenancy/db.py’s _SLUG_RE:
validate_tenant_slug() raises InvalidTenantSlugError (surfaced as a 422 with the pattern itself in the error message) on any violation, and tenant_schema_name() calls it before ever building a tenant_<slug> schema name — so an invalid slug can never reach a DDL statement.
Tenant detail page (/admin/tenants/[tenantId])
A checklist of seven numbered Step cards (tenants/[tenantId]/page.tsx), each showing a checkmark chip once its own done condition is met, mirroring the pipeline’s transitions:
- Provision — a button if the tenant is still
draft; otherwise the tenant’sschema_namein a code chip. - Historical data ingest — file upload → row-count/mapping/detected-PII preview → commit. Its
donestate is derived client-side from whether the tenant’saudit_trailcontains anydataset_ingestedentry, not from a live dataset count — it only updates on the nextadmin-tenantrefetch. - Training configuration — clusterer select, “Save configuration”;
doneis local component state (configSaved), so it resets on page reload even though the backend config persists. - Train — only rendered once step 2 is done; shows a drift banner immediately below it (see Training and checkpoints for the recommendation logic behind
no_checkpoint/up_to_date/incremental/full_retrain). - Activation & output sharing — only rendered once at least one checkpoint exists; lists every checkpoint with an Activate button (or an “active” chip for the current one) and the share-outputs checkbox.
- Invite users — email + role (
org_admin/risk_admin/analyst/viewer) form, plus the tenant’s existing invitations with delivery status. - Go live — a single button once every prerequisite is met.
{timestamp} — {action}) renders below the checklist, reading straight from GET /admin/tenants/{tenant_id}’s audit_trail array (capped at 100 rows server-side, most recent first).
Control plane (/admin/control-plane)
Two side-by-side cards, both backed by listTenants() plus a client-side Prometheus-text parse of GET /metrics:
- Neon Postgres — tenant count, a static isolation description (“One schema per tenant · scoped
app_rwrole · RLS forced on every tenant table”), a per-lifecycle-state breakdown, and the list of every non-offboarded tenant’s schema name (tenant_<slug>). Offboarded tenants are excluded from the schema list becausescripts/offboard_tenant.pyactually drops that schema — thecontrol.tenantsrow is kept for audit, but the schema named there no longer exists. - Serving path — three lines of static descriptive copy about the ingest → compute → read path, plus one live number: pipeline queue depth, read from the
causeloop_pipeline_queue_depthgauge.
Jobs (/admin/jobs)
A cross-tenant operational view, explicitly labeled as reading GET /metrics and refreshing every 5 seconds. Above the lists, a single Pipeline queue depth tile shows the same causeloop_pipeline_queue_depth gauge value the control plane’s Serving path card reads (or an em dash while loading). Below that, three lists, each parsed from one Prometheus metric family (src/lib/api/adminMetrics.ts’s parsePrometheusText()/toJobDashboardData()):
Status chips color-code as:
succeeded → green, running/queued → periwinkle, failed → red, cancelled → neutral gray.