← All stories

COMPONENT (Tier 3) · ui-audit-log-viewer

ui-audit-log-viewer

Component · Tier 3 (compound) Recurrence: 4 stories Composes: ui-data-table, ui-pagination, ui-search-with-filters, ui-date-range-picker, ui-status-pill, ui-modal

Filterable, paginated audit log surface with per-row drill-in. Used wherever a per-entity activity history needs surfacing — team-switch audit, API request log, refund history, activity log report. Hot-tier (queryable, ≤90 days) vs cold-tier (compliance-archived, >90 days) handled transparently.

Component contract

  • scope: AuditScope — { kind: "tenant" | "event" | "entity" | "actor"; id: string }; constrains rows to this scope
  • fetchRows: (filters, page) => Promise<AuditRowsPage> — paginated; returns rows in reverse-chronological order
  • actions: ActionDef[] — closed enum of action types (e.g., "registration_created", "refund_issued", "collaborator_added") with display labels + icons; populates filter dropdown
  • actorKinds?: ActorKindDef[] — distinguishes user / system / api-token / scheduled-job actors; affects row presentation
  • onRowClick?: (row) => void — row drill-in; when omitted, drill-in is the default modal display
  • permissions: { canExport, canViewSensitive } — gates export CTA + sensitive-field redaction
  • onExport?: (filters) => Promise<ExportJobRef> — async export with same filters; returns job ref for tracker

Composition

  • ui-search-with-filters — header row: actor filter, action-kind filter, free-text search, date range
  • ui-date-range-picker — filter; default = last 7 days
  • ui-data-table — rows: timestamp, actor, action (with status pill), context label, drill-in icon
  • ui-status-pill — per-row action category (success / failure / partial / cancelled)
  • ui-pagination — bottom; cold-tier rows show a "loaded from archive" indicator
  • ui-modal — drill-in; full row JSON + before/after diff (where applicable)
  • ui-async-job-tracker — when onExport is invoked, the export job's status is tracked

Interaction surface

  1. Default view: last 7 days, all actions, all actors.

    Reverse-chronological. URL state syncs (?from=...&to=...&action=...&actor=...). Refresh-safe. Empty state distinct from filtered-empty.

  2. Per-row click drills in.

    Modal shows: full timestamp (with timezone), actor (kind + identity), action label, full payload (before/after JSON for state changes), request_id (for API-actor rows), audit row ID (for support). Esc closes; focus returns to row.

  3. Filtering produces visible URL changes.

    Each filter change updates URL state immediately (debounced 300ms via ui-search-with-filters). Bookmarkable, shareable. Refreshing on a filtered URL restores the same filtered view.

  4. Cold-tier rows annotated.

    Filter beyond 90 days fetches from compliance-archived storage; rows display a small "archived" indicator. Slower (banner: "Loading archive — may take a few seconds"). Fully transparent — same row format.

  5. Export CTA opens async-job-tracker.

    When canExport, "Export filtered rows" CTA. Submits an export job with current filter set. Tracker shows pending/running/ready with download URL. Inherits ui-async-job-tracker contract.

Failure modes

Sensitive fields redacted without canViewSensitive

Trigger: drill-in modal opens; user lacks canViewSensitive permission; row contains email or phone.

Sensitive fields rendered as redaction marker: "•••@example.com" or "(redacted)". Server-side: response excludes sensitive values when permission denied. Harness: stub no-permission, drill-in, redacted text visible.

Cold-tier indicator visible

Trigger: filter date range to >90 days ago.

Banner "Loading archive — may take a few seconds." Each row from cold tier has a small "archived" pill. Harness: stub date range >90d, banner + archive-pill visible.

Empty state distinct from filtered-empty

Trigger: scope has 0 audit rows OR filter narrows to 0.

No-rows-ever: "No activity yet for [scope]." Filtered-empty: "No activity matches these filters. Clear filters." Distinct test attributes for each. Harness: each state, distinct selector visible.

URL refresh restores filter state

Trigger: filter to date range + action kind, refresh browser.

URL params restored on mount. fetchRows called with same filters. Same view rendered. Harness: filter + refresh, identical render.

Drill-in renders before/after JSON for state-change rows

Trigger: row is an UPDATE action (e.g., "settings_updated").

Modal shows two-column diff: "Before" and "After" with field-level highlighting. For non-state-change rows (CREATE / DELETE / event-only), shows single payload column. Harness: stub UPDATE row, drill-in shows two columns; CREATE row shows one.

API-actor rows include request ID

Trigger: row's actor_kind = "api-token".

Drill-in displays request_id prominently with copy-to-clipboard. Useful for support tracing API client issues. Harness: drill-in API row, request_id field visible + copy button.

Sort is timestamp-only (server-side)

Trigger: user clicks any column header.

Audit log is reverse-chronological by contract. Other column sorts are NOT supported (would require expensive cold-tier joins). Column headers are not sortable; ui-data-table's sort-toggle is omitted. Harness: column header has no sort affordance.

Permission-gated catalog: scope must match user's reach

Trigger: user with event-organizer role tries to view tenant-level audit.

Server returns 403 if scope.kind exceeds user's reach. UI hides the audit-log nav item for non-permitted scopes. Inherits permission-gated catalog pattern. Harness: stub event-only role + tenant scope, 403 on direct API; nav hidden.

Export job uses same filter set

Trigger: user filters then clicks Export.

Export job created with current filter set as parameters. Generated CSV/JSON contains exactly the rows matching those filters. ui-async-job-tracker monitors. Harness: filter + export, job's filter params match UI state.

Drill-in modal is read-only

Trigger: drill-in modal opens with row payload.

Audit rows are immutable by contract. Drill-in is display-only — no edit affordances, no mutating actions. Harness: drill-in, no buttons that mutate state.

Accessibility

  • Inherits ui-data-table, ui-pagination, ui-search-with-filters, ui-modal a11y contracts.
  • Drill-in modal title is the row's action label + timestamp.
  • Cold-tier banner + archived pill announced via aria-live=polite.
  • Redaction markers have aria-label="redacted (insufficient permission)" so screen readers know it's not missing data.
  • axe-clean ≥ serious across default, filtered, drill-in-open, empty, cold-tier-loading states.

Stable test attributes

data-testWherePurpose
ui-audit-log-viewerOuter wrapperdata-scope-kind + data-scope-id attrs
ui-audit-log-filtersHeader filter rowui-search-with-filters instance
ui-audit-log-tableRowsui-data-table instance
ui-audit-log-rowPer-rowdata-action attr
ui-audit-log-archived-pillPer-row indicatorVisible on cold-tier rows
ui-audit-log-cold-tier-bannerPage headerVisible when filter exceeds 90 days
ui-audit-log-drill-inDrill-in modalRead-only payload + diff
ui-audit-log-redacted-markerDrill-in modalVisible per redacted field
ui-audit-log-request-idDrill-in modalVisible for API-actor rows
ui-audit-log-empty-no-rowsEmpty state"No activity yet"
ui-audit-log-empty-filteredEmpty state"No matches; clear filters"
ui-audit-log-export-ctaPage headerVisible only when canExport
ui-audit-log-export-trackerPer-export jobui-async-job-tracker

Agent test plan

Probe list
- default-view-last-7-days: filter date range = last 7 days
- url-state-syncs: filter changes update URL within 300ms
- url-refresh-restores-filters: filter + refresh, same view
- drill-in-on-row-click: click row, ui-audit-log-drill-in visible
- drill-in-esc-closes: open + Esc, modal closes, row focus restored
- drill-in-update-row-shows-diff: UPDATE action row, two-column diff
- drill-in-create-row-shows-payload: CREATE action row, single payload column
- drill-in-api-row-shows-request-id: api-token actor, request_id visible + copy button
- drill-in-read-only: drill-in has no mutating buttons
- redaction-without-canViewSensitive: stub no-permission, redacted markers visible in drill-in
- cold-tier-indicator: filter > 90d, banner + archived pills visible
- empty-state-no-rows: scope has 0 rows, ui-audit-log-empty-no-rows visible
- empty-state-filtered: filter to 0 rows, ui-audit-log-empty-filtered visible with clear-link
- column-headers-not-sortable: no sort affordance on any column header
- export-uses-current-filters: filter + export, job params match UI
- export-permission-gated: stub no canExport, ui-audit-log-export-cta hidden
- scope-permission-gated-403: stub low-role + high-scope, 403 on direct API
- aria-live-cold-tier: cold-tier banner has aria-live=polite
- axe-clean-across-states: default + filtered + drill-in + empty pass axe ≥ serious

Current consumers

BranchScope kindNotes
EF-002 team-selectiontenantAudit per team-switch action; sensitive fields redacted for non-admin
EF-088 activity-log-reporteventUnioned activity (guest + scan + notification + email); export to CSV/PDF
EF-100 api-access-flagtenant (per-token drill-in)API request log; cold-tier critical (7-year compliance retention)
EF-026 refunds-achentity (registration)Refund + payout history per registration