V1 Web PWA · Surface walkthrough

Operator · audit log

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

walked
9sections7 minread

On this page

Context. surface operator · domain operator (house — audit/event log) · route /operator/audit · auth signed-in · source apps/oshun/web/src/app/operator/audit/page.tsx

Last walked. 2026-06-25 audit ledger row-cell addendum — direct signed-in /operator/audit render, generatedAt/cursor anchors, exact 3-row masthead/table count, real /v1/audit fixture rows sorted newest-first, row metadata attributes, visible When/Actor/Action/Target cells, footer targets, explorer mount, and anonymous redirect-before-render verified in tenant-audit-log-investigation.spec.ts. 2026-06-26 addendum — explorer Export Markdown is now browser-driven through the real BFF export route and the downloaded Markdown body is asserted. 2026-06-26 meta-audit addendum — investigation create/update/share/export/ delete writes audit.investigation.* rows and the real-HTTP spec reads the chain back through the explorer event API. Evidence: WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §90.

Purpose#

The append-only audit ledger: the most recent rows from GET /v1/audit, newest first. Every actor, action, and target laid out in a mono table for investigation — never edited in place. The static ledger is followed by the OperatorAuditExplorer, which provides search/filter, save-as-investigation, and export/delete controls over the live /v1/admin/audit-log/* investigation store.

Entry points#

  • From /operator (hub) — operator-surface card; the foot row's "← Operator hub" returns there
  • Cross-links from sibling operator pages/operator/incidents and /operator/sso foot rows both link to the audit log; /operator/personas cross-links back here through its audit affordance
  • From /operator/incidents during a triage — investigating an incident's actor trail leads here
  • Direct URL — session-gated only (proxy.ts redirects sessionless → /welcome?redirect=); no operator-role gate in the proxy
  • Bookmarkcanonical = '/operator/audit'

Layout regions#

page.tsx is an async server component: await bffGet<AuditResponse>('/v1/audit') (falling back to { events: [] } on null), client-side re-sorted newest-first by occurredAtIso, rendered inside <LWebShell top={<LCustomerNav active="library" />}> (note: it reuses the customer nav with Library active, not an operator-specific bar). Centered column maxWidth: 1080.

  • Header: LCustomerNav (Library highlighted — verify whether an operator shell bar is intended instead)
  • Page anchor: outer content column exposes data-operator-audit-page, data-operator-audit-generated-at, and data-operator-audit-cursor
  • Masthead: LMasthead wrapped by data-operator-audit-masthead with data-operator-audit-row-count; visible copy is left "Operator · audit", right ${sorted.length} rows shown, kicker "Append-only", title "The audit ledger."
  • Ledger: a mono <table data-operator-audit-table> (When / Actor / Action / Target) with data-operator-audit-table-count OR the empty card
    • <thead>: Th cells (uppercase eyebrow style) — When, Actor, Action, Target, with scope="col" and data-operator-audit-column
    • <tbody>: one <tr data-operator-audit-row> per event; Td cells — local timestamp, actorId, accent-colored action, and targetKind:targetId; row attributes expose data-operator-audit-id, data-operator-audit-actor, data-operator-audit-action, data-operator-audit-target-kind, data-operator-audit-target-id, and data-operator-audit-occurred-at; visible cells expose data-operator-audit-cell="when" | "actor" | "action" | "target"
  • Explorer: <OperatorAuditExplorer data-audit-explorer> — filter fields for text, event type prefix, actor ids, workspace ids, and time window; search and reset actions; save-as-investigation; saved investigation load/export JSON/export Markdown/delete controls; event result rows with optional deep links
  • Footer action row: LBtn ghost "← Operator hub" (/operator), LBtn ghost "Incidents" (/operator/incidents), LBtn ghost "Personas" (/operator/personas), LBtn primary "SSO" (/operator/sso)

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/audit redirects to /welcome?... before [data-operator-audit-page] or [data-operator-audit-table] render
  • Signed-in user — Playwright 2026-06-25: primed signed-in session renders the page, generatedAt/cursor anchors, exact row counts, ledger, footer, and explorer. Still session-gated only; no operator-role gate in the proxy or in-page guard
  • Empty — source-verified 2026-06-25: sorted.length === 0 renders [data-operator-audit-empty] with "No audit events available for the current cursor." Masthead right reads "0 rows shown"
  • Populated (short) — Playwright 2026-06-25: real /v1/audit fixture renders exactly three rows, newest first: evt-3 tenant member added, evt-2 SSO connection activated, and evt-1 incident acknowledged
  • Populated (long) — table with the full page of rows; no pagination control despite data.cursor — only the rows returned by one /v1/audit call are shown (verify the server-side page size and how to advance the cursor)
  • BFF unreachable / non-2xx — source-verified 2026-06-25: bffGet returns null; the page substitutes { cursor: null, events: [] } → empty state (no fabricated rows). Dedicated outage-route E2E still pending
  • Offline — RSC fetch (revalidate 60); SW behavior for /operator/* should be network-only (verify SW excludes operator routes)
  • Standalone PWA — renders; operator surfaces are robots-noindex elsewhere — verify this route's robots policy (no robots export in this file)

Interactions#

Masthead#

  • Masthead labels — non-interactive; Playwright asserts "Operator · audit", "Append-only", "The audit ledger.", exact "3 rows shown", and the matching stable row-count attribute

Audit table#

  • Header row (<thead>/Th) — non-interactive; not sortable (sort is fixed newest-first in code); scope="col" is present and Playwright asserts the four columnheader names plus stable column identifiers
  • Each body row (<tr>) — non-interactive display: When (localized occurredAtIso), Actor (actorId), Action (accent text action), Target (targetKind:targetId). Playwright asserts all three real fixture rows in newest-first order with stable id/actor/action/target-kind/target-id/ occurred-at attributes and visible When/Actor/Action/Target cells. No row click / drill-in wired
    • Screen reader: announces cell contents in reading order; verify the targetKind:targetId reads clearly
  • "← Operator hub" (LBtn ghost, link) → /operator
  • "Incidents" (LBtn ghost, link) → /operator/incidents
  • "Personas" (LBtn ghost, link) → /operator/personas
  • "SSO" (LBtn primary, link) → /operator/sso

Audit explorer#

  • Filter fields — text, event type prefix, actor ids, workspace ids, and datetime range fields render below the static ledger
  • Search — Playwright 2026-06-10/2026-06-25: needle and actor filters land on the real /v1/admin/audit-log/events request URL through the live BFF bridge
  • Save investigation — Playwright creates a named investigation through the UI and asserts it appears in the saved-investigations list
  • Export JSON — Playwright triggers a real Blob download for the saved investigation
  • Export Markdown — Playwright 2026-06-26: the browser clicks the explorer control, the live BFF bridge serves GET /v1/admin/audit-log/investigations/:id/export?format=markdown, and the downloaded .md body includes the investigation title, created/updated provenance line, and ## Matched events (...)
  • Delete investigation — Playwright deletes through the UI and verifies both the row removal and API read-back absence

Data & contracts#

  • Reads: bffGet<AuditResponse>('/v1/audit'){ generatedAt: string; cursor: string | null; events: AuditEvent[] } where AuditEvent = { id, actorId, action, targetKind, targetId, occurredAtIso }. On null substitutes an empty list
  • Writes: the static ledger is read-only. The explorer writes investigation records through /v1/admin/audit-log/investigations; successful create/update/share/export/delete also append audit.investigation.created | updated | shared | exported | deleted rows to the same admin audit-event store. The share event records only token issuance/hash presence, never the raw share token
  • Realtime: None observed.
  • Caching: RSC fetch, next.revalidate = 60, 5 s abort; SW policy for operator routes TBD
  • Auth/role check: session-gated only (proxy.ts redirects sessionless → /welcome?redirect=); no operator-role gate in the proxy and no in-page guard. bffGet forwards the session cookie

Cross-references#

E2E coverage#

  • apps/oshun/web/e2e/tenant-audit-log-investigation.spec.ts — direct /operator/audit signed-in render, real /v1/audit fixture table rows sorted newest-first, generatedAt/cursor anchors, exact 3-row masthead/table count, column headers and column identifiers, stable row id/actor/action/target-kind/target-id/occurred-at attributes, visible When/Actor/Action/Target cells, footer targets (/operator, /operator/incidents, /operator/personas, /operator/sso), /operator/incidents "Audit log" entry-point navigation, /operator/admin audit-entry affordance, anonymous redirect-before-render, real-HTTP BFF audit-log access-control/CRUD/export/share/bookmark legs, and explorer UI filter/save/export-json/export-markdown/delete over the live BFF bridge. The same spec's real-HTTP leg also verifies the investigation meta-audit chain (audit.investigation.created/updated/shared/exported/deleted), confirms invalid create and duplicate delete emit no phantom audit row, and asserts raw share tokens are not present in emitted payloads.
  • Remaining route-specific E2E gaps are the static ledger empty/outage branch, long/cursor pagination behavior, standalone/offline/SW policy, and the product gaps listed below.

Open questions / known gaps#

  • data.cursor is read but never surfaced; there is no "load more" / next-page control — document how an investigator reaches older rows
  • This operator route renders the customer LCustomerNav (Library active) rather than an operator bar; confirm that's intended chrome
  • No robots: { index: false } export here unlike /operator/admin; verify operator routes are kept out of SEO at the proxy/layout level
  • Confirm SW caching policy for /operator/* (expected network-only)
  • 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)