V1 Web PWA · Surface walkthrough

Operator · Tenant console · members

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

walked
9sections10 minread

On this page

Context. surface operator · domain operator (tenant — institution-scoped admin) · route /operator/tenant · auth signed-in · source apps/oshun/web/src/app/operator/tenant/page.tsx

Last walked. 2026-07-02 tenant-bulk-export addendum — /operator/tenant now includes a live bulk-export release panel (TenantBulkExportPanel) for admin-scoped content JSON export creation, manifest/file-hash inspection, integrity verification, release approval, and raw released-file download through the real BFF. Evidence: WALKTHROUGH/results/tenant-bulk-operations-export.md; spec: apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts. 2026-06-30 tenant-member-register addendum — Invite and + Add member now refresh the visible members table through the live tenant-console cockpit read after the real BFF write. The TenantMemberRegister client island preserves the 8 fixture rows, appends live BFF member/invite rows with data-tenant-member-row-source="live", and keeps Export CSV bound to the current rendered roster. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §243; spec: apps/oshun/web/e2e/tenant-member-actions.spec.ts. 2026-06-29 tenant-integrations addendum — /operator/tenant now includes the live integrations registry panel (TenantIntegrationsPanel) for admin-scoped API-key creation, webhook subscription creation, and signed sandbox simulator checks against the real BFF. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §221; spec: apps/oshun/web/e2e/tenant-integrations-ui.spec.ts. 2026-06-25 tenant-export addendum — Export CSV is now a real client-side download generated from the rendered TenantConsoleData member rows (TenantMemberExportCsv), with Playwright verifying filename, row count, and CSV content. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §99; specs: apps/oshun/web/e2e/tenant-onboard.spec.ts and apps/oshun/web/e2e/tenant-member-invite-scim.spec.ts. 2026-06-25 operator-surfaces tenant-console row-cell addendum — Playwright real-dev-infra coverage for signed-in /operator/tenant render, anonymous proxy redirect, KPI grid fixture values, all 8 member rows, every row cell (id/member/plan/status/sittings/last-seen/action placeholder), row non-focusability, plan/status diversity, Export CSV control, Invite dialog focus, and + Add member dialog focus. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §86; spec: apps/oshun/web/e2e/operator-surfaces.spec.ts.

Purpose#

Institution-scoped admin: members, plans, sittings, status, last-seen, plus the operator-facing integrations registry for API keys, webhook subscriptions, and sandbox signed-delivery checks, and bulk-export release controls for manifest verification / approval / download. Robots-noindex. The "Lilith Console" register-style surface for managing a single tenant's membership, activity, and integration edge. Backed by getTenantConsole() for the initial member fixture, live BFF calls for the integrations and bulk-export panels, and a live tenant-console cockpit read after Invite / + Add member writes, rendered by TenantConsole and TenantMemberRegister.

Entry points#

  • From /operator (index) — operator-surface card
  • Profile menu → Operator → Tenant
  • Direct URL — session-gated only (proxy.ts redirects sessionless → /welcome?redirect=); no operator-role gate in the proxy; tenant context resolved server-side
  • From /operator/admin inbox detail — tenant-class INC links here
  • Notification handoff — tenant requests / approvals route here

Layout regions#

TenantConsole uses Lilith primitives (LGraphPaper, LOperatorBar, LEyebrow, LCode, LChip, LDataCell, L, SERIF_L, SANS_L).

  • Top: LOperatorBar — product="Lilith Console", section="Tenant", breadcrumb="Pacific Theological › Members" (separator is CRUMB_SEP = ' › ', operator.tsx:38)
  • Header band (flex space-between):
    • Left: eyebrow accent "Members · Pacific Theological Seminary"; Garamond 28 line: "247 members · 192 active this week" (italic count + muted suffix)
    • Right: action row inside TenantMemberRegisterTenantMemberExportCsv button "Export CSV", LBtn "Invite", LBtn "+ Add member"; CSV binds to the current rendered roster, including any live rows appended after a successful BFF write/readback
  • KPI grid — 5-column, inline-borders (no border on first cell); Garamond 36 italic value + eyebrow label
  • Integrations registry panelTenantIntegrationsPanel below the KPI grid; loads GET /v1/admin/integrations/snapshot from the browser using the active admin bearer; displays API-key/webhook/delivery counts, recent redacted key/webhook/delivery rows, and compact controls for sandbox tenant, API-key name/scopes, webhook name/topic/target URL, and simulator topic
  • Bulk export release panelTenantBulkExportPanel below the integrations registry; loads GET /v1/admin/bulk-exports from the browser using the active admin bearer; creates a content JSON export, shows manifest and file hashes, verifies integrity, approves release with a rationale, and downloads the released raw file
  • Members table:
    • Header row: ID / Member / Plan / Status / Sittings / Last seen / (final 60-px column for actions); grid template 110px 2fr 1fr 1fr 1fr 1fr 60px; uppercase eyebrows
    • Body rows: <div data-tenant-member-row> using the same template; stable attributes expose data-tenant-member-row-source (fixture / live), data-tenant-member-id, data-tenant-member-plan, and data-tenant-member-status. Cell spans expose data-tenant-member-cell values id, member, plan, status, sittings, last-seen, and actions; the action cell is a visible ellipsis placeholder only
  • Full padding 24px 28px

States#

  • Loading — no route loading.tsx; getTenantConsole() is a server-side static fixture and the page blocks until it renders
  • Anonymous user — redirected to /welcome?redirect= by proxy.ts
  • Signed-in user — renders (session-gated only; no operator-role gate in the proxy)
  • Tenant present + populated — header counts visible; 8 table rows render
  • Empty tenant (no members) — table empty state (verify)
  • Plan diversity — Solo, Hearth, and Institutional render distinctly
  • Status diversity — active / lapsed / trial / paused render distinctly in the current fixture; invited is covered by the live invite refresh path; suspended / deactivated are not present in the shipped fixture
  • Long member list — verify pagination, scroll, or virtualization
  • getTenantConsole() fails — no route error.tsx exists under apps/oshun/web/src/app/operator; a render crash falls through to the nearest parent/global boundary
  • Realtime presence — "Last seen" updates if a member comes online during the session (verify)
  • Integrations snapshot loading — the panel waits for authenticated browser auth, then loads the live BFF snapshot and displays redacted counts/rows
  • Integrations action errors — missing/insufficient admin scope renders an alert instead of silently failing
  • Bulk export archive loading — the panel waits for authenticated browser auth, then loads the live BFF export list and displays counts, release state, and recent archive rows
  • Bulk export action errors — missing/insufficient admin scope renders an alert; release/download actions remain placeholder-disabled until an inspected archive is present and approved

Interactions#

Header band#

  • Eyebrow + heading — non-interactive labels; Playwright asserts the tenant eyebrow, count heading, and action row
  • "Export CSV" — client-side download generated from the same TenantConsoleData.rows rendered in the table; Playwright verifies pacific-theological-members.csv, six canonical columns, and the first / last fixture rows
  • LBtn "Invite" — opens the invite dialog and focuses the email field; successful submit writes to the real BFF, refreshes GET /v1/tenant-console/members, and appends a live invited row for the pending invite
  • LBtn "+ Add member" — opens the add-member dialog and focuses the user-id field; successful submit writes to the real BFF, refreshes GET /v1/tenant-console/members, and appends a live active member row

KPI grid#

  • Each KPI cell — non-interactive numeric display; 5 cells verified

Integrations registry#

  • Snapshot refreshRefresh rereads the live integrations snapshot through the browser API client with the active bearer
  • Create key — submits the typed tenant ID, display name, and scope list to POST /v1/admin/integrations/api-keys; the one-time raw token is shown in the result block while the snapshot read-back remains redacted
  • Create webhook — submits the typed tenant ID, display name, topic pattern, and target URL to POST /v1/admin/integrations/webhooks; the one-time signing secret is shown in the result block while the snapshot read-back remains redacted
  • Simulate webhook — posts the selected subscription, sandbox tenant, topic, and operator-console source payload to POST /v1/admin/integrations/webhooks/simulator; the panel displays the live delivery ID, status, matched/signature-verification attributes, and returned x-oshun-signature

Bulk export release#

  • Refresh — rereads GET /v1/admin/bulk-exports with the active bearer
  • Create export — posts a browser-entered operation ID, tenant ID, and two content rows to POST /v1/admin/bulk-exports; the panel then reads /v1/admin/bulk-exports/:opId and displays record count, manifest hash, file name, content hash, and pending release state
  • Verify manifest — posts POST /v1/admin/bulk-exports/:opId/verify; the panel displays Manifest verified. when the BFF recomputes the archive successfully
  • Approve release — posts POST /v1/admin/bulk-exports/:opId/release-approval with a compliance rationale; the panel updates release state to approved and exposes the approver user ID
  • Download released file — disabled until release is approved; then reads GET /v1/admin/bulk-exports/:opId/files/:fileName as raw text and shows the downloaded content in the result block. Playwright asserts the response hash/residency headers and structured audit events.

Members table#

  • Header row — non-interactive column labels verified by page-DOM
  • Each body row:
    • LCode ID — non-interactive; ID anchors verified for fixture rows
    • Member name — name renders; no email or member-detail link is wired today
    • Plan — non-interactive; Solo / Hearth / Institutional verified
    • Status — pill; active / lapsed / trial / paused verified
    • Sittings — non-interactive count
    • Last seen — non-interactive timestamp
    • Action cell (60 px) — placeholder ellipsis only; no kebab menu opens today. Playwright asserts all eight fixture rows cell-by-cell and verifies no row contains a link/button/tabindex focus target
  • Live mutation rows — after Invite or + Add member succeeds, the client reads the tenant-console cockpit and appends live BFF rows below the fixture register. Playwright asserts row provenance (live), status, member, plan/role, sitting, and last-seen cells for both paths.
  • Keyboard — Playwright asserts the body rows are not keyboard-focusable links or menus today; the only keyboard-driven member actions are the header Invite / + Add member buttons. Row Enter activation is therefore a missing product affordance, not a hidden behavior
  • SR — table semantics; row announces member context

Data & contracts#

  • Reads:
    • Initial console shell — getTenantConsole() from @/lib/lilith-data/operator-depth returning TenantConsoleData (kpi, fixture members table data, tenant context). NOTE: this is a static in-process fixture (returns getTenantConsoleFixture() directly), not a BFF call — the module is headed "fixture today, BFF tomorrow"
    • Post-mutation member refresh — GET /v1/tenant-console/members?tenantId=tenant_oxbow via TenantMemberRegister, using tenant-admin scope to append live BFF member/invite rows after Invite and + Add member writes
  • Writes:
    • Invite member — POST /v1/admin/tenant-console/members/invite via TenantMemberActions
    • Add member — POST /v1/admin/tenant-console/members via TenantMemberActions
    • Export CSV — synchronous browser download via TenantMemberExportCsv from rendered tenant rows; bulk import/dry-run remains a separate unbuilt path
    • Integrations snapshot — GET /v1/admin/integrations/snapshot via TenantIntegrationsPanel
    • Create integration API key — POST /v1/admin/integrations/api-keys via TenantIntegrationsPanel
    • Create webhook subscription — POST /v1/admin/integrations/webhooks via TenantIntegrationsPanel
    • Run sandbox webhook simulator — POST /v1/admin/integrations/webhooks/simulator via TenantIntegrationsPanel
    • Bulk exports list/detail — GET /v1/admin/bulk-exports and GET /v1/admin/bulk-exports/:opId via TenantBulkExportPanel
    • Create bulk export — POST /v1/admin/bulk-exports via TenantBulkExportPanel
    • Verify bulk export — POST /v1/admin/bulk-exports/:opId/verify via TenantBulkExportPanel
    • Approve bulk-export release — POST /v1/admin/bulk-exports/:opId/release-approval via TenantBulkExportPanel
    • Download released bulk-export file — GET /v1/admin/bulk-exports/:opId/files/:fileName via TenantBulkExportPanel
    • Member actions (change plan, suspend, remove) — audit-stamped
  • Realtime: presence updates (last seen) via Psyche channel (verify)
  • Caching: server-rendered; no SW caching
  • Auth/role check: session-gated only (proxy.ts redirects sessionless → /welcome?redirect=); no operator-role gate in the proxy. Per-tenant context is resolved server-side in getTenantConsole()

E2E coverage#

  • apps/oshun/web/e2e/operator-surfaces.spec.ts — signed-in /operator/tenant render, anonymous redirect-before-render, KPI strip values, 8-row member table, all body row cells (id/member/plan/status/sittings/last-seen/action placeholder), Institutional/lapsed/trial/paused fixture variants, absence of row-level links/buttons/focus targets, Export CSV control state/row count, Invite dialog focus, and + Add member dialog focus.
  • apps/oshun/web/e2e/tenant-onboard.spec.ts — downloads the tenant roster CSV and verifies filename, header columns, row count, and representative first/last rows.
  • apps/oshun/web/e2e/tenant-member-actions.spec.ts — deeper write coverage for the same Invite / + Add member controls against the real BFF, including invite token creation, live invited row refresh through GET /v1/tenant-console/members, add-member success, live active row refresh, tenant.member_invite / tenant.member_create audit read-back through GET /v1/tenant-console/audit, invite acceptance, wrong-token/reuse guards, invalid email, and seat capacity.
  • apps/oshun/web/e2e/tenant-integrations-ui.spec.ts — admin-scoped browser session for the /operator/tenant integrations panel, live BFF snapshot load, UI-driven API-key create request/header/body assertions, raw-token non-leakage in snapshot, UI-driven webhook create request/header/body assertions, signing-secret non-leakage in snapshot, and signed sandbox simulator delivery read-back.
  • apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts — admin-scoped browser session for the /operator/tenant bulk-export panel, live BFF export list load, UI-driven export create request/header/body assertions, manifest/file hash rendering, verify action, release approval request/body assertions, released-file download response header assertions, downloaded content rendering, and structured audit-log read-back.

Cross-references#

Open questions / known gaps#

  • Build/document the per-row kebab / context menu action set; current route ships a non-interactive ellipsis placeholder only
  • Confirm whether "Export CSV" generates synchronously or routes to an async job — current route generates a synchronous browser CSV from the rendered roster rows; bulk-export release/download is now live in TenantBulkExportPanel; CSV import and resumable upload remain separate unbuilt paths
  • Document the Invite + Add member flows: the header buttons open dialogs; deeper real-BFF write and live member-row refresh coverage lives in tenant-member-actions.spec.ts
  • Verify how a platform operator switches between tenants — there is no tenant switcher visible in the fixture (page reads tenant context from getTenantConsole()); is the switcher in operator/operator-overview.md or elsewhere?
  • Clarify the relationship between /operator/tenant (this surface) and apps/oshun/tenant-admin/* (a separate app) — which audiences see which?
  • 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)