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

# Admin portal wiring

> Endpoint-by-endpoint map of the staff onboarding portal — every function in adminOnboarding.ts and adminAuth.ts, which page calls it, its query/mutation key, and what the UI does with the response.

The staff onboarding portal lives at `/admin/*` in the frontend (`src/app/admin/`). It is a separate, `cl_admin_session`-gated surface from the tenant console — see [Proxy and sessions](/api-reference/integration/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-aware `adminSend()` mutation helper every `/admin/*` write uses.
* `src/lib/api/adminOnboarding.ts` — every tenant-lifecycle operation: create, provision, ingest, train, activate, invite, go live.

All of it is TanStack Query on top of the proxy (`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

| Function                        | Backend operation           | Called from                                                                                                                                                                                                                                                                                  | What the UI does with the response                                                                                                                                                                         |
| ------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adminLogin(email, password)`   | `POST /admin/login`         | `src/app/admin/login/page.tsx` form submit; also the "Causeloop Employee" tab of `src/app/login/page.tsx`                                                                                                                                                                                    | On success: `router.push("/admin")` + `router.refresh()`. On failure: maps `429` → "Too many attempts", `401` → "Invalid email or password", anything else → "Sign-in failed — is the app server running?" |
| `adminLogout()`                 | `POST /admin/logout`        | `AdminLogoutButton` (`src/app/admin/(portal)/AdminLogoutButton.tsx`), rendered in `AdminRail`                                                                                                                                                                                                | Always (even on throw, via `finally`) `router.push("/admin/login")` + `router.refresh()`                                                                                                                   |
| `adminMe()`                     | `GET /admin/me`             | **No current call site.** Exported but unused — the portal's actual auth gate (`src/app/admin/(portal)/layout.tsx`) calls `${BACKEND_URL}/admin/me` directly with a manually attached cookie header instead, since it runs as a server component before any client-side query client exists. | —                                                                                                                                                                                                          |
| `adminSend(method, path, body)` | *(helper, not an endpoint)* | Every mutation in `adminOnboarding.ts`                                                                                                                                                                                                                                                       | Reads `readCsrfToken()` (`src/lib/authCookies.ts`) and attaches it as `x-csrf-token` on every write; a `GET` never needs it                                                                                |

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

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

| Call                          | Type     | Key                 | Backend operation                             | UI effect                                                                                                                                                                                                            |
| ----------------------------- | -------- | ------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listTenants()`               | query    | `["admin-tenants"]` | `GET /admin/tenants`                          | Renders KPI row (`Tenants`, `Live`, `Onboarding`, `Isolation model`) and the tenant list (name, slug/industry/region, model route + trained date, lifecycle chip)                                                    |
| `impersonateTenant(tenantId)` | mutation | —                   | `POST /admin/tenants/{tenant_id}/impersonate` | On success: `router.push("/")` + `router.refresh()` — see [Impersonation redirect](#impersonation-redirect) below. On error: shows the backend's `detail` string (e.g. "Tenant has no active users to impersonate.") |

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.

| Step              | Call                                                                                                                         | Type                                                                                              | Key                                 | Backend operation                                                                             | UI effect                                                                                                                                                                                                                                                                                   |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0. Client         | `createTenant({slug, name, industry, region, primary_contact_email})`                                                        | mutation                                                                                          | —                                   | `POST /admin/tenants`                                                                         | On success: stores `tenant.id`, advances to step 1. On error: shows `err.detail`                                                                                                                                                                                                            |
| 1. Environment    | `provisionTenant(tenantId)`                                                                                                  | mutation                                                                                          | —                                   | `POST /admin/tenants/{tenant_id}/provision`                                                   | Invalidates `["wz-provision-status", tenantId]` on success, which restarts the poll below                                                                                                                                                                                                   |
| 1. Environment    | `getProvisionStatus(tenantId)`                                                                                               | query                                                                                             | `["wz-provision-status", tenantId]` | `GET /admin/tenants/{tenant_id}/provision/status`                                             | See [Provisioning poll](#provisioning-and-training-polls)                                                                                                                                                                                                                                   |
| 2. Data & PII     | `uploadIngestFile(tenantId, file)`                                                                                           | mutation                                                                                          | —                                   | `POST /admin/tenants/{tenant_id}/ingest/upload` (multipart)                                   | See [Upload flow](#upload-flow-formdata)                                                                                                                                                                                                                                                    |
| 2. Data & PII     | `commitIngest(tenantId, uploadId, mapping)`                                                                                  | mutation                                                                                          | —                                   | `POST /admin/tenants/{tenant_id}/ingest/commit`                                               | On success: clears `uploadResult`, sets `datasetCommitted = true`, unlocking `Continue →`                                                                                                                                                                                                   |
| 2. Data & PII     | `putDataPolicy(tenantId, {residency, retention, pii_redaction})`                                                             | mutation                                                                                          | —                                   | `PUT /admin/tenants/{tenant_id}/data-policy`                                                  | Fired once, on the step 2 → 3 transition (`handleNext`), not on every field change                                                                                                                                                                                                          |
| —                 | `getDataPolicy(tenantId)`                                                                                                    | —                                                                                                 | —                                   | `GET /admin/tenants/{tenant_id}/data-policy`                                                  | **No current call site.** Exported from `adminOnboarding.ts` and present in `product_app.py`'s `ONBOARDING_OPERATIONS` allowlist, but neither the wizard nor the tenant detail page reads it back — the wizard only ever writes the policy via `putDataPolicy` on the step 2 → 3 transition |
| 3. Sources        | `seedSources(tenantId, kinds)`                                                                                               | mutation                                                                                          | —                                   | `POST /admin/tenants/{tenant_id}/sources/seed`                                                | Fired on step 3 → 4 transition if at least one source kind is selected and sources aren't already seeded                                                                                                                                                                                    |
| 4. Model training | `putTrainingConfig(tenantId, {...})` then `trainModel(tenantId)`                                                             | mutation (single `mutationFn` awaits both calls in sequence)                                      | —                                   | `PUT /admin/tenants/{tenant_id}/training-config` then `POST /admin/tenants/{tenant_id}/train` | Invalidates `["wz-train-status", tenantId]` on success                                                                                                                                                                                                                                      |
| 4. Model training | `getTrainStatus(tenantId)`                                                                                                   | query                                                                                             | `["wz-train-status", tenantId]`     | `GET /admin/tenants/{tenant_id}/train/status`                                                 | See [Training poll](#provisioning-and-training-polls)                                                                                                                                                                                                                                       |
| 4. Model training | `listCheckpoints(tenantId)`                                                                                                  | query                                                                                             | `["wz-checkpoints", tenantId]`      | `GET /admin/tenants/{tenant_id}/checkpoints`                                                  | Enabled only once `trained` is true; renders cluster count / noise % / silhouette from the newest checkpoint                                                                                                                                                                                |
| 5. Activation     | `activateCheckpoint(tenantId, version, shareOutputs)` → `inviteUser(tenantId, adminEmail, "org_admin")` → `goLive(tenantId)` | three sequential `await`s inside a plain `finishGoLive()` function, **not** react-query mutations | —                                   | `POST .../activate`, then `POST .../invite`, then `POST .../go-live`                          | On success: `router.push(/admin/tenants/${tenantId})`. On any failure: shows `err.detail`, the flow stops wherever it failed (partial completion is visible on the tenant detail page's own step checklist)                                                                                 |

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.

| Section            | Call                                                  | Type     | Key                                                 | Backend operation                           | UI effect                                                                                                                                                                                                  |
| ------------------ | ----------------------------------------------------- | -------- | --------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| (page-wide)        | `getTenant(tenantId)`                                 | query    | `["admin-tenant", tenantId]`                        | `GET /admin/tenants/{tenant_id}`            | Drives every `Step` component's `done` flag; `refetchInterval` polls every 2s while `provisioning?.status === "running"`                                                                                   |
| 1. Provision       | `provisionTenant(tenantId)`                           | mutation | —                                                   | `POST /admin/tenants/{tenant_id}/provision` | Invalidates `["admin-tenant", tenantId]` and `["admin-tenant-provision-status", tenantId]`                                                                                                                 |
| 1. Provision       | `getProvisionStatus(tenantId)`                        | query    | `["admin-tenant-provision-status", tenantId]`       | `GET .../provision/status`                  | Enabled while `lifecycle_state` is `draft` or `provisioning`; polls every 2s while `running`                                                                                                               |
| 2. Ingest          | `uploadIngestFile(tenantId, file)`                    | mutation | —                                                   | `POST .../ingest/upload` (multipart)        | See [Upload flow](#upload-flow-formdata)                                                                                                                                                                   |
| 2. Ingest          | `commitIngest(tenantId, uploadId, mapping)`           | mutation | —                                                   | `POST .../ingest/commit`                    | Invalidates `["admin-tenant", tenantId]` and `["admin-tenant-retrain-recommendation", tenantId]`                                                                                                           |
| 3. Training config | `putTrainingConfig(tenantId, {...})`                  | mutation | —                                                   | `PUT .../training-config`                   | Sets `configSaved = true`                                                                                                                                                                                  |
| 4. Train           | `trainModel(tenantId)`                                | mutation | —                                                   | `POST .../train`                            | Invalidates `["admin-tenant-train-status", tenantId]`                                                                                                                                                      |
| 4. Train           | `getTrainStatus(tenantId)`                            | query    | `["admin-tenant-train-status", tenantId]`           | `GET .../train/status`                      | Polls every 2s while `running`; a `useEffect` also invalidates the checkpoints query the moment `status === "succeeded"` (see below)                                                                       |
| Drift banner       | `getRetrainRecommendation(tenantId)`                  | query    | `["admin-tenant-retrain-recommendation", tenantId]` | `GET .../retrain-recommendation`            | Renders one of four `DRIFT_COPY` banners (`no_checkpoint` / `up_to_date` / `incremental` / `full_retrain`) comparing the active checkpoint's dataset version/row count against the latest ingested dataset |
| 5. Activation      | `listCheckpoints(tenantId)`                           | query    | `["admin-tenant-checkpoints", tenantId]`            | `GET .../checkpoints`                       | Enabled once training succeeds, or immediately for any already-provisioned tenant (`checkpointsExists`)                                                                                                    |
| 5. Activation      | `activateCheckpoint(tenantId, version, shareOutputs)` | mutation | —                                                   | `POST .../activate`                         | Invalidates `admin-tenant`, `admin-tenant-checkpoints`, and `admin-tenant-retrain-recommendation`                                                                                                          |
| 6. Invite users    | `inviteUser(tenantId, email, roleProfileKey)`         | mutation | —                                                   | `POST .../invite`                           | Clears the email field, invalidates `["admin-tenant-invitations", tenantId]`                                                                                                                               |
| 6. Invite users    | `listInvitations(tenantId)`                           | query    | `["admin-tenant-invitations", tenantId]`            | `GET .../invitations`                       | Renders each invite's email, role, invite `status`, and email `delivery_status`                                                                                                                            |
| 7. Go live         | `goLive(tenantId)`                                    | mutation | —                                                   | `POST .../go-live`                          | Invalidates `["admin-tenant", tenantId]`; error surfaces inline                                                                                                                                            |

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

```typescript theme={null}
useEffect(() => {
  if (trainStatusQuery.data?.status === "succeeded") {
    void queryClient.invalidateQueries({ queryKey: ["admin-tenant-checkpoints", tenantId] });
  }
}, [trainStatusQuery.data?.status, tenantId, queryClient]);
```

Without this effect, a checkpoints list fetched (empty) before training finished would never be told to refetch once the job actually completes, and the Activation step would never appear.

## Control plane — `src/app/admin/(portal)/control-plane/page.tsx`

A read-only, cross-tenant infrastructure view.

| Call             | Type  | Key                 | Backend operation    | UI effect                                                                                                                                                                                                                   |
| ---------------- | ----- | ------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listTenants()`  | query | `["admin-tenants"]` | `GET /admin/tenants` | Aggregated client-side into a lifecycle-state histogram and a `tenant_<slug>` schema list (tenants in the `offboarded` state are excluded from the schema list, since `scripts/offboard_tenant.py` drops the actual schema) |
| `fetchMetrics()` | query | `["admin-metrics"]` | `GET /metrics`       | Parsed with `toJobDashboardData()`; only `queueDepth` is used on this page                                                                                                                                                  |

## Jobs — `src/app/admin/(portal)/jobs/page.tsx`

The operational dashboard, refreshing every 5 seconds.

| Call             | Type  | Key                                          | Backend operation                                     | UI effect                                                                                                                                                                                                       |
| ---------------- | ----- | -------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fetchMetrics()` | query | `["admin-metrics"]`, `refetchInterval: 5000` | `GET /metrics` (Prometheus text exposition, not JSON) | Parsed by `parsePrometheusText()` then reshaped by `toJobDashboardData()` into: pipeline queue depth, platform jobs by type/status, per-tenant ingest jobs by status, and LLM tokens used this month per tenant |

`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](/api-reference/integration/tenant-console-wiring#export) — 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](/architecture/workers-and-jobs) for how these are durably queued):

```typescript theme={null}
export interface JobStatus {
  status: "not_started" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
  progress: number;
  error_detail: string | null;
  steps_completed?: string[];
}
```

```mermaid theme={null}
stateDiagram-v2
    [*] --> not_started
    not_started --> queued: mutation POST fires\n(provision / train)
    queued --> running: worker claims the job
    running --> succeeded
    running --> failed
    running --> cancelled
    succeeded --> [*]
    failed --> [*]
    cancelled --> [*]
```

The wizard's own two `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.

```typescript theme={null}
// provision poll
refetchInterval: (q) =>
  q.state.data?.status === "running" || q.state.data?.status === "not_started" ? 1200 : false,
// train poll
refetchInterval: (q) =>
  q.state.data?.status === "running" || q.state.data?.status === "not_started" ? 1500 : false,
```

The comment in the wizard source explains why both poll on `"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)

Both `uploadIngestFile()` (staff-side historical dataset ingest) and its tenant-console counterpart `uploadTenantSource()` (covered in [Tenant console wiring](/api-reference/integration/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.

```typescript theme={null}
export async function uploadIngestFile(tenantId: string, file: File): Promise<IngestUploadResult> {
  const csrf = readCsrfToken();
  const form = new FormData();
  form.append("file", file);
  const response = await fetch(`/api/backend/admin/tenants/${tenantId}/ingest/upload`, {
    method: "POST",
    headers: csrf ? { "x-csrf-token": csrf } : {},
    body: form,
  });
  if (!response.ok) {
    const body = await response.json().catch(() => ({ detail: response.statusText }));
    throw new Error(typeof body.detail === "string" ? body.detail : "Upload failed");
  }
  return (await response.json()) as IngestUploadResult;
}
```

Note what's deliberately absent: no `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:

```mermaid theme={null}
sequenceDiagram
    participant Staff as Staff (admin portal)
    participant API as Product API
    participant Tenant as Tenant console

    Staff->>API: POST /admin/tenants/{id}/impersonate<br/>Cookie: cl_admin_session
    API->>API: tenant.lifecycle_state == "live"? else 409<br/>find earliest-created active TenantUser, else 409<br/>mint a real tenant session (impersonate_tenant_user)<br/>write ControlAuditLog(action="employee_impersonation")
    API-->>Staff: 200 OK<br/>Set-Cookie: cl_tenant_session=<slug>:<token>
    Staff->>Staff: router.push("/") + router.refresh()
    Staff->>Tenant: GET / (now carrying cl_tenant_session)
    Tenant->>API: GET /me with cl_tenant_session
    API-->>Tenant: TenantIdentity for the impersonated user
```

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 `ControlAuditLog` row (`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 the `with 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.

A staff member is only ever one `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](/api-reference/integration/proxy-and-sessions)) — the frontend never mints or copies a session token itself.

## Related

* [Proxy and sessions](/api-reference/integration/proxy-and-sessions)
* [Tenant console wiring](/api-reference/integration/tenant-console-wiring)
* [Client onboarding pipeline](/features/client-onboarding-pipeline)
* [Invitations and impersonation](/features/invitations-and-impersonation)
