---
path: /operator/tenants
surface: operator
domain: operator (house — tenant directory)
auth: signed-in
source: apps/oshun/web/src/app/operator/tenants/page.tsx
status: walked
last_walked: >
  2026-06-25 operator-surfaces tenant-directory addendum — Playwright
  real-dev-infra coverage for signed-in /operator/tenants render, stable
  page/roster/row/footer anchors, real BFF tenant IDs, descending member-count
  ordering, row fields, footer targets, and anonymous redirect-before-render.
  Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §85; spec:
  apps/oshun/web/e2e/operator-surfaces.spec.ts.
---

# Operator · tenants roster

## Purpose

The federation tenant directory: one row per tenant from `GET /v1/tenants`,
sorted by member count, showing plan tier, member count, and onboarding date. A
read-only directory in this code; onboarding is handled by
`POST /v1/tenants/onboard` in the BFF. Repository-wide source search on
2026-07-17 found no shipped UI caller for that endpoint; there is no onboarding
control on this page.

## Entry points

- **From `/operator`** (hub) — operator-surface card; foot "← Operator hub"
  returns there
- **Cross-links from siblings** — `/operator/incidents` and `/operator/sso` foot
  rows link here; this page links onward to `/operator/sso` and
  `/operator/incidents`
- **Direct URL** — session-gated only (`proxy.ts` redirects sessionless →
  `/welcome?redirect=`); no operator-role gate in the proxy
- **Bookmark** — `canonical = '/operator/tenants'`

## Layout regions

`page.tsx` is an async server component:
`await bffGet<TenantsResponse>( '/v1/tenants')` (falling back to
`{ tenants: [] }`), client-side sorted by `memberCount` descending with a
`totalMembers` reduce, rendered inside
`<LWebShell top={<LCustomerNav active="library" />}>` (reuses the **customer**
nav, Library active). Centered column `maxWidth: 1080`.

- **Header**: `LCustomerNav` (Library highlighted — verify intended chrome)
- **Masthead**: `LMasthead` — left "Operator · tenants", right
  `${sorted.length} tenants · ${totalMembers} members`, kicker "Federation
  roster", title "The _tenants_."
- **Main**: `<div data-operator-tenants-roster>` — a grid of tenant rows OR the
  empty card
- **Tenant row** (per `tenant`): `<div data-operator-tenant-row>` grid
  `1fr auto auto`; stable attributes expose `data-operator-tenant-id`,
  `data-operator-tenant-plan`, and `data-operator-tenant-member-count`
  - Left: `data-operator-tenant-plan-created` ("{plan} · created {localized
    createdAtIso}") + `data-operator-tenant-label` (`tenant.label`)
  - Middle: `data-operator-tenant-member-total` —
    `memberCount.toLocaleString()` + "members" suffix
  - Right: `data-operator-tenant-row-id` — `tenant.id` (accent mono)
- **Footer action row**: `LBtn` ghost "← Operator hub" (`/operator`), `LBtn`
  ghost "SSO" (`/operator/sso`), `LBtn` primary "Incidents"
  (`/operator/incidents`)

## States

- [x] **Loading** — source-verified 2026-06-25: no route-level `loading.tsx`;
      server-side `await bffGet` blocks render, so there is no skeleton state
- [x] **Anonymous user** — Playwright 2026-06-25: sessionless
      `/operator/tenants` redirects to `/welcome?...` before
      `[data-operator-tenants-page]` or `[data-operator-tenant-row]` render
- [x] **Signed-in user** — Playwright 2026-06-25: renders
      `[data-operator-tenants-page]`, masthead, and the real BFF tenant roster
      (session-gated only; no operator-role gate in the proxy and no in-page
      guard)
- [x] **Empty** — source-verified 2026-06-25: `sorted.length === 0` renders
      `[data-operator-tenants-empty]` with "No tenants registered yet." Masthead
      right reads "0 tenants · 0 members". Dedicated empty-route E2E remains
      pending because the server-component fetch cannot be page-routed
- [x] **Populated** — Playwright 2026-06-25: real fixture rows `tenant-oxbow`
      (184), `tenant-redbird` (41), and `tenant-house` (1) render with
      member-count attrs and in descending member-count order; the masthead
      total pattern renders
- [ ] **Plan diversity** — individual / institutional / enterprise render in the
      eyebrow line distinctly
- [ ] **Populated (long)** — 50+ rows in a single grid; no pagination in code
      (verify behavior at scale)
- [x] **BFF unreachable / non-2xx** — source-verified 2026-06-25: `bffGet`
      returning `null` substitutes `{ tenants: [] }` → empty state (no
      fabricated tenants). Dedicated outage-route E2E remains pending
- [ ] **Offline** — RSC fetch (`revalidate 60`); operator SW policy TBD
- [ ] **Standalone PWA** — renders; verify robots/no-index (no `robots` export
      here)

## Interactions

### Masthead

- [x] **Masthead labels** — non-interactive; Playwright asserts the route
      heading, "Federation roster", and "`N` tenants · `M` members"

### Tenant row (per `tenant` in `sorted`)

- [x] **Plan + created line** (text) — "{plan} · created {localized date}";
      non-interactive
- [x] **Tenant label** (text) — `tenant.label`; non-interactive (**no click into
      a per-tenant console** from this row — verify whether it should link to
      `/operator/tenant`)
- [x] **Member count** (text) — `memberCount.toLocaleString()` + "members";
      non-interactive
- [x] **Tenant id** (accent text) — `tenant.id`; non-interactive
- [x] **Row** — Playwright asserts real row ids, stable plan/member-count
      attributes, and descending order; source confirms no click handler, no
      per-tenant drill-in, and no manage / suspend action wired

### Footer actions

- [x] **"← Operator hub"** (`LBtn` ghost, link) → `/operator`
- [x] **"SSO"** (`LBtn` ghost, link) → `/operator/sso`
- [x] **"Incidents"** (`LBtn` primary, link) → `/operator/incidents`

## Data & contracts

- **Reads**: `bffGet<TenantsResponse>('/v1/tenants')` —
  `{ generatedAt: string; tenants: Tenant[] }` where
  `Tenant = { id, label, plan: 'individual'|'institutional'|'enterprise', memberCount, createdAtIso }`.
  On `null` substitutes an empty list. Sort + total are computed client-side
- **Writes**: _None observed in this view._ Onboarding is
  `POST /v1/tenants/onboard` per the BFF route, but that trigger is **not** on
  this page and no shipped repository UI caller was found
- **Realtime**: _None observed._
- **Caching**: RSC fetch, `next.revalidate = 60`, 5 s abort
- **Auth/role check**: session-gated only (`proxy.ts` redirects sessionless →
  `/welcome?redirect=`); no operator-role gate in the proxy and no in-page guard

### 2026-07-17 onboarding-authority note

- The BFF now requires and restores the tenant-onboarding snapshot before
  production traffic. `POST /v1/tenants/onboard` acknowledges only after its
  exact contract, hierarchy, residency, billing, allocations, isolation, and
  lifecycle record commits; a failed save returns `503` without adding a roster
  row.
- `GET /v1/tenants/:tenantId` omits internal creator provenance. Profile export
  includes the operator-created tenant view; account erasure pseudonymizes that
  provenance and installs a restart-safe fence while retaining the shared
  organization.
- The roster projection is unchanged: `memberCount` still receives licensed
  `seatTotal`, not the live member count from the separate invitation/member
  store. No UI behavior changed.

## E2E coverage

- [`apps/oshun/web/e2e/operator-surfaces.spec.ts`](../../apps/oshun/web/e2e/operator-surfaces.spec.ts)
  — signed-in `/operator/tenants` render, stable page/roster/row/footer anchors,
  real BFF fixture rows `tenant-oxbow`, `tenant-redbird`, and `tenant-house`,
  descending member-count order, row plan/label/member/id fields, all footer
  hrefs, and anonymous redirect-before-render.
- Still not browser-covered on this server-component route: forced empty roster,
  forced BFF-unreachable fallback, 50+ tenant long-list behavior, offline /
  standalone / SW policy, missing per-row drill-in, missing onboarding trigger,
  and absent operator-role gate.
- No new Playwright case accompanies the 2026-07-17 onboarding-store hardening
  because the endpoint has no shipped UI caller and this roster's response shape
  did not change. Connected BFF tests cover the route, durable failure,
  public-view privacy, profile export/erasure, restart, and the emitted server.

## Cross-references

- Parent: [`operator-overview.md`](./operator-overview.md)
- Sibling operator routes:
  - [`operator-sso.md`](./operator-sso.md)
  - [`operator-incidents.md`](./operator-incidents.md)
  - [`operator-audit.md`](./operator-audit.md)
  - [`operator-tenant.md`](./operator-tenant.md) — per-tenant member console
    (the drill-in target this roster lacks)
- Feature spec: [`V1/features.md`](../../V1/features.md#product-surfaces)
- Architecture:
  [`V1/ARCHITECTURE.md`](../../V1/ARCHITECTURE.md#operator-surfaces)
- Journeys traversing this view:
  - [`../journeys/tenant-onboard.md`](../journeys/tenant-onboard.md)
  - [`../journeys/tenant-audit-log-investigation.md`](../journeys/tenant-audit-log-investigation.md)

## Open questions / known gaps

- [ ] Tenant rows are not links — there is no drill-in from this roster to
      `/operator/tenant` (the per-tenant member console). Confirm whether rows
      should navigate there, and how an operator selects which tenant the
      single-tenant console shows
- [ ] No shipped UI caller was found for `POST /v1/tenants/onboard`; implement
      and browser-cover the owning onboarding flow before claiming tenant CRUD
      UI
- [ ] Onboarding has no request idempotency key, dedicated audit event, external
      isolation provisioning effect, or update/archive/offboarding lifecycle
- [ ] No filter / search / pagination at scale; document large-roster behavior
- [ ] Customer `LCustomerNav` (Library active) chrome on an operator route and
      no `robots: { index: false }` — confirm intended
- [ ] An operator-role gate appears absent — `proxy.ts` only redirects
      sessionless users to `/welcome`; no role check in the proxy or page
      component (possible gap vs intended scope)
