V1 Web PWA · Surface walkthrough

Operator · tenants roster

A per-surface walkthrough of the V1 Web PWA operator surface: layout, states, interactions, data, and cross-references.

walked
9sections6 minread

On this page

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/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
  • Bookmarkcanonical = '/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-totalmemberCount.toLocaleString() + "members" suffix
    • Right: data-operator-tenant-row-idtenant.id (accent mono)
  • Footer action row: LBtn ghost "← Operator hub" (/operator), LBtn ghost "SSO" (/operator/sso), LBtn primary "Incidents" (/operator/incidents)

States#

  • Loading — source-verified 2026-06-25: no route-level loading.tsx; server-side await bffGet blocks render, so there is no skeleton state
  • Anonymous user — Playwright 2026-06-25: sessionless /operator/tenants redirects 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 === 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
  • 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)
  • 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#

  • Masthead labels — non-interactive; Playwright asserts the route heading, "Federation roster", and "N tenants · M members"

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
  • "← Operator hub" (LBtn ghost, link) → /operator
  • "SSO" (LBtn ghost, link) → /operator/sso
  • "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 — 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#

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)