Skip to main content
The staff (Onboarding) portal lives at 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. 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:
The status chip logic (LIFECYCLE_CHIP/LIFECYCLE_LABEL in page.tsx) only defines a mapping for live (green “Live” chip). Every other lifecycle_statedraft, onboarding, review, even a hypothetical offboarded row that somehow still renders here — falls through to the same amber “Onboarding” chip. A tenant sitting in review (activated, awaiting go-live) looks identical in this list to one that’s still draft. Use the tenant detail page’s numbered checklist, not this chip, to see exactly which step a tenant is on.
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 direct POST /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:
Lowercase letters, digits, and hyphens only; must start and end with a letter or digit; 3–63 characters total. 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:
  1. Provision — a button if the tenant is still draft; otherwise the tenant’s schema_name in a code chip.
  2. Historical data ingest — file upload → row-count/mapping/detected-PII preview → commit. Its done state is derived client-side from whether the tenant’s audit_trail contains any dataset_ingested entry, not from a live dataset count — it only updates on the next admin-tenant refetch.
  3. Training configuration — clusterer select, “Save configuration”; done is local component state (configSaved), so it resets on page reload even though the backend config persists.
  4. 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).
  5. 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.
  6. Invite users — email + role (org_admin/risk_admin/analyst/viewer) form, plus the tenant’s existing invitations with delivery status.
  7. Go live — a single button once every prerequisite is met.
A chronological audit trail ({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_rw role · 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 because scripts/offboard_tenant.py actually drops that schema — the control.tenants row 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_depth gauge.
The “Compute” line in the Serving path card reads “arq worker materializes insight_collections per dataset version.” This is stale copy: the deployed materialization worker is the custom PostgreSQL-backed job queue described in Workers and jobs (services/pipeline/job_worker.py, claiming rows from control.platform_jobs with FOR UPDATE SKIP LOCKED) — the codebase does not use arq anywhere in this path. Treat this card as illustrative, not literal, until it’s updated.

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.