Context. surface operator · domain operator (house — tenant directory) · route /operator/tenants · auth signed-in · source apps/oshun/web/src/app/operator/tenants/page.tsx
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.
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/incidentsand/operator/ssofoot rows link here; this page links onward to/operator/ssoand/operator/incidents - Direct URL — session-gated only (
proxy.tsredirects 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>grid1fr auto auto; stable attributes exposedata-operator-tenant-id,data-operator-tenant-plan, anddata-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)
- Left:
- Footer action row:
LBtnghost "← Operator hub" (/operator),LBtnghost "SSO" (/operator/sso),LBtnprimary "Incidents" (/operator/incidents)
States#
- Loading — source-verified 2026-06-25: no route-level
loading.tsx; server-sideawait bffGetblocks render, so there is no skeleton state - Anonymous user — Playwright 2026-06-25: sessionless
/operator/tenantsredirects to/welcome?...before[data-operator-tenants-page]or[data-operator-tenant-row]render - 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) - Empty — source-verified 2026-06-25:
sorted.length === 0renders[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 - Populated — Playwright 2026-06-25: real fixture rows
tenant-oxbow(184),tenant-redbird(41), andtenant-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)
- BFF unreachable / non-2xx — source-verified 2026-06-25:
bffGetreturningnullsubstitutes{ 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
robotsexport here)
Interactions#
Masthead#
- Masthead labels — non-interactive; Playwright asserts the route
heading, "Federation roster", and "
Ntenants ·Mmembers"
Tenant row (per tenant in sorted)#
- Plan + created line (text) — "{plan} · created {localized date}"; non-interactive
- 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) - Member count (text) —
memberCount.toLocaleString()+ "members"; non-interactive - Tenant id (accent text) —
tenant.id; non-interactive - 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#
- "← Operator hub" (
LBtnghost, link) →/operator - "SSO" (
LBtnghost, link) →/operator/sso - "Incidents" (
LBtnprimary, link) →/operator/incidents
Data & contracts#
- Reads:
bffGet<TenantsResponse>('/v1/tenants')—{ generatedAt: string; tenants: Tenant[] }whereTenant = { id, label, plan: 'individual'|'institutional'|'enterprise', memberCount, createdAtIso }. Onnullsubstitutes an empty list. Sort + total are computed client-side - Writes: None observed in this view. Onboarding is
POST /v1/tenants/onboardper 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.tsredirects 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/onboardacknowledges only after its exact contract, hierarchy, residency, billing, allocations, isolation, and lifecycle record commits; a failed save returns503without adding a roster row. GET /v1/tenants/:tenantIdomits 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:
memberCountstill receives licensedseatTotal, 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— signed-in/operator/tenantsrender, stable page/roster/row/footer anchors, real BFF fixture rowstenant-oxbow,tenant-redbird, andtenant-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 - Sibling operator routes:
operator-sso.mdoperator-incidents.mdoperator-audit.mdoperator-tenant.md— per-tenant member console (the drill-in target this roster lacks)
- Feature spec:
V1/features.md - Architecture:
V1/ARCHITECTURE.md - Journeys traversing this view:
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 norobots: { index: false }— confirm intended - An operator-role gate appears absent —
proxy.tsonly redirects sessionless users to/welcome; no role check in the proxy or page component (possible gap vs intended scope)