> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Staff portal

> The internal Onboarding Portal: tenant directory, tenant detail checklist, the new-client wizard, and the control-plane and jobs operational views.

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).

<Note>
  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.
</Note>

## 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](/architecture/security)) 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:

|                                                           | `onboarding_admin` | `support_readonly` |
| --------------------------------------------------------- | ------------------ | ------------------ |
| View tenant directory, tenant detail, control plane, jobs | ✓                  | ✓                  |
| Create/provision/ingest/train/activate/invite/go-live     | ✓                  | ✗ (`403`)          |
| Impersonate a tenant (**Open portal**)                    | ✓                  | ✗ (`403`)          |

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 — "`causel`**`oo`**`p`**`.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:

| Column      | Source                                                                                                          |
| ----------- | --------------------------------------------------------------------------------------------------------------- |
| Tenant      | `tenant.name`, with a subtitle of `industry · region` (falls back to the raw `slug` if both are empty)          |
| Environment | `tenant_${slug}` — the tenant's Postgres schema name, computed client-side, not read from `schema_name`         |
| Model       | `model_route` + `trained {date}` (date truncated to `YYYY-MM-DD`) if `model_trained_at` is set, else an em dash |
| Status      | a chip — see below                                                                                              |
| (action)    | an **Open portal →** button, only for live tenants                                                              |

<Warning>
  The status chip logic (`LIFECYCLE_CHIP`/`LIFECYCLE_LABEL` in `page.tsx`) only defines a mapping for `live` (green "Live" chip). Every other `lifecycle_state` — `draft`, `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.
</Warning>

Clicking a tenant row's name/subtitle navigates to its [detail page](#tenant-detail-page-admintenantstenantid). 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](/features/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](/features/client-onboarding-pipeline) describes, one HTTP call per step rather than one combined submission.

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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](#tenant-detail-page-admintenantstenantid)'s ingest step, not here.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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](/features/client-onboarding-pipeline) for what happens if one of the three fails partway through), then redirects to the tenant's detail page.
  </Step>
</Steps>

### 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`:

```python theme={null}
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$")
```

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](/features/client-onboarding-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](/features/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](/features/client-onboarding-pipeline#8-go-live--review--live) 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.

<Warning>
  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](/architecture/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.
</Warning>

## 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()`):

| Section                       | Metric                                 | Columns                         |
| ----------------------------- | -------------------------------------- | ------------------------------- |
| Platform jobs (control plane) | `causeloop_platform_jobs_total`        | job type, status chip, count    |
| Per-tenant ingest jobs        | `causeloop_ingest_jobs_total`          | tenant slug, status chip, count |
| LLM tokens used this month    | `causeloop_llm_tokens_used_this_month` | tenant slug, token count        |

Status chips color-code as: `succeeded` → green, `running`/`queued` → periwinkle, `failed` → red, `cancelled` → neutral gray.

## Related

* [Client onboarding pipeline](/features/client-onboarding-pipeline)
* [Training and checkpoints](/features/training-and-checkpoints)
* [Invitations and impersonation](/features/invitations-and-impersonation)
* [Workers and jobs](/architecture/workers-and-jobs)
