Skip to content

✨ feat: Audit log UI for SystemGrants changes#52

Open
dustinhealy wants to merge 15 commits into
mainfrom
feat/audit-log-ui
Open

✨ feat: Audit log UI for SystemGrants changes#52
dustinhealy wants to merge 15 commits into
mainfrom
feat/audit-log-ui

Conversation

@dustinhealy
Copy link
Copy Markdown
Contributor

@dustinhealy dustinhealy commented May 12, 2026

Summary

Sibling PR: danny-avila/LibreChat#13087

Do not merge until the LibreChat backend PR is merged. This PR relies on the new endpoints in LibreChat for the audit log to work

Adds an Audit Log tab on the Grants page (/grants?tab=audit-log) that surfaces every SystemGrant assign and revoke event from the LibreChat admin backend. The tab provides faceted filters (action chips, date range, actor name, target name, target type, capability), debounced search across all denormalized name fields, offset-based pagination using the shared <Pagination> component (50 entries per page), and a side panel with copy-to-clipboard buttons for every ID-like field, an optional before/after capability diff, and a permalink that copies a canonical ?tab=audit-log&entryId=... URL.

CSV export goes through the backend's streaming /export.csv endpoint so result sets of any size are handled by a single code path. The client-side auditLogToCsv serializer is kept and tested as the contract the server is expected to satisfy.

The audit log tab is gated on SystemCapabilities.READ_AUDIT_LOG. The tab trigger, content slot, and body render are hidden when the user does not hold that cap. A stale ?tab=audit-log URL on a session without the cap silently falls back to the management tab.

This PR depends on @librechat/data-schemas@0.0.52 (added by the sibling LibreChat PR), which exports the READ_AUDIT_LOG capability. The version pin in package.json has been bumped accordingly. bun install will fail in CI until that version publishes to npm; once it does, this PR can merge.

Change Type

  • New feature (non-breaking change which adds functionality)

Testing

Local Vitest suite: bun run test runs 176 unit tests including CSV serializer coverage (formula-injection defanging with leading whitespace, NBSP, and BOM stripping plus \n and | triggers in addition to = + - @ \t \r, BOM, CRLF, non-ASCII round-trip, empty entries, RFC 4180 quoting), localDayBoundaryIso round-trip for the date filter, and useDebouncedFilter behavior. bun run sort-imports clean (181 files), ESLint clean, TypeScript clean against a locally-linked @librechat/data-schemas from the sibling LC branch.

Manual:

  • Tab to /grants?tab=audit-log renders the empty state when no entries exist
  • Assigning a capability via the Management tab adds a matching audit row within staleTime
  • Numbered pagination navigates and stays in sync with filters (page resets on debounced search and on any non-debounced filter change)
  • Top search partial-matches across actor, target, and capability
  • Action chips support multi-select; clicking a chip toggles it
  • Date pickers filter by the user's local-time day boundary, not UTC midnight
  • "Clear" under the date pickers zeroes both filters and resets the picker visual state
  • Actor and Target inputs partial-match against denormalized names; Capability partial-matches the capability key
  • Target Type select supports keyboard activation of the "All" option
  • Clicking a row opens the side panel with a slide-in animation; clicking outside, Esc, or Close slides it out
  • Every Copy button (actor, target, capability, timestamp, entry id, permalink) flips to a check icon for ~1.5s only on successful clipboard write; failures surface via the screen-reader announcer with com_a11y_copy_failed
  • The permalink copies a canonical ${origin}/grants?tab=audit-log&entryId=... URL, not the current filter-laden href
  • CSV export opens in Excel without corruption (BOM + CRLF) and Excel does not auto-execute formula-prefixed cells
  • Deep-link ?entryId=<id> opens the side panel on cold load even when the entry is not on the current page (a single-entry fetch via getAuditLogEntryFn populates the drawer). When the entry does not exist, the drawer renders a "not found" empty state instead of staying empty
  • A user with ACCESS_ADMIN but without READ_AUDIT_LOG does not see the audit log tab; navigating directly to ?tab=audit-log falls back to the management tab

Test Configuration

  • LibreChat backend providing /api/admin/audit-log (see parallel PR)
  • @librechat/data-schemas@0.0.52 (published by the sibling PR) for SystemCapabilities.READ_AUDIT_LOG
  • Local Mongo

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes
  • Any changes dependent on mine have been merged and published in downstream modules.

Wire the audit-log tab into the grants page, switch the server function from a stub to a real /api/admin/audit-log call with filter query params, generate the CSV client-side from already-fetched entries, and add unit coverage for the audit log utilities.
…er validation

Defang CSV formula injection (CWE-1236) with leading-quote escape for cells beginning with =/+/-/@/tab/CR, switch to CRLF line endings, prepend UTF-8 BOM, and emit localized headers via a new auditLogToCsv(entries, localize) signature.

Migrate the audit log tab UI to click-ui: ButtonGroup for the action filter, DatePicker for date inputs, Button for export, Badge with state="success"/"danger"/"neutral" for action and principal-type pills (fixes the failing 4.5:1 contrast on the prior badge-success class).

Fix the focus-loss bug where the search input unmounted on every keystroke: drop the isLoading early-return, debounce search at 300ms, render LoadingState inline within the table body, and handle the isError case explicitly.

Wire useAnnouncement + ScreenReaderAnnouncer so filter changes announce the result count to assistive tech; give SearchInput a proper aria-label; rename the entry-count plural keys to the i18next v25 _zero/_one/_other suffix convention; harden the CSV blob download for Safari/Firefox via appendChild plus a deferred URL.revokeObjectURL.

Server-side: add a requireAnyCapability defense-in-depth guard, tighten the Zod schema with ISO date validation and a 200-char cap on search, parse the response body via Zod, bump staleTime to 60s, and add placeholderData: keepPreviousData so filter changes don't flash empty.
…wer, CSP, click-ui

Server: paginated getAuditLogPageFn with cursor/limit + multi-action + facet params (actorId, targetPrincipalType, targetPrincipalId, capability), Zod-parsed response schema, auditLogInfiniteQueryOptions factory for useInfiniteQuery, exportAuditLogServerFn that proxies the backend CSV endpoint, all behind the same triple-capability defense-in-depth guard.

UI: new AuditLogDetailDrawer (click-ui Flyout) renders the full entry with copyable IDs and before/after diff highlighted via Badge state. Local AuditLogEntryWithDiff type carries optional before/after arrays until the data-schemas package upstreams the fields.

Parser: parseAuditSearch handles actor: / target: / capability: / created:>YYYY-MM-DD qualifiers with quoted multi-word values, falling back to free text for unknown keys. diffGrantState reports added/removed/unchanged sets.

Click-ui migration: GrantTableRow and EditCapabilitiesDialog now use Badge state for status pills and the principal-type chip; deleted unused badge-success and badge-danger CSS classes from styles.css. GrantManagementTab keeps its raw table for now since click-ui Table does not support per-row tabIndex/role/onKeyDown/ref (documented inline).

Security: Content-Security-Policy plus X-Content-Type-Options, Referrer-Policy, X-Frame-Options on every HTML response, with HSTS gated on production. Inline filter action wrapped in an array to match the new multi-action server schema (batch B will replace this filter UI entirely).
…arch, permalinks

Replace useQuery with useInfiniteQuery against auditLogInfiniteQueryOptions so audit log pages on demand via cursor pagination — both a manual Load more button and an IntersectionObserver sentinel auto-load when the bottom row scrolls into view. The legacy single-shot getAuditLogFn and auditLogQueryOptions are gone.

Multi-select action facet via click-ui CheckboxMultiSelect plus four faceted text/select filters (actor ID, target ID, target principal type, capability) collapsed behind a "More filters" disclosure with debounced inputs.

Structured search runs the live input through parseAuditSearch on every debounce tick, extracts actor: / target: / capability: / created:>YYYY-MM-DD qualifiers, and renders each one as a dismissible Badge chip; clicking a chip regex-strips the corresponding token from the input. Qualifiers override the manual facet inputs when both are present.

Row click and Enter/Space activation set ?entryId= on the route via TanStack Router; the matching entry opens in the AuditLogDetailDrawer with copy-permalink and Esc-to-close semantics. validateSearch on /_app/grants is extended so the param survives tab switches.

Dual-mode CSV export: client-side auditLogToCsv for ≤500 loaded entries, server-side exportAuditLogServerFn for larger result sets or when more pages remain. Filter changes announce the result count via ScreenReaderAnnouncer, and Load More announces page-loaded count for assistive tech.
…r, dead-code purge

Replace the cursor-based useInfiniteQuery with offset-based useQuery + placeholderData: keepPreviousData and the shared numbered Pagination component, matching the GroupsTab pattern; debounced filter setters reset the page in the same callback so search and pagination stay in sync.

Drop the qualifier-parser and the disclosure-collapsed More-filters block; the four facet fields (Actor, Target, Target type, Capability) sit always-visible and partial-match against denormalized name fields on the backend. Top search box is plain regex-substring across actor, target, and capability.

Replace click-ui Flyout with @radix-ui/react-dialog directly for the side panel so enter and exit animations actually play, driven by data-state keyframes added to styles.css. Every ID-like field in the drawer gets a CopyableMono button with per-button copied feedback. Each DatePicker renders a single tab stop and the shared danger-styled Clear button resets both date inputs together.

Delete the unused AuditLogRow.tsx, the parseAuditSearch parser plus its types and tests, the dead ACTION_FILTER_LABELS and AUDIT_ACTION_FILTERS exports, the diffGrantState helper, and the locale keys left over from the load-more / qualifier-chip iteration. Net 383 lines deleted.
TanStack Start's SSR injects an inline `<script type="module">import("...")</script>`
into the root HTML to boot the client. The previous enforced policy of
`script-src 'self'` (no nonce, no `'unsafe-inline'`) would cause browsers to refuse
that inline script in production, breaking hydration before any UI rendered. Local
`bun run dev` never exercises `server.ts`, so the regression hid in plain sight.

Threading a per-request nonce through TanStack Start's manifest is non-trivial.
As an interim, the policy now ships as `Content-Security-Policy-Report-Only` so
violations still surface in browser devtools and reporting endpoints without
blocking hydration. Set `ADMIN_PANEL_CSP_ENFORCE=true` to flip back to enforcement
once the nonce wiring lands.
`useLocalize` returned a fresh closure on every render, so any effect that
listed it in its deps array re-fired every render. In `AuditLogTab` that was
the screen-reader announce effect, causing assistive tech to be spammed every
time React reconciled the component. Wrapping the closure in `useCallback`
keyed on `translate` keeps the function identity stable across renders while
still picking up language changes.
The previous prefix regex `^[=+\-@\t\r]` missed payloads that lead with
whitespace before the formula trigger (e.g. ` =SUM(...)`), payloads that start
with `\n` or `|` (the latter is Excel's DDE invocation marker), and Unicode
decoy characters such as NBSP and BOM that spreadsheets render as zero-width
but JavaScript's `\s` does not always cover symmetrically. The defang now
treats a value as dangerous if either its first character is a trigger or if
the first character after stripping space/NBSP/BOM is a trigger; stripping the
entire `\s` class would falsely accept payloads led by `\r` / `\n` / `\t`,
which are themselves triggers.

Local-day date helpers (`isoDateToDate`, `dateToIsoDate`, `localDayBoundaryIso`)
also moved here so the timezone fix in `AuditLogTab` can be unit tested in
isolation; new cases cover round-trips, rolled-over input rejection, and both
start/end boundaries.
Each `getAuditLogPageFn` / `exportAuditLogServerFn` invocation previously did
two round-trips: `requireAnyCapability` would call `getEffectiveCapabilitiesFn`,
then the handler would call the audit-log endpoint. Pagination doubled the
backend traffic of the whole tab.

Handlers now fetch capabilities once via a new `guardAuditLogAccess` helper and
run `checkAnyCapability` against the in-memory list. `checkAnyCapability` is
exposed so future server functions can adopt the same pattern; `requireAnyCapability`
is implemented in terms of it to keep behaviour identical for unchanged callers.

Adds `getAuditLogEntryFn` and `auditLogEntryQueryOptions` so the UI can deep-link
to entries that aren't on the current page. The endpoint returns `{ entry: null }`
for 404 so callers can render an explicit "not found" state without crashing.
Four near-identical debounced-text-filter handlers in `AuditLogTab` collapsed
into a single hook that owns the controlled value, the debounced commit value,
and timer cleanup. The optional `onCommit` callback fires once per quiescent
settle so callers can reset pagination or log analytics without re-rolling
their own ref/`setTimeout` plumbing.
Drawer permalinks no longer silently fail for entries off the current page.
When `?entryId=` points at a row that isn't in `pageEntries`, the tab falls
back to `getAuditLogEntryFn` via React Query and renders the drawer from
either the on-page row or the fetched record. A new not-found state in
`AuditLogDetailDrawer` surfaces the case where the id is gone instead of
leaving the drawer empty.

CSV export now always hits the backend. The previous client/server split
truncated CSVs whenever a result set had between 51 and 500 matching rows:
the client path serialized at most one page (`AUDIT_LOG_PAGE_SIZE = 50`) but
the threshold for switching to the server endpoint was 500. Pulling the
client path keeps `auditLogToCsv` (and its tests) as the contract the server
is expected to honor, and removes the now-unused `com_audit_export_client`
translation key.

Clipboard writes for the permalink button and the inline copyable cells now
await the promise and only flip to the "Copied!" affordance on success.
Permission-denied, HTTP-origin, and `navigator.clipboard === undefined` paths
all surface via the existing `ScreenReaderAnnouncer` with a new
`com_a11y_copy_failed` key. The permalink itself is now built from
`window.location.origin` + the canonical `/grants?tab=audit-log&entryId=…`
shape so copied links don't carry the current filter state.

Filter pages now use `useDebouncedFilter` instead of four ad-hoc handlers.
`DatePickerCell`'s `useEffect` no longer re-runs every render; the comment
captures *why* the workaround exists so future readers don't strip it. Date
filters now anchor at local-day boundaries (`localDayBoundaryIso`) instead of
mixing UTC midnight with local-time picker values, fixing off-by-one filter
results for any non-UTC user. `pageEntries` is memoized to avoid being a
fresh array each render.

Dead `com_audit_filter_*` translation keys from the qualifier-parser cleanup
are removed.
The LibreChat backend already enforces ACCESS_ADMIN on every /api/admin/audit-log
route, and any future tightening (e.g. a dedicated READ_AUDIT_LOG capability)
belongs there. The BFF-layer guard was running an extra /effective round-trip on
every page request without buying real protection, since the backend would
reject the same callers we did. It was also inconsistent with GrantManagementTab,
which sits on the same page and already calls getAllGrantsFn with no BFF guard.

Removes guardAuditLogAccess, AUDIT_LOG_REQUIRED_CAPS, and the three call sites
in getAuditLogPageFn / getAuditLogEntryFn / exportAuditLogServerFn. The
checkAnyCapability helper extracted in eef26ce stays — it's still used by
requireAnyCapability and is useful on its own.
The audit-log tab was visible to anyone who could reach the Grants page (i.e.
anyone with `ACCESS_ADMIN`). With the LibreChat backend now requiring
`READ_AUDIT_LOG` on `/api/admin/audit-log`, users without that grant will hit
a 403 if they click the tab — surfacing the right backend policy but a bad UX.

The tab trigger, panel slot, and body render are all gated on
`hasCapability('read:audit_log')` via the existing `useCapabilities` hook,
which reads from the cached effective-capabilities lookup the sidebar already
uses. A stale `?tab=audit-log` URL on a session that lost the cap silently
falls back to management rather than rendering an empty page.

`READ_AUDIT_LOG_CAPABILITY` lives in `@/constants` as a forward-compat string
constant until the `@librechat/data-schemas` dependency bumps to a version
that exports it from `SystemCapabilities`.
LC PR #13087 adds READ_AUDIT_LOG to @librechat/data-schemas, so the local
forward-compat constant is just a string-literal detour. Removed
READ_AUDIT_LOG_CAPABILITY and updated GrantsPage to read the cap from
SystemCapabilities.READ_AUDIT_LOG.

This commit will not typecheck against the currently-published data-schemas
0.0.48 (the cap does not exist there). That is the intended state until LC
merges, data-schemas re-publishes, and this PR's package.json pin is bumped
as a final commit. Until then, local verification via `bun link` against the
LC checkout exercises the full path.

Also adds the picker labels:
com_cap_read_audit_log and com_cap_desc_read_audit_log so the System
category renders the toggle and tooltip correctly once data-schemas
publishes with the cap in CAPABILITY_CATEGORIES.
This PR depends on the READ_AUDIT_LOG capability added in
danny-avila/LibreChat#13087. Pinning ahead of publication: the install will
fail until the LC PR merges and data-schemas is republished, at which point
bun install populates the lockfile and CI goes green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant