Skip to content

✨ feat: Audit log UI for SystemGrants changes#52

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

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

Conversation

@dustinhealy
Copy link
Copy Markdown
Contributor

Summary

Adds an Audit Log tab on the Grants page (/grants?tab=audit-log) that surfaces every SystemGrant assign/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 via ?entryId=.

CSV export uses the client-side serializer for ≤500 entries and the server-side streaming endpoint otherwise, with RFC 4180 quoting, formula-injection defang, CRLF line endings, UTF-8 BOM, and localized column headers.

The server function getAuditLogPageFn requires one of manage:users / manage:roles / manage:groups as defense-in-depth above the backend's ACCESS_ADMIN gate. The backend half ships in a parallel PR on LibreChat.

Change Type

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

Testing

Local Vitest suite: bun run test — 569 unit tests pass including new CSV serializer coverage (formula-injection defanging across all six trigger prefixes, BOM, CRLF, non-ASCII round-trip, empty entries, RFC 4180 quoting). TypeScript and ESLint pass; Prettier-clean for every file touched by this PR.

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
  • "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 / Close slides it out
  • Every Copy button (actor, target, capability, timestamp, entry id, permalink) flips to a check icon for ~1.5s and writes the value to clipboard
  • CSV export opens in Excel without corruption (BOM + CRLF) and Excel does not auto-execute prefixed =/+/-/@ cells
  • Deep-link ?entryId=<id> opens the side panel to the matching entry on cold load

Test Configuration:

  • LibreChat backend providing /api/admin/audit-log (see parallel PR)
  • Local Mongo with at least one user holding manage:users, manage:roles, or manage:groups to satisfy the server fn defense-in-depth guard

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.
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