Component contract
scope: AuditScope— { kind: "tenant" | "event" | "entity" | "actor"; id: string }; constrains rows to this scopefetchRows: (filters, page) => Promise<AuditRowsPage>— paginated; returns rows in reverse-chronological orderactions: ActionDef[]— closed enum of action types (e.g., "registration_created", "refund_issued", "collaborator_added") with display labels + icons; populates filter dropdownactorKinds?: ActorKindDef[]— distinguishes user / system / api-token / scheduled-job actors; affects row presentationonRowClick?: (row) => void— row drill-in; when omitted, drill-in is the default modal displaypermissions: { canExport, canViewSensitive }— gates export CTA + sensitive-field redactiononExport?: (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 rangeui-date-range-picker— filter; default = last 7 daysui-data-table— rows: timestamp, actor, action (with status pill), context label, drill-in iconui-status-pill— per-row action category (success / failure / partial / cancelled)ui-pagination— bottom; cold-tier rows show a "loaded from archive" indicatorui-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
-
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.
-
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.
-
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.
-
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.
-
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-test | Where | Purpose |
|---|---|---|
ui-audit-log-viewer | Outer wrapper | data-scope-kind + data-scope-id attrs |
ui-audit-log-filters | Header filter row | ui-search-with-filters instance |
ui-audit-log-table | Rows | ui-data-table instance |
ui-audit-log-row | Per-row | data-action attr |
ui-audit-log-archived-pill | Per-row indicator | Visible on cold-tier rows |
ui-audit-log-cold-tier-banner | Page header | Visible when filter exceeds 90 days |
ui-audit-log-drill-in | Drill-in modal | Read-only payload + diff |
ui-audit-log-redacted-marker | Drill-in modal | Visible per redacted field |
ui-audit-log-request-id | Drill-in modal | Visible for API-actor rows |
ui-audit-log-empty-no-rows | Empty state | "No activity yet" |
ui-audit-log-empty-filtered | Empty state | "No matches; clear filters" |
ui-audit-log-export-cta | Page header | Visible only when canExport |
ui-audit-log-export-tracker | Per-export job | ui-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
| Branch | Scope kind | Notes |
|---|---|---|
| EF-002 team-selection | tenant | Audit per team-switch action; sensitive fields redacted for non-admin |
| EF-088 activity-log-report | event | Unioned activity (guest + scan + notification + email); export to CSV/PDF |
| EF-100 api-access-flag | tenant (per-token drill-in) | API request log; cold-tier critical (7-year compliance retention) |
| EF-026 refunds-ach | entity (registration) | Refund + payout history per registration |