From 53c927fe2e542403fd80c891392726bf6f35d81b Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 11 May 2026 15:51:02 -0700 Subject: [PATCH 01/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Audit=20log=20UI=20fo?= =?UTF-8?q?r=20SystemGrants=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/grants/AuditLogTab.tsx | 32 +++----- src/components/grants/GrantsPage.tsx | 24 +++--- src/components/grants/auditLogUtils.test.ts | 89 +++++++++++++++++++++ src/components/grants/auditLogUtils.ts | 28 +++++++ src/components/grants/index.ts | 2 +- src/server/capabilities.ts | 27 +++++-- 6 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 src/components/grants/auditLogUtils.test.ts diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index b1884eb..d703777 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -3,8 +3,8 @@ import { useQuery } from '@tanstack/react-query'; import { useState, useMemo, useCallback } from 'react'; import type * as t from '@/types'; import { EmptyState, LoadingState, SearchInput } from '@/components/shared'; -import { auditLogQueryOptions, exportAuditLogCsvFn } from '@/server'; -import { ACTION_FILTER_LABELS } from './auditLogUtils'; +import { ACTION_FILTER_LABELS, auditLogToCsv } from './auditLogUtils'; +import { auditLogQueryOptions } from '@/server'; import { AuditLogRow } from './AuditLogRow'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -15,7 +15,6 @@ export function AuditLogTab() { const [actionFilter, setActionFilter] = useState('all'); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); - const [exporting, setExporting] = useState(false); const filters = useMemo( () => ({ search: search || undefined, @@ -36,21 +35,16 @@ export function AuditLogTab() { setActionFilter(filter); }, []); - const handleExport = useCallback(async () => { - setExporting(true); - try { - const { csv } = await exportAuditLogCsvFn({ data: filters }); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(url); - } finally { - setExporting(false); - } - }, [filters]); + const handleExport = useCallback(() => { + const csv = auditLogToCsv(entries); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [entries]); if (isLoading) { return ; @@ -119,7 +113,7 @@ export function AuditLogTab() { - ))} +
+
- - setDateFrom(e.target.value)} - className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-1.5 text-sm text-(--cui-color-text-default) transition-colors focus-visible:ring-2 focus-visible:ring-(--cui-color-stroke-active) focus-visible:outline-none" + + setDateFrom(d ? dateToIsoDate(d) : '')} + placeholder={localize('com_audit_date_from')} />
- - setDateTo(e.target.value)} - className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-1.5 text-sm text-(--cui-color-text-default) transition-colors focus-visible:ring-2 focus-visible:ring-(--cui-color-stroke-active) focus-visible:outline-none" + + setDateTo(d ? dateToIsoDate(d) : '')} + placeholder={localize('com_audit_date_to')} />
- + label={localize('com_audit_export_csv')} + />
- {entries.map((entry, i) => ( + {showLoading && ( + + + + + + )} + {!showLoading && isError && ( + + + + + + )} + {!showLoading && !isError && entries.map((entry, i) => ( ))} - {entries.length === 0 && ( + {!showLoading && !isError && entries.length === 0 && ( @@ -173,10 +211,10 @@ export function AuditLogTab() {

- {localize(entries.length === 1 ? 'com_audit_entry_count' : 'com_audit_entry_count_plural', { - count: entries.length, - })} + {localize('com_audit_entry_count', { count: entries.length })}

+ + ); } diff --git a/src/components/grants/auditLogUtils.test.ts b/src/components/grants/auditLogUtils.test.ts index cadf9bb..47a3bc0 100644 --- a/src/components/grants/auditLogUtils.test.ts +++ b/src/components/grants/auditLogUtils.test.ts @@ -1,7 +1,16 @@ import { describe, it, expect } from 'vitest'; import { PrincipalType } from 'librechat-data-provider'; import type { AdminAuditLogEntry } from '@librechat/data-schemas'; -import { ACTION_FILTER_LABELS, auditLogToCsv, capabilityLabel, formatTimestamp } from './auditLogUtils'; +import { + ACTION_BADGE_STATE, + ACTION_FILTER_LABELS, + AUDIT_ACTION_FILTERS, + auditLogToCsv, + capabilityLabel, + formatTimestamp, +} from './auditLogUtils'; + +const UTF8_BOM = ''; const sampleEntry: AdminAuditLogEntry = { id: 'a1', @@ -15,6 +24,11 @@ const sampleEntry: AdminAuditLogEntry = { timestamp: '2026-05-10T14:30:00.000Z', }; +const identityLocalize = (k: string) => k; + +const expectedHeader = + 'com_audit_csv_col_timestamp,com_audit_csv_col_action,com_audit_csv_col_actor,com_audit_csv_col_actor_id,com_audit_csv_col_target_type,com_audit_csv_col_target_id,com_audit_csv_col_target_name,com_audit_csv_col_capability'; + describe('ACTION_FILTER_LABELS', () => { it('maps every filter value to a locale key', () => { expect(ACTION_FILTER_LABELS.all).toBe('com_audit_filter_all'); @@ -23,6 +37,19 @@ describe('ACTION_FILTER_LABELS', () => { }); }); +describe('AUDIT_ACTION_FILTERS', () => { + it('exposes the ordered filter list', () => { + expect(AUDIT_ACTION_FILTERS).toEqual(['all', 'grant_assigned', 'grant_removed']); + }); +}); + +describe('ACTION_BADGE_STATE', () => { + it('maps each audit action to a badge state', () => { + expect(ACTION_BADGE_STATE.grant_assigned).toBe('success'); + expect(ACTION_BADGE_STATE.grant_removed).toBe('danger'); + }); +}); + describe('formatTimestamp', () => { it('produces a non-empty localized string for valid ISO input', () => { const out = formatTimestamp('2026-05-10T14:30:00.000Z'); @@ -33,6 +60,11 @@ describe('formatTimestamp', () => { it('falls back to the input string when the date is invalid', () => { expect(formatTimestamp('not-a-date')).toBe('not-a-date'); }); + + it('accepts a locale override', () => { + const out = formatTimestamp('2026-05-10T14:30:00.000Z', 'en-US'); + expect(out.length).toBeGreaterThan(0); + }); }); describe('capabilityLabel', () => { @@ -42,8 +74,7 @@ describe('capabilityLabel', () => { }); it('returns the raw capability when no locale match is found', () => { - const localize = (key: string) => key; - expect(capabilityLabel('custom:unknown', localize)).toBe('custom:unknown'); + expect(capabilityLabel('custom:unknown', identityLocalize)).toBe('custom:unknown'); }); it('converts all colons in the capability to underscores in the lookup key', () => { @@ -59,21 +90,20 @@ describe('capabilityLabel', () => { describe('auditLogToCsv', () => { it('emits a header row and one row per entry', () => { - const csv = auditLogToCsv([sampleEntry]); - const lines = csv.split('\n'); + const csv = auditLogToCsv([sampleEntry], identityLocalize); + expect(csv.startsWith(UTF8_BOM)).toBe(true); + const body = csv.slice(UTF8_BOM.length); + expect(body.endsWith('\r\n')).toBe(true); + const lines = body.replace(/\r\n$/, '').split('\r\n'); expect(lines.length).toBe(2); - expect(lines[0]).toBe( - 'timestamp,action,actorId,actorName,targetPrincipalType,targetPrincipalId,targetName,capability', - ); + expect(lines[0]).toBe(expectedHeader); expect(lines[1]).toContain('Alice Admin'); expect(lines[1]).toContain('manage:configs'); expect(lines[1]).toContain('grant_assigned'); }); it('returns only the header for an empty entry list', () => { - expect(auditLogToCsv([])).toBe( - 'timestamp,action,actorId,actorName,targetPrincipalType,targetPrincipalId,targetName,capability', - ); + expect(auditLogToCsv([], identityLocalize)).toBe(UTF8_BOM + expectedHeader + '\r\n'); }); it('quotes and escapes cells containing commas, quotes, or newlines', () => { @@ -82,8 +112,60 @@ describe('auditLogToCsv', () => { actorName: 'Alice, "the admin"', targetName: 'Line1\nLine2', }; - const csv = auditLogToCsv([tricky]); + const csv = auditLogToCsv([tricky], identityLocalize); expect(csv).toContain('"Alice, ""the admin"""'); expect(csv).toContain('"Line1\nLine2"'); }); + + it('starts with a UTF-8 BOM', () => { + const csv = auditLogToCsv([sampleEntry], identityLocalize); + expect(csv.charCodeAt(0)).toBe(0xfeff); + }); + + it('uses CRLF line endings with a trailing CRLF', () => { + const csv = auditLogToCsv([sampleEntry, sampleEntry], identityLocalize); + const body = csv.slice(UTF8_BOM.length); + expect(body.endsWith('\r\n')).toBe(true); + const lines = body.slice(0, -2).split('\r\n'); + expect(lines.length).toBe(3); + }); + + it('preserves non-ASCII content through a CSV round trip', () => { + const entry: AdminAuditLogEntry = { + ...sampleEntry, + actorName: 'Müller', + targetName: '日本語', + }; + const csv = auditLogToCsv([entry], identityLocalize); + expect(csv).toContain('Müller'); + expect(csv).toContain('日本語'); + }); + + describe('CSV formula-injection defanging', () => { + const prefixes: Array<{ name: string; char: string }> = [ + { name: 'equals', char: '=' }, + { name: 'plus', char: '+' }, + { name: 'minus', char: '-' }, + { name: 'at', char: '@' }, + { name: 'tab', char: '\t' }, + { name: 'carriage-return', char: '\r' }, + ]; + + for (const { name, char } of prefixes) { + it(`prepends a single quote to actorName starting with ${name}`, () => { + const payload = `${char}HYPERLINK("evil")`; + const malicious: AdminAuditLogEntry = { + ...sampleEntry, + actorName: payload, + }; + const csv = auditLogToCsv([malicious], identityLocalize); + const guarded = `'${payload}`; + const expectedCell = /[",\n\r]/.test(guarded) + ? `"${guarded.replace(/"/g, '""')}"` + : guarded; + expect(csv).toContain(expectedCell); + expect(csv).not.toContain(`,${payload},`); + }); + } + }); }); diff --git a/src/components/grants/auditLogUtils.ts b/src/components/grants/auditLogUtils.ts index 07b1aca..aab80f4 100644 --- a/src/components/grants/auditLogUtils.ts +++ b/src/components/grants/auditLogUtils.ts @@ -1,4 +1,4 @@ -import type { AdminAuditLogEntry } from '@librechat/data-schemas'; +import type { AdminAuditLogEntry, AuditAction } from '@librechat/data-schemas'; import type * as t from '@/types'; export const ACTION_FILTER_LABELS: Record = { @@ -7,20 +7,43 @@ export const ACTION_FILTER_LABELS: Record = { grant_removed: 'com_audit_filter_removed', }; -const CSV_COLUMNS: readonly (keyof AdminAuditLogEntry)[] = [ - 'timestamp', - 'action', - 'actorId', - 'actorName', - 'targetPrincipalType', - 'targetPrincipalId', - 'targetName', - 'capability', -]; - -export function formatTimestamp(iso: string): string { +export const AUDIT_ACTION_FILTERS: readonly t.ActionFilter[] = [ + 'all', + 'grant_assigned', + 'grant_removed', +] as const; + +export const ACTION_BADGE_STATE: Record = { + grant_assigned: 'success', + grant_removed: 'danger', +}; + +const CSV_COLUMNS = [ + { key: 'timestamp', labelKey: 'com_audit_csv_col_timestamp' }, + { key: 'action', labelKey: 'com_audit_csv_col_action' }, + { key: 'actorName', labelKey: 'com_audit_csv_col_actor' }, + { key: 'actorId', labelKey: 'com_audit_csv_col_actor_id' }, + { key: 'targetPrincipalType', labelKey: 'com_audit_csv_col_target_type' }, + { key: 'targetPrincipalId', labelKey: 'com_audit_csv_col_target_id' }, + { key: 'targetName', labelKey: 'com_audit_csv_col_target_name' }, + { key: 'capability', labelKey: 'com_audit_csv_col_capability' }, +] as const satisfies readonly { key: keyof AdminAuditLogEntry; labelKey: string }[]; + +type _CsvColumnsExhaustive = Exclude< + keyof AdminAuditLogEntry, + 'id' | (typeof CSV_COLUMNS)[number]['key'] +> extends never + ? true + : never; +const _csvColumnsExhaustive: _CsvColumnsExhaustive = true; +void _csvColumnsExhaustive; + +const FORMULA_PREFIX = /^[=+\-@\t\r]/; +const UTF8_BOM = ''; + +export function formatTimestamp(iso: string, locale: string | undefined = undefined): string { try { - return new Intl.DateTimeFormat(undefined, { + return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric', @@ -40,16 +63,20 @@ export function capabilityLabel(cap: string, localize: (key: string) => string): function escapeCsvCell(value: string): string { if (value === '') return ''; - if (/[",\n\r]/.test(value)) { - return `"${value.replace(/"/g, '""')}"`; + const guarded = FORMULA_PREFIX.test(value) ? `'${value}` : value; + if (/[",\n\r]/.test(guarded)) { + return `"${guarded.replace(/"/g, '""')}"`; } - return value; + return guarded; } -export function auditLogToCsv(entries: readonly AdminAuditLogEntry[]): string { - const header = CSV_COLUMNS.join(','); +export function auditLogToCsv( + entries: readonly AdminAuditLogEntry[], + localize: (key: string) => string, +): string { + const header = CSV_COLUMNS.map((col) => escapeCsvCell(localize(col.labelKey))).join(','); const rows = entries.map((entry) => - CSV_COLUMNS.map((col) => escapeCsvCell(String(entry[col] ?? ''))).join(','), + CSV_COLUMNS.map((col) => escapeCsvCell(String(entry[col.key] ?? ''))).join(','), ); - return [header, ...rows].join('\n'); + return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'; } diff --git a/src/components/grants/index.ts b/src/components/grants/index.ts index de82ba3..caa023e 100644 --- a/src/components/grants/index.ts +++ b/src/components/grants/index.ts @@ -1,4 +1,4 @@ -export { ACTION_FILTER_LABELS, auditLogToCsv, capabilityLabel, formatTimestamp } from './auditLogUtils'; +export { ACTION_BADGE_STATE, ACTION_FILTER_LABELS, AUDIT_ACTION_FILTERS, auditLogToCsv, capabilityLabel, formatTimestamp } from './auditLogUtils'; export { aggregatePrincipals, buildRoleNames, filterPrincipals } from './utils'; export { EditCapabilitiesDialog } from './EditCapabilitiesDialog'; export { GrantManagementTab } from './GrantManagementTab'; diff --git a/src/components/shared/SearchInput.tsx b/src/components/shared/SearchInput.tsx index fac362d..1b133fa 100644 --- a/src/components/shared/SearchInput.tsx +++ b/src/components/shared/SearchInput.tsx @@ -1,10 +1,21 @@ import { SearchField } from '@clickhouse/click-ui'; import type * as t from '@/types'; -export function SearchInput({ value, onChange, placeholder, className }: t.SearchInputProps) { +export function SearchInput({ + value, + onChange, + placeholder, + className, + ariaLabel, +}: t.SearchInputProps) { return (
- +
); } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 13fed10..6abb580 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1037,11 +1037,25 @@ "com_audit_filter_removed": "Revoked", "com_audit_export_csv": "Export as CSV", "com_audit_empty": "No audit log entries found", - "com_audit_entry_count": "{{count}} entry", - "com_audit_entry_count_plural": "{{count}} entries", + "com_audit_entry_count_zero": "No entries", + "com_audit_entry_count_one": "{{count}} entry", + "com_audit_entry_count_other": "{{count}} entries", "com_audit_date_from": "From", "com_audit_date_to": "To", + "com_audit_filter_action_label": "Filter by action", + "com_audit_search_label": "Search audit log", + "com_audit_search_placeholder": "Search actor, target, or capability", + "com_audit_error": "Failed to load audit log", + "com_audit_csv_col_timestamp": "Timestamp", + "com_audit_csv_col_action": "Action", + "com_audit_csv_col_actor": "Actor", + "com_audit_csv_col_actor_id": "Actor ID", + "com_audit_csv_col_target_type": "Target type", + "com_audit_csv_col_target_id": "Target ID", + "com_audit_csv_col_target_name": "Target", + "com_audit_csv_col_capability": "Capability", "com_a11y_filters": "Filters", + "com_a11y_audit_filter_changed": "{{count}} audit log entries match the current filters", "com_nav_grants": "Grants", "com_nav_expand_sidebar": "Expand sidebar", "com_nav_collapse_sidebar": "Collapse sidebar", diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 119450d..7ecaab2 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -7,7 +7,7 @@ */ import { z } from 'zod'; -import { queryOptions } from '@tanstack/react-query'; +import { keepPreviousData, queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; import { PrincipalType } from 'librechat-data-provider'; import { hasImpliedCapability, SystemCapabilities } from '@librechat/data-schemas/capabilities'; @@ -221,21 +221,43 @@ export const revokeCapabilityFn = createServerFn({ method: 'POST' }) // ── Audit Log ──────────────────────────────────────────────────────── +const isoDate = z + .string() + .regex( + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?Z?)?$/, + 'Expected ISO 8601 date', + ); + const auditFilterSchema = z.object({ - search: z.string().optional(), + search: z.string().max(200).optional(), action: z.enum(['grant_assigned', 'grant_removed']).optional(), - from: z.string().optional(), - to: z.string().optional(), + from: isoDate.optional(), + to: isoDate.optional(), +}); + +export type AuditFilters = z.infer; + +const adminAuditLogEntrySchema = z.object({ + id: z.string(), + action: z.enum(['grant_assigned', 'grant_removed']), + actorId: z.string(), + actorName: z.string(), + targetPrincipalType: z.nativeEnum(PrincipalType), + targetPrincipalId: z.string(), + targetName: z.string(), + capability: z.string(), + timestamp: z.string(), }); -type AuditFilters = z.infer; +const auditLogResponseSchema = z.object({ + entries: z.array(adminAuditLogEntrySchema), +}); function buildAuditLogQuery(filters: AuditFilters): string { const params = new URLSearchParams(); - if (filters.search) params.set('search', filters.search); - if (filters.action) params.set('action', filters.action); - if (filters.from) params.set('from', filters.from); - if (filters.to) params.set('to', filters.to); + for (const [key, value] of Object.entries(filters)) { + if (value) params.set(key, value); + } const qs = params.toString(); return qs ? `?${qs}` : ''; } @@ -244,12 +266,16 @@ export const getAuditLogFn = createServerFn({ method: 'GET' }) .inputValidator(auditFilterSchema) .handler( async ({ data }: { data: AuditFilters }): Promise<{ entries: AdminAuditLogEntry[] }> => { + await requireAnyCapability([ + SystemCapabilities.MANAGE_ROLES, + SystemCapabilities.MANAGE_USERS, + SystemCapabilities.MANAGE_GROUPS, + ]); const response = await apiFetch(`/api/admin/audit-log${buildAuditLogQuery(data)}`); if (!response.ok) { await extractApiError(response, 'Failed to fetch audit log'); } - const json = (await response.json()) as { entries: AdminAuditLogEntry[] }; - return { entries: json.entries }; + return auditLogResponseSchema.parse(await response.json()); }, ); @@ -257,5 +283,6 @@ export const auditLogQueryOptions = (filters: AuditFilters = {}) => queryOptions({ queryKey: ['auditLog', filters], queryFn: () => getAuditLogFn({ data: filters }).then((r) => r.entries), - staleTime: 30_000, + staleTime: 60_000, + placeholderData: keepPreviousData, }); diff --git a/src/types/shared.ts b/src/types/shared.ts index 30b3b9f..1c6a280 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -79,6 +79,7 @@ export interface SearchInputProps { onChange: (value: string) => void; placeholder: string; className?: string; + ariaLabel?: string; } export interface SelectedMemberListProps { From 49226bdfd9789610928ae6110c9bd02c62e7d03f Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 11 May 2026 16:32:57 -0700 Subject: [PATCH 03/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Audit=20log=20?= =?UTF-8?q?=E2=80=94=20pagination,=20faceted=20filters,=20server=20CSV,=20?= =?UTF-8?q?side=20drawer,=20CSP,=20click-ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- server.ts | 37 ++- .../grants/AuditLogDetailDrawer.tsx | 258 ++++++++++++++++++ src/components/grants/AuditLogTab.tsx | 2 +- .../grants/EditCapabilitiesDialog.tsx | 22 +- src/components/grants/GrantManagementTab.tsx | 3 + src/components/grants/GrantTableRow.tsx | 14 +- src/components/grants/auditLogUtils.test.ts | 143 ++++++++++ src/components/grants/auditLogUtils.ts | 95 +++++++ src/components/grants/index.ts | 3 +- src/locales/en/translation.json | 15 + src/server/capabilities.ts | 83 +++++- src/styles.css | 9 - src/types/grant.ts | 24 ++ 13 files changed, 659 insertions(+), 49 deletions(-) create mode 100644 src/components/grants/AuditLogDetailDrawer.tsx diff --git a/server.ts b/server.ts index 70ef218..7f54e3c 100644 --- a/server.ts +++ b/server.ts @@ -40,6 +40,32 @@ function getCacheHeaders(filePath: string): Record { return {}; } +// 'unsafe-inline' in style-src is required because Tailwind 4 + click-ui inject inline styles at runtime. +const CSP_VALUE = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); + +function applySecurityHeaders(headers: Headers): void { + const contentType = headers.get('Content-Type') ?? ''; + if (!contentType.toLowerCase().startsWith('text/html')) return; + headers.set('Content-Security-Policy', CSP_VALUE); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + headers.set('X-Frame-Options', 'DENY'); + if (process.env.NODE_ENV === 'production') { + headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } +} + type Handler = { default: { fetch: (req: Request) => Promise } }; const { default: handler } = (await import(SERVER_ENTRY.href)) as Handler; @@ -65,11 +91,11 @@ async function buildStaticRoutes(): Promise Pro const cache = getCacheHeaders(path); const routePath = `/${path}`; routes[routePath] = (req) => - withHttpMetrics( - req, - routePath, - () => new Response(file, { headers: { 'Content-Type': file.type, ...cache } }), - ); + withHttpMetrics(req, routePath, () => { + const res = new Response(file, { headers: { 'Content-Type': file.type, ...cache } }); + applySecurityHeaders(res.headers); + return res; + }); } return routes; } @@ -86,6 +112,7 @@ const server = Bun.serve({ for (const [k, v] of Object.entries(NO_CACHE)) { patched.headers.set(k, v); } + applySecurityHeaders(patched.headers); return patched; }, }, diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx new file mode 100644 index 0000000..565792d --- /dev/null +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -0,0 +1,258 @@ +import { useCallback } from 'react'; +import { Badge, Button, Flyout, Icon, IconButton } from '@clickhouse/click-ui'; +import type { ReactElement } from 'react'; +import type * as t from '@/types'; +import { ACTION_BADGE_STATE, ACTION_LABEL_KEY, capabilityLabel, formatTimestamp } from './auditLogUtils'; +import { getScopeTypeConfig } from '@/constants'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +interface AuditLogDetailDrawerProps { + entry: t.AuditLogEntryWithDiff | null; + open: boolean; + onClose: () => void; + onCopyPermalink: () => void; +} + +function CopyableMono({ value, ariaLabel }: { value: string; ariaLabel: string }): ReactElement { + const handleCopy = useCallback(() => { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(value); + } + }, [value]); + + return ( + + ); +} + +function DiffList({ + items, + variant, + localize, +}: { + items: readonly string[]; + variant: 'added' | 'removed'; + localize: ReturnType; +}): ReactElement { + if (items.length === 0) { + return ( +

+ {localize('com_audit_detail_no_changes')} +

+ ); + } + const state = variant === 'added' ? 'success' : 'danger'; + return ( +
    + {items.map((cap) => ( +
  • + + {cap} +
  • + ))} +
+ ); +} + +export function AuditLogDetailDrawer({ + entry, + open, + onClose, + onCopyPermalink, +}: AuditLogDetailDrawerProps): ReactElement | null { + const localize = useLocalize(); + + if (!entry || !open) return null; + + const targetConfig = getScopeTypeConfig(entry.targetPrincipalType); + const summaryKey = + entry.action === 'grant_assigned' + ? 'com_audit_detail_summary_assigned' + : 'com_audit_detail_summary_removed'; + + const before = entry.before ?? []; + const after = entry.after ?? []; + const hasDiff = before.length > 0 || after.length > 0; + + return ( + { + if (!isOpen) onClose(); + }} + > + onClose()} + > + +
+
+ + + {localize('com_audit_detail_title')} + +
+ +
+
+ + +
+

+ {localize(summaryKey, { + actor: entry.actorName, + capability: capabilityLabel(entry.capability, localize), + target: entry.targetName, + })} +

+ +
+ +
+ + {formatTimestamp(entry.timestamp)} + + +
+
+ + +
+ + {entry.actorName} + + +
+
+ + +
+ + + + {localize(targetConfig.labelKey)} + + } + /> + + {entry.targetName} + + + +
+
+ + +
+ + {capabilityLabel(entry.capability, localize)} + + + {entry.capability} + +
+
+ + + + +
+ + {hasDiff && ( +
+
+
+

+ {localize('com_audit_detail_before')} +

+ +
+
+

+ {localize('com_audit_detail_after')} +

+ +
+
+
+ )} +
+
+ + +
+
+
+
+
+ ); +} + +function DetailRow({ label, children }: { label: string; children: ReactElement }): ReactElement { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index 2bf2f37..29241ad 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -49,7 +49,7 @@ export function AuditLogTab() { // UTC-inclusive: dateFrom -> 00:00:00Z, dateTo -> 23:59:59.999Z () => ({ search: debouncedSearch || undefined, - action: actionFilter !== 'all' ? actionFilter : undefined, + action: actionFilter !== 'all' ? [actionFilter] : undefined, from: dateFrom ? new Date(`${dateFrom}T00:00:00Z`).toISOString() : undefined, to: dateTo ? new Date(`${dateTo}T23:59:59.999Z`).toISOString() : undefined, }), diff --git a/src/components/grants/EditCapabilitiesDialog.tsx b/src/components/grants/EditCapabilitiesDialog.tsx index 6289ef0..3fad712 100644 --- a/src/components/grants/EditCapabilitiesDialog.tsx +++ b/src/components/grants/EditCapabilitiesDialog.tsx @@ -1,6 +1,6 @@ import { PrincipalType } from 'librechat-data-provider'; import { useCallback, useEffect, useState } from 'react'; -import { Button, Dialog, Icon } from '@clickhouse/click-ui'; +import { Badge, Button, Dialog, Icon } from '@clickhouse/click-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { AdminSystemGrant } from '@librechat/data-schemas'; import type * as t from '@/types'; @@ -9,7 +9,6 @@ import { getScopeTypeConfig, SystemCapabilities } from '@/constants'; import { CapabilityPanel } from './CapabilityPanel'; import { LoadingState } from '@/components/shared'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; function grantsToRecord(grants: AdminSystemGrant[]): Record { const record: Record = {}; @@ -107,15 +106,16 @@ export function EditCapabilitiesDialog({
{principalConfig && (
- - - {localize(principalConfig.labelKey)} - + + + {localize(principalConfig.labelKey)} + + } + /> {principalName} diff --git a/src/components/grants/GrantManagementTab.tsx b/src/components/grants/GrantManagementTab.tsx index 4f42bde..bef69f7 100644 --- a/src/components/grants/GrantManagementTab.tsx +++ b/src/components/grants/GrantManagementTab.tsx @@ -75,6 +75,9 @@ export function GrantManagementTab() { />
+ {/* Raw kept (not click-ui Table): rows act as buttons with tabIndex, + role, aria-label, onKeyDown, and a ref for focus restoration — semantics + the click-ui Table API does not expose. Matches AuditLogTab's choice. */}
diff --git a/src/components/grants/GrantTableRow.tsx b/src/components/grants/GrantTableRow.tsx index 0aeb28f..9097b8e 100644 --- a/src/components/grants/GrantTableRow.tsx +++ b/src/components/grants/GrantTableRow.tsx @@ -1,3 +1,4 @@ +import { Badge } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -25,14 +26,11 @@ export function GrantTableRow({ row, isLast, onClick, onKeyDown, rowRef }: t.Gra : localize('com_grants_capability_count', { count: row.grantCount })} ); diff --git a/src/components/grants/auditLogUtils.test.ts b/src/components/grants/auditLogUtils.test.ts index 47a3bc0..2a8b9e7 100644 --- a/src/components/grants/auditLogUtils.test.ts +++ b/src/components/grants/auditLogUtils.test.ts @@ -7,7 +7,9 @@ import { AUDIT_ACTION_FILTERS, auditLogToCsv, capabilityLabel, + diffGrantState, formatTimestamp, + parseAuditSearch, } from './auditLogUtils'; const UTF8_BOM = ''; @@ -169,3 +171,144 @@ describe('auditLogToCsv', () => { } }); }); + +describe('parseAuditSearch', () => { + it('returns an empty result for empty input', () => { + expect(parseAuditSearch('')).toEqual({ freeText: '', qualifiers: {} }); + }); + + it('places plain text into freeText', () => { + const result = parseAuditSearch('hello world'); + expect(result.qualifiers).toEqual({}); + expect(result.freeText).toBe('hello world'); + }); + + it('extracts a single actor qualifier', () => { + const result = parseAuditSearch('actor:alice'); + expect(result.qualifiers.actor).toBe('alice'); + expect(result.freeText).toBe(''); + }); + + it('extracts multiple qualifiers', () => { + const result = parseAuditSearch('actor:alice capability:manage:configs'); + expect(result.qualifiers.actor).toBe('alice'); + expect(result.qualifiers.capability).toBe('manage:configs'); + }); + + it('supports a target qualifier', () => { + const result = parseAuditSearch('target:bob'); + expect(result.qualifiers.target).toBe('bob'); + }); + + it('handles quoted multi-word qualifier values', () => { + const result = parseAuditSearch('actor:"Alice Admin"'); + expect(result.qualifiers.actor).toBe('Alice Admin'); + expect(result.freeText).toBe(''); + }); + + it('maps created:> to createdAfter', () => { + const result = parseAuditSearch('created:>2026-05-01'); + expect(result.qualifiers.createdAfter).toBe('2026-05-01'); + expect(result.qualifiers.createdBefore).toBeUndefined(); + }); + + it('maps created:>= to createdAfter', () => { + const result = parseAuditSearch('created:>=2026-05-01'); + expect(result.qualifiers.createdAfter).toBe('2026-05-01'); + }); + + it('maps created:< to createdBefore', () => { + const result = parseAuditSearch('created:<2026-05-31'); + expect(result.qualifiers.createdBefore).toBe('2026-05-31'); + expect(result.qualifiers.createdAfter).toBeUndefined(); + }); + + it('maps created:<= to createdBefore', () => { + const result = parseAuditSearch('created:<=2026-05-31'); + expect(result.qualifiers.createdBefore).toBe('2026-05-31'); + }); + + it('treats created without an operator as an exact day window', () => { + const result = parseAuditSearch('created:2026-05-01'); + expect(result.qualifiers.createdAfter).toBe('2026-05-01'); + expect(result.qualifiers.createdBefore).toBe('2026-05-01'); + }); + + it('keeps free text alongside qualifiers', () => { + const result = parseAuditSearch('login actor:alice from prod'); + expect(result.qualifiers.actor).toBe('alice'); + expect(result.freeText).toBe('login from prod'); + }); + + it('accepts qualifier keys case-insensitively', () => { + expect(parseAuditSearch('Actor:alice').qualifiers.actor).toBe('alice'); + expect(parseAuditSearch('ACTOR:alice').qualifiers.actor).toBe('alice'); + }); + + it('treats unknown qualifier keys as free text', () => { + const result = parseAuditSearch('foo:bar actor:alice'); + expect(result.qualifiers.actor).toBe('alice'); + expect(result.freeText).toBe('foo:bar'); + }); + + it('combines actor + created range + free text', () => { + const result = parseAuditSearch( + 'actor:alice created:>2026-05-01 created:<2026-05-31 audit', + ); + expect(result.qualifiers).toEqual({ + actor: 'alice', + createdAfter: '2026-05-01', + createdBefore: '2026-05-31', + }); + expect(result.freeText).toBe('audit'); + }); +}); + +describe('diffGrantState', () => { + it('returns an empty diff when both sides are undefined', () => { + expect(diffGrantState(undefined, undefined)).toEqual({ + added: [], + removed: [], + unchanged: [], + }); + }); + + it('returns an empty diff when both sides are empty arrays', () => { + expect(diffGrantState([], [])).toEqual({ added: [], removed: [], unchanged: [] }); + }); + + it('treats every capability as added when before is missing', () => { + const diff = diffGrantState(undefined, ['a', 'b']); + expect(new Set(diff.added)).toEqual(new Set(['a', 'b'])); + expect(diff.removed).toEqual([]); + expect(diff.unchanged).toEqual([]); + }); + + it('treats every capability as removed when after is missing', () => { + const diff = diffGrantState(['a', 'b'], undefined); + expect(new Set(diff.removed)).toEqual(new Set(['a', 'b'])); + expect(diff.added).toEqual([]); + expect(diff.unchanged).toEqual([]); + }); + + it('classifies added, removed, and unchanged capabilities', () => { + const diff = diffGrantState(['a', 'b', 'c'], ['b', 'c', 'd']); + expect(new Set(diff.added)).toEqual(new Set(['d'])); + expect(new Set(diff.removed)).toEqual(new Set(['a'])); + expect(new Set(diff.unchanged)).toEqual(new Set(['b', 'c'])); + }); + + it('returns identical input as fully unchanged', () => { + const diff = diffGrantState(['a', 'b'], ['a', 'b']); + expect(new Set(diff.unchanged)).toEqual(new Set(['a', 'b'])); + expect(diff.added).toEqual([]); + expect(diff.removed).toEqual([]); + }); + + it('deduplicates repeated capabilities via Set semantics', () => { + const diff = diffGrantState(['a', 'a', 'b'], ['a', 'a', 'c']); + expect(diff.unchanged).toEqual(['a']); + expect(diff.added).toEqual(['c']); + expect(diff.removed).toEqual(['b']); + }); +}); diff --git a/src/components/grants/auditLogUtils.ts b/src/components/grants/auditLogUtils.ts index aab80f4..706f222 100644 --- a/src/components/grants/auditLogUtils.ts +++ b/src/components/grants/auditLogUtils.ts @@ -18,6 +18,11 @@ export const ACTION_BADGE_STATE: Record = { grant_removed: 'danger', }; +export const ACTION_LABEL_KEY: Record = { + grant_assigned: 'com_audit_action_assigned', + grant_removed: 'com_audit_action_removed', +}; + const CSV_COLUMNS = [ { key: 'timestamp', labelKey: 'com_audit_csv_col_timestamp' }, { key: 'action', labelKey: 'com_audit_csv_col_action' }, @@ -80,3 +85,93 @@ export function auditLogToCsv( ); return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'; } + +const QUALIFIER_KEYS = new Set(['actor', 'target', 'capability', 'created']); +const TOKEN_RE = /(\w+):(>?' || op === '>=') { + qualifiers.createdAfter = value; + return; + } + if (op === '<' || op === '<=') { + qualifiers.createdBefore = value; + return; + } + qualifiers.createdAfter = value; + qualifiers.createdBefore = value; + return; + } + if (key === 'actor') { + qualifiers.actor = value; + return; + } + if (key === 'target') { + qualifiers.target = value; + return; + } + if (key === 'capability') { + qualifiers.capability = value; + } +} + +export function parseAuditSearch(input: string): t.ParsedAuditSearch { + const qualifiers: t.AuditSearchQualifiers = {}; + const freeTextParts: string[] = []; + if (!input) { + return { freeText: '', qualifiers }; + } + + for (const match of input.matchAll(TOKEN_RE)) { + const [raw, key, op, quotedValue, bareValue, quotedFree, bareFree] = match; + const normalizedKey = key?.toLowerCase(); + if (normalizedKey && QUALIFIER_KEYS.has(normalizedKey)) { + const value = quotedValue ?? bareValue ?? ''; + if (!value) { + continue; + } + assignQualifier(qualifiers, normalizedKey, op, value); + continue; + } + if (key) { + freeTextParts.push(raw); + continue; + } + const free = quotedFree ?? bareFree ?? ''; + if (free) { + freeTextParts.push(free); + } + } + + return { freeText: freeTextParts.join(' '), qualifiers }; +} + +export function diffGrantState( + before: readonly string[] | undefined, + after: readonly string[] | undefined, +): t.GrantDiff { + const beforeSet = new Set(before ?? []); + const afterSet = new Set(after ?? []); + const added: string[] = []; + const removed: string[] = []; + const unchanged: string[] = []; + for (const cap of afterSet) { + if (beforeSet.has(cap)) { + unchanged.push(cap); + } else { + added.push(cap); + } + } + for (const cap of beforeSet) { + if (!afterSet.has(cap)) { + removed.push(cap); + } + } + return { added, removed, unchanged }; +} diff --git a/src/components/grants/index.ts b/src/components/grants/index.ts index caa023e..4a606e6 100644 --- a/src/components/grants/index.ts +++ b/src/components/grants/index.ts @@ -1,9 +1,10 @@ -export { ACTION_BADGE_STATE, ACTION_FILTER_LABELS, AUDIT_ACTION_FILTERS, auditLogToCsv, capabilityLabel, formatTimestamp } from './auditLogUtils'; +export { ACTION_BADGE_STATE, ACTION_FILTER_LABELS, AUDIT_ACTION_FILTERS, auditLogToCsv, capabilityLabel, diffGrantState, formatTimestamp, parseAuditSearch } from './auditLogUtils'; export { aggregatePrincipals, buildRoleNames, filterPrincipals } from './utils'; export { EditCapabilitiesDialog } from './EditCapabilitiesDialog'; export { GrantManagementTab } from './GrantManagementTab'; export { CapabilityPanel } from './CapabilityPanel'; export { AuditLogRow } from './AuditLogRow'; +export { AuditLogDetailDrawer } from './AuditLogDetailDrawer'; export { AuditLogTab } from './AuditLogTab'; export { GrantTableRow } from './GrantTableRow'; export { GrantsPage } from './GrantsPage'; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6abb580..7cccd66 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1054,6 +1054,21 @@ "com_audit_csv_col_target_id": "Target ID", "com_audit_csv_col_target_name": "Target", "com_audit_csv_col_capability": "Capability", + "com_audit_detail_title": "Audit log entry", + "com_audit_detail_summary_assigned": "{{actor}} granted {{capability}} to {{target}}", + "com_audit_detail_summary_removed": "{{actor}} revoked {{capability}} from {{target}}", + "com_audit_detail_actor": "Actor", + "com_audit_detail_target": "Target", + "com_audit_detail_capability": "Capability", + "com_audit_detail_timestamp": "When", + "com_audit_detail_entry_id": "Entry ID", + "com_audit_detail_before": "Before", + "com_audit_detail_after": "After", + "com_audit_detail_diff_added": "Added", + "com_audit_detail_diff_removed": "Removed", + "com_audit_detail_no_changes": "No detailed changes recorded", + "com_audit_detail_copy_permalink": "Copy link", + "com_audit_detail_close": "Close", "com_a11y_filters": "Filters", "com_a11y_audit_filter_changed": "{{count}} audit log entries match the current filters", "com_nav_grants": "Grants", diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 7ecaab2..b7895d4 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -7,7 +7,7 @@ */ import { z } from 'zod'; -import { keepPreviousData, queryOptions } from '@tanstack/react-query'; +import { infiniteQueryOptions, keepPreviousData, queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; import { PrincipalType } from 'librechat-data-provider'; import { hasImpliedCapability, SystemCapabilities } from '@librechat/data-schemas/capabilities'; @@ -230,9 +230,15 @@ const isoDate = z const auditFilterSchema = z.object({ search: z.string().max(200).optional(), - action: z.enum(['grant_assigned', 'grant_removed']).optional(), + action: z.array(z.enum(['grant_assigned', 'grant_removed'])).optional(), from: isoDate.optional(), to: isoDate.optional(), + actorId: z.string().max(128).optional(), + targetPrincipalType: z.nativeEnum(PrincipalType).optional(), + targetPrincipalId: z.string().max(128).optional(), + capability: z.string().max(128).optional(), + cursor: z.string().max(256).optional(), + limit: z.number().int().min(1).max(500).optional(), }); export type AuditFilters = z.infer; @@ -247,35 +253,65 @@ const adminAuditLogEntrySchema = z.object({ targetName: z.string(), capability: z.string(), timestamp: z.string(), + before: z.array(z.string()).optional(), + after: z.array(z.string()).optional(), }); -const auditLogResponseSchema = z.object({ +const auditLogPageResponseSchema = z.object({ entries: z.array(adminAuditLogEntrySchema), + nextCursor: z.string().nullable(), }); +export type AuditLogPage = z.infer; + function buildAuditLogQuery(filters: AuditFilters): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) { - if (value) params.set(key, value); + if (value === undefined || value === null || value === '') continue; + if (Array.isArray(value)) { + for (const v of value) params.append(key, String(v)); + } else { + params.set(key, String(value)); + } } const qs = params.toString(); return qs ? `?${qs}` : ''; } +export const getAuditLogPageFn = createServerFn({ method: 'GET' }) + .inputValidator(auditFilterSchema) + .handler(async ({ data }: { data: AuditFilters }): Promise => { + await requireAnyCapability([ + SystemCapabilities.MANAGE_ROLES, + SystemCapabilities.MANAGE_USERS, + SystemCapabilities.MANAGE_GROUPS, + ]); + const withDefaults: AuditFilters = { limit: 100, ...data }; + const response = await apiFetch(`/api/admin/audit-log${buildAuditLogQuery(withDefaults)}`); + if (!response.ok) { + await extractApiError(response, 'Failed to fetch audit log'); + } + return auditLogPageResponseSchema.parse(await response.json()); + }); + +export const auditLogInfiniteQueryOptions = ( + filters: Omit = {}, +) => + infiniteQueryOptions({ + queryKey: ['auditLog', 'infinite', filters], + queryFn: ({ pageParam }) => + getAuditLogPageFn({ data: { ...filters, cursor: pageParam } }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: 60_000, + }); + export const getAuditLogFn = createServerFn({ method: 'GET' }) .inputValidator(auditFilterSchema) .handler( async ({ data }: { data: AuditFilters }): Promise<{ entries: AdminAuditLogEntry[] }> => { - await requireAnyCapability([ - SystemCapabilities.MANAGE_ROLES, - SystemCapabilities.MANAGE_USERS, - SystemCapabilities.MANAGE_GROUPS, - ]); - const response = await apiFetch(`/api/admin/audit-log${buildAuditLogQuery(data)}`); - if (!response.ok) { - await extractApiError(response, 'Failed to fetch audit log'); - } - return auditLogResponseSchema.parse(await response.json()); + const page = await getAuditLogPageFn({ data: { ...data, limit: 500 } }); + return { entries: page.entries }; }, ); @@ -286,3 +322,22 @@ export const auditLogQueryOptions = (filters: AuditFilters = {}) => staleTime: 60_000, placeholderData: keepPreviousData, }); + +export const exportAuditLogServerFn = createServerFn({ method: 'POST' }) + .inputValidator(auditFilterSchema) + .handler(async ({ data }: { data: AuditFilters }): Promise<{ csv: string }> => { + await requireAnyCapability([ + SystemCapabilities.MANAGE_ROLES, + SystemCapabilities.MANAGE_USERS, + SystemCapabilities.MANAGE_GROUPS, + ]); + const response = await apiFetch( + `/api/admin/audit-log/export.csv${buildAuditLogQuery(data)}`, + { method: 'GET', headers: { Accept: 'text/csv' } }, + ); + if (!response.ok) { + await extractApiError(response, 'Failed to export audit log'); + } + const csv = await response.text(); + return { csv }; + }); diff --git a/src/styles.css b/src/styles.css index 38ad3fe..4c66730 100644 --- a/src/styles.css +++ b/src/styles.css @@ -923,12 +923,3 @@ body > div:has(+ .modal-frost) { color: var(--cui-color-feedback-user-fg); } -.badge-success { - background-color: var(--cui-color-feedback-success-bg); - color: var(--cui-color-feedback-success-fg); -} - -.badge-danger { - background-color: var(--cui-color-feedback-danger-bg); - color: var(--cui-color-feedback-danger-fg); -} diff --git a/src/types/grant.ts b/src/types/grant.ts index 639c2f5..273b88f 100644 --- a/src/types/grant.ts +++ b/src/types/grant.ts @@ -2,6 +2,30 @@ import type { AdminAuditLogEntry, AuditAction } from '@librechat/data-schemas'; import type { PrincipalType } from 'librechat-data-provider'; import type { KeyboardEvent } from 'react'; +export interface AuditLogEntryWithDiff extends AdminAuditLogEntry { + before?: readonly string[]; + after?: readonly string[]; +} + +export interface AuditSearchQualifiers { + actor?: string; + target?: string; + capability?: string; + createdAfter?: string; + createdBefore?: string; +} + +export interface ParsedAuditSearch { + freeText: string; + qualifiers: AuditSearchQualifiers; +} + +export interface GrantDiff { + added: readonly string[]; + removed: readonly string[]; + unchanged: readonly string[]; +} + export interface PrincipalRow { principalType: PrincipalType; principalId: string; From b6c1b732f553b16f7fcc823458d0dc9991b78106 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 11 May 2026 16:42:54 -0700 Subject: [PATCH 04/21] =?UTF-8?q?=E2=9C=A8=20feat:=20Audit=20log=20infinit?= =?UTF-8?q?e=20pagination,=20faceted=20filters,=20structured=20search,=20p?= =?UTF-8?q?ermalinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/grants/AuditLogTab.tsx | 581 +++++++++++++++++++++++--- src/locales/en/translation.json | 14 + src/routes/_app/grants.tsx | 4 +- src/server/capabilities.ts | 21 +- 4 files changed, 541 insertions(+), 79 deletions(-) diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index 29241ad..87d23d1 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -1,12 +1,48 @@ -import { useQuery } from '@tanstack/react-query'; -import { Button, ButtonGroup, DatePicker } from '@clickhouse/click-ui'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { + Badge, + Button, + CheckboxMultiSelect, + DatePicker, + Icon, + IconButton, + Select, + TextField, +} from '@clickhouse/click-ui'; import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import { PrincipalType } from 'librechat-data-provider'; +import type { AuditAction } from '@librechat/data-schemas'; import type * as t from '@/types'; import { EmptyState, LoadingState, ScreenReaderAnnouncer, SearchInput } from '@/components/shared'; -import { ACTION_FILTER_LABELS, AUDIT_ACTION_FILTERS, auditLogToCsv } from './auditLogUtils'; +import { + ACTION_BADGE_STATE, + ACTION_LABEL_KEY, + auditLogToCsv, + capabilityLabel, + formatTimestamp, + parseAuditSearch, +} from './auditLogUtils'; +import { getScopeTypeConfig } from '@/constants'; import { useAnnouncement, useLocalize } from '@/hooks'; -import { auditLogQueryOptions } from '@/server'; -import { AuditLogRow } from './AuditLogRow'; +import { auditLogInfiniteQueryOptions, exportAuditLogServerFn } from '@/server'; +import type { AuditFilters } from '@/server/capabilities'; +import { AuditLogDetailDrawer } from './AuditLogDetailDrawer'; +import { cn } from '@/utils'; + +const CLIENT_EXPORT_THRESHOLD = 500; +const AUDIT_ACTIONS: readonly AuditAction[] = ['grant_assigned', 'grant_removed'] as const; +const TARGET_TYPE_OPTIONS: readonly PrincipalType[] = [ + PrincipalType.USER, + PrincipalType.GROUP, + PrincipalType.ROLE, +] as const; + +interface QualifierChip { + key: keyof t.AuditSearchQualifiers; + display: string; + removalToken: RegExp; +} function isoDateToDate(iso: string): Date | undefined { if (!iso) return undefined; @@ -21,77 +57,307 @@ function dateToIsoDate(date: Date): string { return `${yyyy}-${mm}-${dd}`; } +function buildQualifierChips( + parsed: t.ParsedAuditSearch, + raw: string, +): QualifierChip[] { + const chips: QualifierChip[] = []; + const q = parsed.qualifiers; + if (q.actor) { + chips.push({ + key: 'actor', + display: `actor:${q.actor}`, + removalToken: /\bactor:(?:"[^"]*"|\S+)\s*/gi, + }); + } + if (q.target) { + chips.push({ + key: 'target', + display: `target:${q.target}`, + removalToken: /\btarget:(?:"[^"]*"|\S+)\s*/gi, + }); + } + if (q.capability) { + chips.push({ + key: 'capability', + display: `capability:${q.capability}`, + removalToken: /\bcapability:(?:"[^"]*"|\S+)\s*/gi, + }); + } + if (q.createdAfter && q.createdAfter === q.createdBefore) { + chips.push({ + key: 'createdAfter', + display: `created:${q.createdAfter}`, + removalToken: /\bcreated:(?!>|<)(?:"[^"]*"|\S+)\s*/gi, + }); + } else { + if (q.createdAfter) { + chips.push({ + key: 'createdAfter', + display: `created:>${q.createdAfter}`, + removalToken: /\bcreated:>=?(?:"[^"]*"|\S+)\s*/gi, + }); + } + if (q.createdBefore) { + chips.push({ + key: 'createdBefore', + display: `created:<${q.createdBefore}`, + removalToken: /\bcreated:<=?(?:"[^"]*"|\S+)\s*/gi, + }); + } + } + void raw; + return chips; +} + +function downloadCsv(csv: string): void { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + export function AuditLogTab() { const localize = useLocalize(); + const navigate = useNavigate({ from: '/grants' }); + const { entryId } = useSearch({ from: '/_app/grants' }); + const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); - const [actionFilter, setActionFilter] = useState('all'); + const [actionFilter, setActionFilter] = useState([]); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); + const [actorIdFilter, setActorIdFilter] = useState(''); + const [debouncedActorId, setDebouncedActorId] = useState(''); + const [targetIdFilter, setTargetIdFilter] = useState(''); + const [debouncedTargetId, setDebouncedTargetId] = useState(''); + const [capabilityFilter, setCapabilityFilter] = useState(''); + const [debouncedCapability, setDebouncedCapability] = useState(''); + const [targetTypeFilter, setTargetTypeFilter] = useState(''); + const [moreOpen, setMoreOpen] = useState(false); + const { message: announcement, announce } = useAnnouncement(); - const debounceRef = useRef>(undefined); + const searchDebounceRef = useRef>(undefined); + const actorDebounceRef = useRef>(undefined); + const targetDebounceRef = useRef>(undefined); + const capabilityDebounceRef = useRef>(undefined); + const sentinelRef = useRef(null); + const previousPageCountRef = useRef(0); useEffect(() => { - return () => clearTimeout(debounceRef.current); + return () => { + clearTimeout(searchDebounceRef.current); + clearTimeout(actorDebounceRef.current); + clearTimeout(targetDebounceRef.current); + clearTimeout(capabilityDebounceRef.current); + }; }, []); const handleSearchChange = (value: string) => { setSearch(value); - clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => setDebouncedSearch(value), 300); + clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => setDebouncedSearch(value), 300); }; - const handleActionFilter = (filter: string) => { - setActionFilter(filter as t.ActionFilter); + const handleActorIdChange = (value: string) => { + setActorIdFilter(value); + clearTimeout(actorDebounceRef.current); + actorDebounceRef.current = setTimeout(() => setDebouncedActorId(value), 300); }; - const filters = useMemo( - // UTC-inclusive: dateFrom -> 00:00:00Z, dateTo -> 23:59:59.999Z - () => ({ - search: debouncedSearch || undefined, - action: actionFilter !== 'all' ? [actionFilter] : undefined, - from: dateFrom ? new Date(`${dateFrom}T00:00:00Z`).toISOString() : undefined, - to: dateTo ? new Date(`${dateTo}T23:59:59.999Z`).toISOString() : undefined, - }), - [debouncedSearch, actionFilter, dateFrom, dateTo], + const handleTargetIdChange = (value: string) => { + setTargetIdFilter(value); + clearTimeout(targetDebounceRef.current); + targetDebounceRef.current = setTimeout(() => setDebouncedTargetId(value), 300); + }; + + const handleCapabilityChange = (value: string) => { + setCapabilityFilter(value); + clearTimeout(capabilityDebounceRef.current); + capabilityDebounceRef.current = setTimeout(() => setDebouncedCapability(value), 300); + }; + + const parsed = useMemo(() => parseAuditSearch(debouncedSearch), [debouncedSearch]); + const qualifierChips = useMemo( + () => buildQualifierChips(parsed, debouncedSearch), + [parsed, debouncedSearch], ); + const filters = useMemo>(() => { + const q = parsed.qualifiers; + const fromIso = + q.createdAfter || (dateFrom ? new Date(`${dateFrom}T00:00:00Z`).toISOString() : undefined); + const toIso = + q.createdBefore || (dateTo ? new Date(`${dateTo}T23:59:59.999Z`).toISOString() : undefined); + return { + search: parsed.freeText.trim() ? parsed.freeText.trim() : undefined, + action: actionFilter.length ? actionFilter : undefined, + from: fromIso ? new Date(fromIso).toISOString() : undefined, + to: toIso ? new Date(toIso).toISOString() : undefined, + actorId: (q.actor || debouncedActorId || undefined) ?? undefined, + targetPrincipalId: (q.target || debouncedTargetId || undefined) ?? undefined, + targetPrincipalType: targetTypeFilter ? targetTypeFilter : undefined, + capability: (q.capability || debouncedCapability || undefined) ?? undefined, + }; + }, [ + parsed, + actionFilter, + dateFrom, + dateTo, + debouncedActorId, + debouncedTargetId, + debouncedCapability, + targetTypeFilter, + ]); + const { - data: entries = [], + data, isPending, isPlaceholderData, isFetching, + isFetchingNextPage, isError, - } = useQuery(auditLogQueryOptions(filters)); + hasNextPage, + fetchNextPage, + } = useInfiniteQuery(auditLogInfiniteQueryOptions(filters)); + + const entries: t.AuditLogEntryWithDiff[] = useMemo( + () => (data?.pages ?? []).flatMap((page) => page.entries), + [data], + ); useEffect(() => { if (isFetching) return; announce(localize('com_a11y_audit_filter_changed', { count: entries.length })); - }, [debouncedSearch, actionFilter, dateFrom, dateTo, isFetching, entries.length, announce, localize]); + previousPageCountRef.current = data?.pages.length ?? 0; + }, [ + debouncedSearch, + actionFilter, + dateFrom, + dateTo, + debouncedActorId, + debouncedTargetId, + debouncedCapability, + targetTypeFilter, + isFetching, + entries.length, + announce, + localize, + data?.pages.length, + ]); + + useEffect(() => { + if (!data?.pages) return; + const newPages = data.pages.length; + const prev = previousPageCountRef.current; + if (newPages > prev && prev > 0) { + const lastPage = data.pages[newPages - 1]; + announce( + localize('com_a11y_audit_page_loaded', { count: lastPage.entries.length }), + ); + } + previousPageCountRef.current = newPages; + }, [data?.pages, announce, localize]); + + useEffect(() => { + const node = sentinelRef.current; + if (!node) return; + if (!hasNextPage || isFetchingNextPage) return; + if (typeof IntersectionObserver === 'undefined') return; + const observer = new IntersectionObserver( + (entriesList) => { + if (entriesList.some((e) => e.isIntersecting)) { + void fetchNextPage(); + } + }, + { rootMargin: '200px 0px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage, entries.length]); + + const selectedEntry = useMemo( + () => (entryId ? entries.find((e) => e.id === entryId) ?? null : null), + [entries, entryId], + ); + + const openEntry = useCallback( + (id: string) => { + void navigate({ search: (prev: Record) => ({ ...prev, entryId: id }) }); + }, + [navigate], + ); + + const closeEntry = useCallback(() => { + void navigate({ + search: (prev: Record) => { + const next = { ...prev }; + delete next.entryId; + return next; + }, + }); + }, [navigate]); + + const handleCopyPermalink = useCallback(() => { + if (typeof window === 'undefined' || !navigator.clipboard) return; + void navigator.clipboard.writeText(window.location.href); + }, []); + + const handleRowKeyDown = useCallback( + (e: React.KeyboardEvent, id: string) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openEntry(id); + } + }, + [openEntry], + ); + + const removeQualifier = useCallback((token: RegExp) => { + setSearch((current) => { + const next = current.replace(token, '').trim().replace(/\s+/g, ' '); + clearTimeout(searchDebounceRef.current); + setDebouncedSearch(next); + return next; + }); + }, []); + + const usingServerExport = entries.length > CLIENT_EXPORT_THRESHOLD || hasNextPage; + const [exporting, setExporting] = useState(false); - const handleExport = useCallback(() => { + const handleExport = useCallback(async () => { + if (usingServerExport) { + setExporting(true); + try { + const { csv } = await exportAuditLogServerFn({ data: filters }); + downloadCsv(csv); + } finally { + setExporting(false); + } + return; + } const csv = auditLogToCsv(entries, localize); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 0); - }, [entries, localize]); - - const actionFilterOptions = useMemo( + downloadCsv(csv); + }, [entries, localize, filters, usingServerExport]); + + const actionOptions = useMemo( () => - AUDIT_ACTION_FILTERS.map((filter) => ({ - value: filter, - label: localize(ACTION_FILTER_LABELS[filter]), + AUDIT_ACTIONS.map((act) => ({ + value: act, + label: localize(ACTION_LABEL_KEY[act]), })), [localize], ); const showLoading = isPending && !isPlaceholderData; + const exportLabel = usingServerExport + ? localize('com_audit_export_server') + : localize('com_audit_export_client'); return (
@@ -104,16 +370,18 @@ export function AuditLogTab() { -
- + setActionFilter(values as AuditAction[])} + selectLabel={localize('com_audit_filter_action_label')} + placeholder={localize('com_audit_filter_all')} />
@@ -139,13 +407,98 @@ export function AuditLogTab() {
- + + {moreOpen && ( +
+ + +
+ + {localize('com_audit_filter_target_type')} + + +
+ +
+ )}
)} - {!showLoading && !isError && entries.map((entry, i) => ( - - ))} + {!showLoading && + !isError && + entries.map((entry, i) => ( + openEntry(entry.id)} + onKeyDown={(e) => handleRowKeyDown(e, entry.id)} + localize={localize} + /> + ))} {!showLoading && !isError && entries.length === 0 && (
)} + {!showLoading && !isError && entries.length > 0 && ( + + + )}
{localize('com_grants_title')}
- - {row.isActive ? localize('com_ui_active') : localize('com_ui_paused')} - +
@@ -206,15 +568,116 @@ export function AuditLogTab() {
-

- {localize('com_audit_entry_count', { count: entries.length })} -

+
+

+ {localize('com_audit_entry_count', { count: entries.length })} +

+ {hasNextPage ? ( +
+ + {selectedEntry && ( + + )} ); } + +function AuditLogTableRow({ + entry, + isLast, + onActivate, + onKeyDown, + localize, +}: { + entry: t.AuditLogEntryWithDiff; + isLast: boolean; + onActivate: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + localize: ReturnType; +}) { + const targetConfig = getScopeTypeConfig(entry.targetPrincipalType); + return ( + + + + + + + + + {localize(targetConfig.labelKey)} + + } + /> + {entry.targetName} + + + +
+ + {capabilityLabel(entry.capability, localize)} + + +
+ + {entry.actorName} + + + + + ); +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7cccd66..55b95a7 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1045,6 +1045,20 @@ "com_audit_filter_action_label": "Filter by action", "com_audit_search_label": "Search audit log", "com_audit_search_placeholder": "Search actor, target, or capability", + "com_audit_search_placeholder_qualifiers": "Search or use qualifiers: actor:, target:, capability:, created:>YYYY-MM-DD", + "com_audit_filter_more": "More filters", + "com_audit_filter_actor_id": "Actor ID", + "com_audit_filter_target_id": "Target ID", + "com_audit_filter_target_type": "Target type", + "com_audit_filter_capability": "Capability", + "com_audit_load_more": "Load more", + "com_audit_loading_more": "Loading more entries…", + "com_audit_no_more": "End of audit log", + "com_audit_qualifier_remove": "Remove {{qualifier}} filter", + "com_audit_export_client": "Export current results", + "com_audit_export_server": "Export full match (server)", + "com_a11y_audit_page_loaded": "Loaded {{count}} more entries", + "com_a11y_audit_row_open": "Open audit log entry", "com_audit_error": "Failed to load audit log", "com_audit_csv_col_timestamp": "Timestamp", "com_audit_csv_col_action": "Action", diff --git a/src/routes/_app/grants.tsx b/src/routes/_app/grants.tsx index 6fb9d35..9627631 100644 --- a/src/routes/_app/grants.tsx +++ b/src/routes/_app/grants.tsx @@ -5,6 +5,7 @@ type Tab = 'management' | 'audit-log'; interface GrantsSearch { tab?: string; + entryId?: string; } function isValidTab(value?: string): value is Tab { @@ -14,6 +15,7 @@ function isValidTab(value?: string): value is Tab { export const Route = createFileRoute('/_app/grants')({ validateSearch: (search: Record): GrantsSearch => ({ tab: typeof search.tab === 'string' ? search.tab : undefined, + entryId: typeof search.entryId === 'string' ? search.entryId : undefined, }), component: GrantsRoute, }); @@ -25,7 +27,7 @@ function GrantsRoute() { const handleTabChange = (value: string) => { if (isValidTab(value)) { - navigate({ search: { tab: value } }); + navigate({ search: (prev: Record) => ({ ...prev, tab: value }) }); } }; diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index b7895d4..edfda67 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -7,11 +7,11 @@ */ import { z } from 'zod'; -import { infiniteQueryOptions, keepPreviousData, queryOptions } from '@tanstack/react-query'; +import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; import { PrincipalType } from 'librechat-data-provider'; import { hasImpliedCapability, SystemCapabilities } from '@librechat/data-schemas/capabilities'; -import type { AdminAuditLogEntry, AdminSystemGrant } from '@librechat/data-schemas'; +import type { AdminSystemGrant } from '@librechat/data-schemas'; import { apiFetch, extractApiError } from './utils/api'; // ── Helpers ────────────────────────────────────────────────────────── @@ -306,23 +306,6 @@ export const auditLogInfiniteQueryOptions = ( staleTime: 60_000, }); -export const getAuditLogFn = createServerFn({ method: 'GET' }) - .inputValidator(auditFilterSchema) - .handler( - async ({ data }: { data: AuditFilters }): Promise<{ entries: AdminAuditLogEntry[] }> => { - const page = await getAuditLogPageFn({ data: { ...data, limit: 500 } }); - return { entries: page.entries }; - }, - ); - -export const auditLogQueryOptions = (filters: AuditFilters = {}) => - queryOptions({ - queryKey: ['auditLog', filters], - queryFn: () => getAuditLogFn({ data: filters }).then((r) => r.entries), - staleTime: 60_000, - placeholderData: keepPreviousData, - }); - export const exportAuditLogServerFn = createServerFn({ method: 'POST' }) .inputValidator(auditFilterSchema) .handler(async ({ data }: { data: AuditFilters }): Promise<{ csv: string }> => { From 2714f0de5dec3042a529f308da54746cf2664f69 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 11 May 2026 22:52:19 -0700 Subject: [PATCH 05/21] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Audit=20log=20pol?= =?UTF-8?q?ish=20=E2=80=94=20UX,=20offset=20pagination,=20Radix=20Dialog?= =?UTF-8?q?=20drawer,=20dead-code=20purge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../grants/AuditLogDetailDrawer.tsx | 307 ++++++----- src/components/grants/AuditLogRow.tsx | 63 --- src/components/grants/AuditLogTab.tsx | 511 +++++++----------- src/components/grants/GrantManagementTab.tsx | 10 +- src/components/grants/GrantsPage.tsx | 16 +- src/components/grants/auditLogUtils.test.ts | 159 ------ src/components/grants/auditLogUtils.ts | 113 +--- src/components/grants/index.ts | 9 +- src/locales/en/translation.json | 18 +- src/server/capabilities.ts | 44 +- src/styles.css | 49 +- src/types/grant.ts | 28 +- 12 files changed, 472 insertions(+), 855 deletions(-) delete mode 100644 src/components/grants/AuditLogRow.tsx diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx index 565792d..a45c6c0 100644 --- a/src/components/grants/AuditLogDetailDrawer.tsx +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -1,8 +1,14 @@ -import { useCallback } from 'react'; -import { Badge, Button, Flyout, Icon, IconButton } from '@clickhouse/click-ui'; +import * as Dialog from '@radix-ui/react-dialog'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge, Button, Icon, IconButton } from '@clickhouse/click-ui'; import type { ReactElement } from 'react'; import type * as t from '@/types'; -import { ACTION_BADGE_STATE, ACTION_LABEL_KEY, capabilityLabel, formatTimestamp } from './auditLogUtils'; +import { + ACTION_BADGE_STATE, + ACTION_LABEL_KEY, + capabilityLabel, + formatTimestamp, +} from './auditLogUtils'; import { getScopeTypeConfig } from '@/constants'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -15,10 +21,21 @@ interface AuditLogDetailDrawerProps { } function CopyableMono({ value, ariaLabel }: { value: string; ariaLabel: string }): ReactElement { + const [copied, setCopied] = useState(false); + const timerRef = useRef | undefined>(undefined); + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + const handleCopy = useCallback(() => { if (typeof navigator !== 'undefined' && navigator.clipboard) { void navigator.clipboard.writeText(value); } + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); }, [value]); return ( @@ -26,14 +43,16 @@ function CopyableMono({ value, ariaLabel }: { value: string; ariaLabel: string } type="button" onClick={handleCopy} aria-label={ariaLabel} + aria-live="polite" className={cn( - 'inline-flex items-center gap-1 rounded px-1 py-0.5 font-mono text-[11px]', + 'inline-flex w-fit items-center gap-1 self-start rounded px-1 py-0.5 font-mono text-[11px]', 'text-(--cui-color-text-muted) hover:bg-(--cui-color-background-hover)', 'focus:outline-2 focus:outline-(--cui-color-stroke-focus)', + copied && 'text-(--cui-color-feedback-success-foreground)', )} > {value} - + ); } @@ -75,41 +94,72 @@ export function AuditLogDetailDrawer({ }: AuditLogDetailDrawerProps): ReactElement | null { const localize = useLocalize(); - if (!entry || !open) return null; + // Keep the last non-null entry so the close animation has content to render + // while Radix Dialog slides the panel out. Without this, unmounting on + // `entry === null` would cut off the data-state="closed" exit animation. + const [latestEntry, setLatestEntry] = useState(entry); + useEffect(() => { + if (entry) setLatestEntry(entry); + }, [entry]); + + // Copied-feedback state for the permalink button. + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | undefined>(undefined); + useEffect(() => { + return () => { + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + }; + }, []); + const handleCopyPermalinkClick = useCallback(() => { + onCopyPermalink(); + setCopied(true); + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = setTimeout(() => setCopied(false), 1500); + }, [onCopyPermalink]); - const targetConfig = getScopeTypeConfig(entry.targetPrincipalType); + if (!latestEntry) return null; + + const targetConfig = getScopeTypeConfig(latestEntry.targetPrincipalType); const summaryKey = - entry.action === 'grant_assigned' + latestEntry.action === 'grant_assigned' ? 'com_audit_detail_summary_assigned' : 'com_audit_detail_summary_removed'; - const before = entry.before ?? []; - const after = entry.after ?? []; + const before = latestEntry.before ?? []; + const after = latestEntry.after ?? []; const hasDiff = before.length > 0 || after.length > 0; return ( - { if (!isOpen) onClose(); }} > - onClose()} - > - -
+ + + onClose()} + className={cn( + 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl sm:w-120', + 'border-l border-(--cui-color-stroke-default)', + 'will-change-transform', + 'data-[state=closed]:animate-drawer-out data-[state=open]:animate-drawer-in', + )} + > + {localize('com_audit_detail_title')} +
{localize('com_audit_detail_title')} @@ -122,127 +172,124 @@ export function AuditLogDetailDrawer({ aria-label={localize('com_audit_detail_close')} onClick={onClose} /> -
- +
- -
-

- {localize(summaryKey, { - actor: entry.actorName, - capability: capabilityLabel(entry.capability, localize), - target: entry.targetName, - })} -

+
+
+

+ {localize(summaryKey, { + actor: latestEntry.actorName, + capability: capabilityLabel(latestEntry.capability, localize), + target: latestEntry.targetName, + })} +

-
- -
- - {formatTimestamp(entry.timestamp)} - - -
-
+
+ +
+ + {formatTimestamp(latestEntry.timestamp)} + + +
+
- -
- - {entry.actorName} - - -
-
+ +
+ + {latestEntry.actorName} + + +
+
- -
- - - - {localize(targetConfig.labelKey)} - - } + +
+ + + + {localize(targetConfig.labelKey)} + + } + /> + + {latestEntry.targetName} + + + - - {entry.targetName} +
+
+ + +
+ + {capabilityLabel(latestEntry.capability, localize)} - + +
+
+ + -
-
+ +
- -
- - {capabilityLabel(entry.capability, localize)} - - - {entry.capability} - + {hasDiff && ( +
+
+
+

+ {localize('com_audit_detail_before')} +

+ +
+
+

+ {localize('com_audit_detail_after')} +

+ +
+
- - - - - -
- - {hasDiff && ( -
-
-
-

- {localize('com_audit_detail_before')} -

- -
-
-

- {localize('com_audit_detail_after')} -

- -
-
-
- )} + )} +
- - -
+
-
- - +
-
+
{localize('com_audit_date_from')} - setDateFrom(d ? dateToIsoDate(d) : '')} - placeholder={localize('com_audit_date_from')} - /> + + setDateFrom(d ? dateToIsoDate(d) : '')} + placeholder={localize('com_audit_date_from')} + /> +
-
+
{localize('com_audit_date_to')} - setDateTo(d ? dateToIsoDate(d) : '')} - placeholder={localize('com_audit_date_to')} - /> + + setDateTo(d ? dateToIsoDate(d) : '')} + placeholder={localize('com_audit_date_to')} + /> +
+ {(dateFrom || dateTo) && ( +
@@ -412,98 +364,51 @@ export function AuditLogTab() { type="secondary" iconLeft="download" onClick={() => void handleExport()} - disabled={entries.length === 0 || exporting} + disabled={total === 0 || exporting} loading={exporting} label={exportLabel} />
- {qualifierChips.length > 0 && ( -
- {qualifierChips.map((chip) => ( - removeQualifier(chip.removalToken)} - aria-label={localize('com_audit_qualifier_remove', { qualifier: chip.display })} - /> - ))} -
- )} - -
-
- setMoreOpen((v) => !v)} - /> -
- {selectedEntry && ( - - )} +
); } @@ -638,7 +519,7 @@ function AuditLogTableRow({ onClick={onActivate} onKeyDown={onKeyDown} className={cn( - 'cursor-pointer bg-(--cui-color-background-panel) outline-none hover:bg-(--cui-color-background-hover) focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-(--cui-color-outline)', + 'cursor-pointer bg-(--cui-color-background-panel) outline-none hover:bg-(--cui-color-background-hover) focus-visible:bg-(--cui-color-background-hover) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--cui-color-outline)', !isLast && 'border-b border-(--cui-color-stroke-default)', )} > diff --git a/src/components/grants/GrantManagementTab.tsx b/src/components/grants/GrantManagementTab.tsx index bef69f7..64ed216 100644 --- a/src/components/grants/GrantManagementTab.tsx +++ b/src/components/grants/GrantManagementTab.tsx @@ -29,15 +29,9 @@ export function GrantManagementTab() { const roleNames = useMemo(() => buildRoleNames(roles), [roles]); - const principals = useMemo( - () => aggregatePrincipals(grants, roleNames), - [grants, roleNames], - ); + const principals = useMemo(() => aggregatePrincipals(grants, roleNames), [grants, roleNames]); - const filtered = useMemo( - () => filterPrincipals(principals, search), - [principals, search], - ); + const filtered = useMemo(() => filterPrincipals(principals, search), [principals, search]); const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const paged = useMemo( diff --git a/src/components/grants/GrantsPage.tsx b/src/components/grants/GrantsPage.tsx index 43a1b98..e9ee8c4 100644 --- a/src/components/grants/GrantsPage.tsx +++ b/src/components/grants/GrantsPage.tsx @@ -11,21 +11,21 @@ export function GrantsPage({ activeTab, onTabChange }: t.GrantsPageProps) {
{localize('com_grants_tab_management')} {localize('com_grants_tab_audit_log')} - - - - - - - + + + +
+ {activeTab === 'management' && } + {activeTab === 'audit-log' && } +
); } diff --git a/src/components/grants/auditLogUtils.test.ts b/src/components/grants/auditLogUtils.test.ts index 2a8b9e7..494399f 100644 --- a/src/components/grants/auditLogUtils.test.ts +++ b/src/components/grants/auditLogUtils.test.ts @@ -3,13 +3,9 @@ import { PrincipalType } from 'librechat-data-provider'; import type { AdminAuditLogEntry } from '@librechat/data-schemas'; import { ACTION_BADGE_STATE, - ACTION_FILTER_LABELS, - AUDIT_ACTION_FILTERS, auditLogToCsv, capabilityLabel, - diffGrantState, formatTimestamp, - parseAuditSearch, } from './auditLogUtils'; const UTF8_BOM = ''; @@ -31,20 +27,6 @@ const identityLocalize = (k: string) => k; const expectedHeader = 'com_audit_csv_col_timestamp,com_audit_csv_col_action,com_audit_csv_col_actor,com_audit_csv_col_actor_id,com_audit_csv_col_target_type,com_audit_csv_col_target_id,com_audit_csv_col_target_name,com_audit_csv_col_capability'; -describe('ACTION_FILTER_LABELS', () => { - it('maps every filter value to a locale key', () => { - expect(ACTION_FILTER_LABELS.all).toBe('com_audit_filter_all'); - expect(ACTION_FILTER_LABELS.grant_assigned).toBe('com_audit_filter_assigned'); - expect(ACTION_FILTER_LABELS.grant_removed).toBe('com_audit_filter_removed'); - }); -}); - -describe('AUDIT_ACTION_FILTERS', () => { - it('exposes the ordered filter list', () => { - expect(AUDIT_ACTION_FILTERS).toEqual(['all', 'grant_assigned', 'grant_removed']); - }); -}); - describe('ACTION_BADGE_STATE', () => { it('maps each audit action to a badge state', () => { expect(ACTION_BADGE_STATE.grant_assigned).toBe('success'); @@ -171,144 +153,3 @@ describe('auditLogToCsv', () => { } }); }); - -describe('parseAuditSearch', () => { - it('returns an empty result for empty input', () => { - expect(parseAuditSearch('')).toEqual({ freeText: '', qualifiers: {} }); - }); - - it('places plain text into freeText', () => { - const result = parseAuditSearch('hello world'); - expect(result.qualifiers).toEqual({}); - expect(result.freeText).toBe('hello world'); - }); - - it('extracts a single actor qualifier', () => { - const result = parseAuditSearch('actor:alice'); - expect(result.qualifiers.actor).toBe('alice'); - expect(result.freeText).toBe(''); - }); - - it('extracts multiple qualifiers', () => { - const result = parseAuditSearch('actor:alice capability:manage:configs'); - expect(result.qualifiers.actor).toBe('alice'); - expect(result.qualifiers.capability).toBe('manage:configs'); - }); - - it('supports a target qualifier', () => { - const result = parseAuditSearch('target:bob'); - expect(result.qualifiers.target).toBe('bob'); - }); - - it('handles quoted multi-word qualifier values', () => { - const result = parseAuditSearch('actor:"Alice Admin"'); - expect(result.qualifiers.actor).toBe('Alice Admin'); - expect(result.freeText).toBe(''); - }); - - it('maps created:> to createdAfter', () => { - const result = parseAuditSearch('created:>2026-05-01'); - expect(result.qualifiers.createdAfter).toBe('2026-05-01'); - expect(result.qualifiers.createdBefore).toBeUndefined(); - }); - - it('maps created:>= to createdAfter', () => { - const result = parseAuditSearch('created:>=2026-05-01'); - expect(result.qualifiers.createdAfter).toBe('2026-05-01'); - }); - - it('maps created:< to createdBefore', () => { - const result = parseAuditSearch('created:<2026-05-31'); - expect(result.qualifiers.createdBefore).toBe('2026-05-31'); - expect(result.qualifiers.createdAfter).toBeUndefined(); - }); - - it('maps created:<= to createdBefore', () => { - const result = parseAuditSearch('created:<=2026-05-31'); - expect(result.qualifiers.createdBefore).toBe('2026-05-31'); - }); - - it('treats created without an operator as an exact day window', () => { - const result = parseAuditSearch('created:2026-05-01'); - expect(result.qualifiers.createdAfter).toBe('2026-05-01'); - expect(result.qualifiers.createdBefore).toBe('2026-05-01'); - }); - - it('keeps free text alongside qualifiers', () => { - const result = parseAuditSearch('login actor:alice from prod'); - expect(result.qualifiers.actor).toBe('alice'); - expect(result.freeText).toBe('login from prod'); - }); - - it('accepts qualifier keys case-insensitively', () => { - expect(parseAuditSearch('Actor:alice').qualifiers.actor).toBe('alice'); - expect(parseAuditSearch('ACTOR:alice').qualifiers.actor).toBe('alice'); - }); - - it('treats unknown qualifier keys as free text', () => { - const result = parseAuditSearch('foo:bar actor:alice'); - expect(result.qualifiers.actor).toBe('alice'); - expect(result.freeText).toBe('foo:bar'); - }); - - it('combines actor + created range + free text', () => { - const result = parseAuditSearch( - 'actor:alice created:>2026-05-01 created:<2026-05-31 audit', - ); - expect(result.qualifiers).toEqual({ - actor: 'alice', - createdAfter: '2026-05-01', - createdBefore: '2026-05-31', - }); - expect(result.freeText).toBe('audit'); - }); -}); - -describe('diffGrantState', () => { - it('returns an empty diff when both sides are undefined', () => { - expect(diffGrantState(undefined, undefined)).toEqual({ - added: [], - removed: [], - unchanged: [], - }); - }); - - it('returns an empty diff when both sides are empty arrays', () => { - expect(diffGrantState([], [])).toEqual({ added: [], removed: [], unchanged: [] }); - }); - - it('treats every capability as added when before is missing', () => { - const diff = diffGrantState(undefined, ['a', 'b']); - expect(new Set(diff.added)).toEqual(new Set(['a', 'b'])); - expect(diff.removed).toEqual([]); - expect(diff.unchanged).toEqual([]); - }); - - it('treats every capability as removed when after is missing', () => { - const diff = diffGrantState(['a', 'b'], undefined); - expect(new Set(diff.removed)).toEqual(new Set(['a', 'b'])); - expect(diff.added).toEqual([]); - expect(diff.unchanged).toEqual([]); - }); - - it('classifies added, removed, and unchanged capabilities', () => { - const diff = diffGrantState(['a', 'b', 'c'], ['b', 'c', 'd']); - expect(new Set(diff.added)).toEqual(new Set(['d'])); - expect(new Set(diff.removed)).toEqual(new Set(['a'])); - expect(new Set(diff.unchanged)).toEqual(new Set(['b', 'c'])); - }); - - it('returns identical input as fully unchanged', () => { - const diff = diffGrantState(['a', 'b'], ['a', 'b']); - expect(new Set(diff.unchanged)).toEqual(new Set(['a', 'b'])); - expect(diff.added).toEqual([]); - expect(diff.removed).toEqual([]); - }); - - it('deduplicates repeated capabilities via Set semantics', () => { - const diff = diffGrantState(['a', 'a', 'b'], ['a', 'a', 'c']); - expect(diff.unchanged).toEqual(['a']); - expect(diff.added).toEqual(['c']); - expect(diff.removed).toEqual(['b']); - }); -}); diff --git a/src/components/grants/auditLogUtils.ts b/src/components/grants/auditLogUtils.ts index 706f222..25a8657 100644 --- a/src/components/grants/auditLogUtils.ts +++ b/src/components/grants/auditLogUtils.ts @@ -1,17 +1,4 @@ import type { AdminAuditLogEntry, AuditAction } from '@librechat/data-schemas'; -import type * as t from '@/types'; - -export const ACTION_FILTER_LABELS: Record = { - all: 'com_audit_filter_all', - grant_assigned: 'com_audit_filter_assigned', - grant_removed: 'com_audit_filter_removed', -}; - -export const AUDIT_ACTION_FILTERS: readonly t.ActionFilter[] = [ - 'all', - 'grant_assigned', - 'grant_removed', -] as const; export const ACTION_BADGE_STATE: Record = { grant_assigned: 'success', @@ -34,12 +21,10 @@ const CSV_COLUMNS = [ { key: 'capability', labelKey: 'com_audit_csv_col_capability' }, ] as const satisfies readonly { key: keyof AdminAuditLogEntry; labelKey: string }[]; -type _CsvColumnsExhaustive = Exclude< - keyof AdminAuditLogEntry, - 'id' | (typeof CSV_COLUMNS)[number]['key'] -> extends never - ? true - : never; +type _CsvColumnsExhaustive = + Exclude extends never + ? true + : never; const _csvColumnsExhaustive: _CsvColumnsExhaustive = true; void _csvColumnsExhaustive; @@ -85,93 +70,3 @@ export function auditLogToCsv( ); return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'; } - -const QUALIFIER_KEYS = new Set(['actor', 'target', 'capability', 'created']); -const TOKEN_RE = /(\w+):(>?' || op === '>=') { - qualifiers.createdAfter = value; - return; - } - if (op === '<' || op === '<=') { - qualifiers.createdBefore = value; - return; - } - qualifiers.createdAfter = value; - qualifiers.createdBefore = value; - return; - } - if (key === 'actor') { - qualifiers.actor = value; - return; - } - if (key === 'target') { - qualifiers.target = value; - return; - } - if (key === 'capability') { - qualifiers.capability = value; - } -} - -export function parseAuditSearch(input: string): t.ParsedAuditSearch { - const qualifiers: t.AuditSearchQualifiers = {}; - const freeTextParts: string[] = []; - if (!input) { - return { freeText: '', qualifiers }; - } - - for (const match of input.matchAll(TOKEN_RE)) { - const [raw, key, op, quotedValue, bareValue, quotedFree, bareFree] = match; - const normalizedKey = key?.toLowerCase(); - if (normalizedKey && QUALIFIER_KEYS.has(normalizedKey)) { - const value = quotedValue ?? bareValue ?? ''; - if (!value) { - continue; - } - assignQualifier(qualifiers, normalizedKey, op, value); - continue; - } - if (key) { - freeTextParts.push(raw); - continue; - } - const free = quotedFree ?? bareFree ?? ''; - if (free) { - freeTextParts.push(free); - } - } - - return { freeText: freeTextParts.join(' '), qualifiers }; -} - -export function diffGrantState( - before: readonly string[] | undefined, - after: readonly string[] | undefined, -): t.GrantDiff { - const beforeSet = new Set(before ?? []); - const afterSet = new Set(after ?? []); - const added: string[] = []; - const removed: string[] = []; - const unchanged: string[] = []; - for (const cap of afterSet) { - if (beforeSet.has(cap)) { - unchanged.push(cap); - } else { - added.push(cap); - } - } - for (const cap of beforeSet) { - if (!afterSet.has(cap)) { - removed.push(cap); - } - } - return { added, removed, unchanged }; -} diff --git a/src/components/grants/index.ts b/src/components/grants/index.ts index 4a606e6..ffe61d6 100644 --- a/src/components/grants/index.ts +++ b/src/components/grants/index.ts @@ -1,9 +1,14 @@ -export { ACTION_BADGE_STATE, ACTION_FILTER_LABELS, AUDIT_ACTION_FILTERS, auditLogToCsv, capabilityLabel, diffGrantState, formatTimestamp, parseAuditSearch } from './auditLogUtils'; +export { + ACTION_BADGE_STATE, + ACTION_LABEL_KEY, + auditLogToCsv, + capabilityLabel, + formatTimestamp, +} from './auditLogUtils'; export { aggregatePrincipals, buildRoleNames, filterPrincipals } from './utils'; export { EditCapabilitiesDialog } from './EditCapabilitiesDialog'; export { GrantManagementTab } from './GrantManagementTab'; export { CapabilityPanel } from './CapabilityPanel'; -export { AuditLogRow } from './AuditLogRow'; export { AuditLogDetailDrawer } from './AuditLogDetailDrawer'; export { AuditLogTab } from './AuditLogTab'; export { GrantTableRow } from './GrantTableRow'; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 55b95a7..c8992ed 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2,6 +2,9 @@ "com_ui_read_more": "Read more", "com_ui_add_item": "Add {{item}}", "com_ui_delete": "Delete", + "com_ui_clear": "Clear", + "com_a11y_clear_dates": "Clear date filters", + "com_audit_detail_copied": "Copied!", "com_ui_remove_item": "Remove {{name}}", "com_ui_save": "Save", "com_ui_add": "Add", @@ -1035,7 +1038,6 @@ "com_audit_filter_all": "All actions", "com_audit_filter_assigned": "Granted", "com_audit_filter_removed": "Revoked", - "com_audit_export_csv": "Export as CSV", "com_audit_empty": "No audit log entries found", "com_audit_entry_count_zero": "No entries", "com_audit_entry_count_one": "{{count}} entry", @@ -1044,20 +1046,12 @@ "com_audit_date_to": "To", "com_audit_filter_action_label": "Filter by action", "com_audit_search_label": "Search audit log", - "com_audit_search_placeholder": "Search actor, target, or capability", - "com_audit_search_placeholder_qualifiers": "Search or use qualifiers: actor:, target:, capability:, created:>YYYY-MM-DD", - "com_audit_filter_more": "More filters", - "com_audit_filter_actor_id": "Actor ID", - "com_audit_filter_target_id": "Target ID", + "com_audit_filter_actor_id": "Actor", + "com_audit_filter_target_id": "Target", "com_audit_filter_target_type": "Target type", "com_audit_filter_capability": "Capability", - "com_audit_load_more": "Load more", - "com_audit_loading_more": "Loading more entries…", - "com_audit_no_more": "End of audit log", - "com_audit_qualifier_remove": "Remove {{qualifier}} filter", "com_audit_export_client": "Export current results", "com_audit_export_server": "Export full match (server)", - "com_a11y_audit_page_loaded": "Loaded {{count}} more entries", "com_a11y_audit_row_open": "Open audit log entry", "com_audit_error": "Failed to load audit log", "com_audit_csv_col_timestamp": "Timestamp", @@ -1078,8 +1072,6 @@ "com_audit_detail_entry_id": "Entry ID", "com_audit_detail_before": "Before", "com_audit_detail_after": "After", - "com_audit_detail_diff_added": "Added", - "com_audit_detail_diff_removed": "Removed", "com_audit_detail_no_changes": "No detailed changes recorded", "com_audit_detail_copy_permalink": "Copy link", "com_audit_detail_close": "Close", diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index edfda67..5d8436e 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -7,7 +7,7 @@ */ import { z } from 'zod'; -import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; +import { queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; import { PrincipalType } from 'librechat-data-provider'; import { hasImpliedCapability, SystemCapabilities } from '@librechat/data-schemas/capabilities'; @@ -223,10 +223,7 @@ export const revokeCapabilityFn = createServerFn({ method: 'POST' }) const isoDate = z .string() - .regex( - /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?Z?)?$/, - 'Expected ISO 8601 date', - ); + .regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?Z?)?$/, 'Expected ISO 8601 date'); const auditFilterSchema = z.object({ search: z.string().max(200).optional(), @@ -237,7 +234,7 @@ const auditFilterSchema = z.object({ targetPrincipalType: z.nativeEnum(PrincipalType).optional(), targetPrincipalId: z.string().max(128).optional(), capability: z.string().max(128).optional(), - cursor: z.string().max(256).optional(), + offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(500).optional(), }); @@ -259,11 +256,13 @@ const adminAuditLogEntrySchema = z.object({ const auditLogPageResponseSchema = z.object({ entries: z.array(adminAuditLogEntrySchema), - nextCursor: z.string().nullable(), + total: z.number().int().min(0), }); export type AuditLogPage = z.infer; +export const AUDIT_LOG_PAGE_SIZE = 50; + function buildAuditLogQuery(filters: AuditFilters): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) { @@ -286,7 +285,7 @@ export const getAuditLogPageFn = createServerFn({ method: 'GET' }) SystemCapabilities.MANAGE_USERS, SystemCapabilities.MANAGE_GROUPS, ]); - const withDefaults: AuditFilters = { limit: 100, ...data }; + const withDefaults: AuditFilters = { limit: AUDIT_LOG_PAGE_SIZE, ...data }; const response = await apiFetch(`/api/admin/audit-log${buildAuditLogQuery(withDefaults)}`); if (!response.ok) { await extractApiError(response, 'Failed to fetch audit log'); @@ -294,15 +293,20 @@ export const getAuditLogPageFn = createServerFn({ method: 'GET' }) return auditLogPageResponseSchema.parse(await response.json()); }); -export const auditLogInfiniteQueryOptions = ( - filters: Omit = {}, +export const auditLogQueryOptions = ( + page: number, + filters: Omit = {}, ) => - infiniteQueryOptions({ - queryKey: ['auditLog', 'infinite', filters], - queryFn: ({ pageParam }) => - getAuditLogPageFn({ data: { ...filters, cursor: pageParam } }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + queryOptions({ + queryKey: ['auditLog', page, filters] as const, + queryFn: () => + getAuditLogPageFn({ + data: { + ...filters, + offset: (Math.max(1, page) - 1) * AUDIT_LOG_PAGE_SIZE, + limit: AUDIT_LOG_PAGE_SIZE, + }, + }), staleTime: 60_000, }); @@ -314,10 +318,10 @@ export const exportAuditLogServerFn = createServerFn({ method: 'POST' }) SystemCapabilities.MANAGE_USERS, SystemCapabilities.MANAGE_GROUPS, ]); - const response = await apiFetch( - `/api/admin/audit-log/export.csv${buildAuditLogQuery(data)}`, - { method: 'GET', headers: { Accept: 'text/csv' } }, - ); + const response = await apiFetch(`/api/admin/audit-log/export.csv${buildAuditLogQuery(data)}`, { + method: 'GET', + headers: { Accept: 'text/csv' }, + }); if (!response.ok) { await extractApiError(response, 'Failed to export audit log'); } diff --git a/src/styles.css b/src/styles.css index 4c66730..d61fb54 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3,9 +3,51 @@ @theme { --z-sticky: 10; /* sticky table/section headers (in-tree) */ --z-floating: 20; /* sidebar, content toolbars (in-tree) */ + --z-overlay: 60; /* drawer / modal overlay + content */ --z-command-overlay: 69; /* command menu backdrop */ --z-command: 70; /* command menu panel */ --z-toast: 80; /* toasts */ + + --animate-drawer-in: drawer-slide-in 240ms cubic-bezier(0.32, 0.72, 0, 1); + --animate-drawer-out: drawer-slide-out 200ms cubic-bezier(0.32, 0.72, 0, 1); + --animate-overlay-in: overlay-fade-in 240ms ease-out; + --animate-overlay-out: overlay-fade-out 200ms ease-out; +} + +@keyframes drawer-slide-in { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes drawer-slide-out { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } +} + +@keyframes overlay-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes overlay-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } } @custom-variant dark { @@ -327,6 +369,12 @@ summary:focus-visible { border-color: var(--cui-color-outline) !important; } +/* click-ui DatePicker: round the PopoverTrigger button so the global + focus-visible outline matches the input wrapper's border-radius. */ +.audit-date-cell button { + border-radius: 4px; +} + input:not([cmdk-input]):focus-visible, textarea:focus-visible { outline: 1px solid var(--cui-color-outline) !important; @@ -922,4 +970,3 @@ body > div:has(+ .modal-frost) { background-color: var(--cui-color-feedback-user-bg); color: var(--cui-color-feedback-user-fg); } - diff --git a/src/types/grant.ts b/src/types/grant.ts index 273b88f..be43f91 100644 --- a/src/types/grant.ts +++ b/src/types/grant.ts @@ -1,4 +1,4 @@ -import type { AdminAuditLogEntry, AuditAction } from '@librechat/data-schemas'; +import type { AdminAuditLogEntry } from '@librechat/data-schemas'; import type { PrincipalType } from 'librechat-data-provider'; import type { KeyboardEvent } from 'react'; @@ -7,25 +7,6 @@ export interface AuditLogEntryWithDiff extends AdminAuditLogEntry { after?: readonly string[]; } -export interface AuditSearchQualifiers { - actor?: string; - target?: string; - capability?: string; - createdAfter?: string; - createdBefore?: string; -} - -export interface ParsedAuditSearch { - freeText: string; - qualifiers: AuditSearchQualifiers; -} - -export interface GrantDiff { - added: readonly string[]; - removed: readonly string[]; - unchanged: readonly string[]; -} - export interface PrincipalRow { principalType: PrincipalType; principalId: string; @@ -34,13 +15,6 @@ export interface PrincipalRow { isActive: boolean; } -export type ActionFilter = 'all' | AuditAction; - -export interface AuditLogRowProps { - entry: AdminAuditLogEntry; - isLast: boolean; -} - export interface CapabilityPanelProps { capabilities: Record; onChange: (capabilities: Record) => void; From 2c74905bf0f4ef2d2903dc3c910484b23309f967 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:03:45 -0700 Subject: [PATCH 06/21] =?UTF-8?q?=F0=9F=94=92=20fix:=20CSP=20report-only?= =?UTF-8?q?=20fallback=20to=20unblock=20SSR=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Start's SSR injects an inline `` 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. --- server.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server.ts b/server.ts index 7f54e3c..6c4e5ed 100644 --- a/server.ts +++ b/server.ts @@ -41,6 +41,12 @@ function getCacheHeaders(filePath: string): Record { } // 'unsafe-inline' in style-src is required because Tailwind 4 + click-ui inject inline styles at runtime. +// TanStack Start's SSR injects an inline `` to +// boot the client. Without a nonce or 'unsafe-inline' for script-src, browsers will block hydration. +// Threading a per-request nonce through TanStack Start's manifest is non-trivial; until that wiring +// lands we ship the policy as report-only so it surfaces violations in dev tooling without breaking +// hydration in prod. Set ADMIN_PANEL_CSP_ENFORCE=true to switch back to enforcement (only safe once +// the nonce path is in place). const CSP_VALUE = [ "default-src 'self'", "script-src 'self'", @@ -54,10 +60,15 @@ const CSP_VALUE = [ "form-action 'self'", ].join('; '); +const CSP_ENFORCE = process.env.ADMIN_PANEL_CSP_ENFORCE === 'true'; +const CSP_HEADER_NAME = CSP_ENFORCE + ? 'Content-Security-Policy' + : 'Content-Security-Policy-Report-Only'; + function applySecurityHeaders(headers: Headers): void { const contentType = headers.get('Content-Type') ?? ''; if (!contentType.toLowerCase().startsWith('text/html')) return; - headers.set('Content-Security-Policy', CSP_VALUE); + headers.set(CSP_HEADER_NAME, CSP_VALUE); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('X-Frame-Options', 'DENY'); From 1582498aae3eafa72290fc615e09f10dc933cf5d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:13:22 -0700 Subject: [PATCH 07/21] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Stabilize?= =?UTF-8?q?=20`localize`=20reference=20across=20renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/hooks/useLocalize.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/hooks/useLocalize.ts b/src/hooks/useLocalize.ts index 3287292..949516f 100644 --- a/src/hooks/useLocalize.ts +++ b/src/hooks/useLocalize.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import type * as t from '@/types'; @@ -7,8 +8,9 @@ export function useLocalize(): ( ) => string { const { t: translate } = useTranslation(); - const localize = (phraseKey: t.TranslationKeys, options?: Record) => - translate(phraseKey, options ?? {}); - - return localize; + return useCallback( + (phraseKey: t.TranslationKeys, options?: Record) => + translate(phraseKey, options ?? {}), + [translate], + ); } From 26b90c9cab330ace0f5b9266efde0d078d9fe97c Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:13:32 -0700 Subject: [PATCH 08/21] =?UTF-8?q?=F0=9F=94=92=20fix:=20Close=20CSV=20formu?= =?UTF-8?q?la-injection=20prefix=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/grants/auditLogUtils.test.ts | 104 ++++++++++++++++++++ src/components/grants/auditLogUtils.ts | 65 +++++++++++- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/components/grants/auditLogUtils.test.ts b/src/components/grants/auditLogUtils.test.ts index 494399f..5bd476c 100644 --- a/src/components/grants/auditLogUtils.test.ts +++ b/src/components/grants/auditLogUtils.test.ts @@ -5,7 +5,10 @@ import { ACTION_BADGE_STATE, auditLogToCsv, capabilityLabel, + dateToIsoDate, formatTimestamp, + isoDateToDate, + localDayBoundaryIso, } from './auditLogUtils'; const UTF8_BOM = ''; @@ -151,5 +154,106 @@ describe('auditLogToCsv', () => { expect(csv).not.toContain(`,${payload},`); }); } + + const obscured: Array<{ name: string; prefix: string }> = [ + { name: 'leading-space', prefix: ' ' }, + { name: 'leading-tab', prefix: '\t' }, + { name: 'leading-newline', prefix: '\n' }, + { name: 'NBSP', prefix: ' ' }, + { name: 'BOM', prefix: '' }, + ]; + + for (const { name, prefix } of obscured) { + it(`defangs payloads obscured by a ${name} before an equals sign`, () => { + const payload = `${prefix}=SUM(A1)`; + const malicious: AdminAuditLogEntry = { + ...sampleEntry, + actorName: payload, + }; + const csv = auditLogToCsv([malicious], identityLocalize); + const guarded = `'${payload}`; + const expectedCell = /[",\n\r]/.test(guarded) + ? `"${guarded.replace(/"/g, '""')}"` + : guarded; + expect(csv).toContain(expectedCell); + }); + } + + const lineFeedTriggers: Array<{ name: string; char: string }> = [ + { name: 'line-feed', char: '\n' }, + { name: 'pipe', char: '|' }, + ]; + + for (const { name, char } of lineFeedTriggers) { + it(`defangs payloads starting with ${name}`, () => { + const payload = `${char}cmd|'/C calc'!A0`; + const malicious: AdminAuditLogEntry = { + ...sampleEntry, + actorName: payload, + }; + const csv = auditLogToCsv([malicious], identityLocalize); + const guarded = `'${payload}`; + const expectedCell = /[",\n\r]/.test(guarded) + ? `"${guarded.replace(/"/g, '""')}"` + : guarded; + expect(csv).toContain(expectedCell); + }); + } + }); +}); + +describe('isoDateToDate / dateToIsoDate', () => { + it('round-trips a YYYY-MM-DD value in local time', () => { + const date = isoDateToDate('2026-05-14'); + expect(date).toBeInstanceOf(Date); + if (!date) return; + expect(date.getFullYear()).toBe(2026); + expect(date.getMonth()).toBe(4); + expect(date.getDate()).toBe(14); + expect(dateToIsoDate(date)).toBe('2026-05-14'); + }); + + it('returns undefined for empty input', () => { + expect(isoDateToDate('')).toBeUndefined(); + }); + + it('returns undefined for malformed input', () => { + expect(isoDateToDate('not-a-date')).toBeUndefined(); + expect(isoDateToDate('2026-13-01')).toBeUndefined(); + }); +}); + +describe('localDayBoundaryIso', () => { + it('returns undefined for empty input', () => { + expect(localDayBoundaryIso('', 'start')).toBeUndefined(); + expect(localDayBoundaryIso('', 'end')).toBeUndefined(); + }); + + it('produces start-of-day in local time for the start boundary', () => { + const out = localDayBoundaryIso('2026-05-14', 'start'); + expect(out).toBeTruthy(); + if (!out) return; + const parsed = new Date(out); + expect(parsed.getFullYear()).toBe(2026); + expect(parsed.getMonth()).toBe(4); + expect(parsed.getDate()).toBe(14); + expect(parsed.getHours()).toBe(0); + expect(parsed.getMinutes()).toBe(0); + expect(parsed.getSeconds()).toBe(0); + expect(parsed.getMilliseconds()).toBe(0); + }); + + it('produces end-of-day (23:59:59.999) in local time for the end boundary', () => { + const out = localDayBoundaryIso('2026-05-14', 'end'); + expect(out).toBeTruthy(); + if (!out) return; + const parsed = new Date(out); + expect(parsed.getFullYear()).toBe(2026); + expect(parsed.getMonth()).toBe(4); + expect(parsed.getDate()).toBe(14); + expect(parsed.getHours()).toBe(23); + expect(parsed.getMinutes()).toBe(59); + expect(parsed.getSeconds()).toBe(59); + expect(parsed.getMilliseconds()).toBe(999); }); }); diff --git a/src/components/grants/auditLogUtils.ts b/src/components/grants/auditLogUtils.ts index 25a8657..64c5ec4 100644 --- a/src/components/grants/auditLogUtils.ts +++ b/src/components/grants/auditLogUtils.ts @@ -28,9 +28,61 @@ type _CsvColumnsExhaustive = const _csvColumnsExhaustive: _CsvColumnsExhaustive = true; void _csvColumnsExhaustive; -const FORMULA_PREFIX = /^[=+\-@\t\r]/; +// Strip leading characters spreadsheets render as visual whitespace but that +// are NOT themselves formula triggers (space, NBSP  , BOM ). This +// is the "decoy" sneak path — a payload like " =SUM(...)" would otherwise pass +// the raw-prefix check yet still execute when Excel renders it. +const NON_TRIGGER_LEADING = /^[  ]+/; +// Cover spreadsheet-formula triggers (`=` `+` `-` `@`) and Excel-only command +// vectors (`\t` `\r` `\n` `|`). The vertical bar is part of DDE invocation +// (e.g. `|cmd|`), and `\n` matches what spreadsheets accept as a new line. +const FORMULA_PREFIX = /^[=+\-@\t\r\n|]/; const UTF8_BOM = ''; +/** Parse a `YYYY-MM-DD` filter value as a local-time date so the DatePicker + * round-trips the same calendar day the user picked, regardless of TZ. + * Rejects rolled-over inputs like `2026-13-01` (which `Date` would silently + * coerce to January 2027) by re-checking the parsed components. */ +export function isoDateToDate(iso: string): Date | undefined { + if (!iso) return undefined; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!match) return undefined; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + if (Number.isNaN(date.getTime())) return undefined; + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; + } + return date; +} + +export function dateToIsoDate(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +/** Convert a `YYYY-MM-DD` filter value into the ISO timestamp for the start + * (inclusive) or end (inclusive, millisecond-precise) of that local-time day. + * Mixing local-day pick-list values with UTC midnight (the prior behaviour) + * caused off-by-one filtering for any non-UTC user. */ +export function localDayBoundaryIso( + iso: string, + boundary: 'start' | 'end', +): string | undefined { + const date = isoDateToDate(iso); + if (!date) return undefined; + if (boundary === 'end') date.setHours(23, 59, 59, 999); + return date.toISOString(); +} + export function formatTimestamp(iso: string, locale: string | undefined = undefined): string { try { return new Intl.DateTimeFormat(locale, { @@ -51,9 +103,18 @@ export function capabilityLabel(cap: string, localize: (key: string) => string): return label !== key ? label : cap; } +/** Treat a cell as a formula-injection vector if its first character is a + * formula trigger, or if removing leading non-trigger whitespace (space, NBSP, + * BOM) reveals one. Trimming the entire `\s` class would mistakenly accept + * payloads that lead with `\r` / `\n` / `\t`, which are themselves triggers. */ +function hasFormulaPrefix(value: string): boolean { + if (FORMULA_PREFIX.test(value)) return true; + return FORMULA_PREFIX.test(value.replace(NON_TRIGGER_LEADING, '')); +} + function escapeCsvCell(value: string): string { if (value === '') return ''; - const guarded = FORMULA_PREFIX.test(value) ? `'${value}` : value; + const guarded = hasFormulaPrefix(value) ? `'${value}` : value; if (/[",\n\r]/.test(guarded)) { return `"${guarded.replace(/"/g, '""')}"`; } From 5b2d1ae2a5863f4ec5b3b2f51507b47ccf50fb56 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:13:42 -0700 Subject: [PATCH 09/21] =?UTF-8?q?=E2=9A=A1=20perf:=20De-duplicate=20effect?= =?UTF-8?q?ive-capabilities=20fetch=20per=20audit-log=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/server/capabilities.ts | 71 ++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 5d8436e..c34f4ec 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -115,19 +115,26 @@ export async function requireCapability(capability: string): Promise { } } -/** Require at least one of the given capabilities. - * @throws if `required` is empty — callers must provide at least one capability. */ -export async function requireAnyCapability(required: string[]): Promise { +/** Check `requireAnyCapability` against an already-fetched capability set. + * Lets handlers reuse a single effective-capabilities lookup across the guard + * and any follow-up backend call instead of hitting `/effective` twice. */ +export function checkAnyCapability(held: string[], required: readonly string[]): void { if (required.length === 0) { throw new Error('No capabilities provided for check'); } - const { capabilities } = await getEffectiveCapabilitiesFn(); for (const cap of required) { - if (hasImpliedCapability(capabilities, cap)) return; + if (hasImpliedCapability(held, cap)) return; } throw new Error(`Insufficient permissions: requires one of ${required.join(', ')}`); } +/** Require at least one of the given capabilities. + * @throws if `required` is empty — callers must provide at least one capability. */ +export async function requireAnyCapability(required: string[]): Promise { + const { capabilities } = await getEffectiveCapabilitiesFn(); + checkAnyCapability(capabilities, required); +} + /** * Require a capability for every config section in a batch. * Short-circuits if the user holds the broad MANAGE_CONFIGS capability. @@ -263,6 +270,12 @@ export type AuditLogPage = z.infer; export const AUDIT_LOG_PAGE_SIZE = 50; +const AUDIT_LOG_REQUIRED_CAPS = [ + SystemCapabilities.MANAGE_ROLES, + SystemCapabilities.MANAGE_USERS, + SystemCapabilities.MANAGE_GROUPS, +]; + function buildAuditLogQuery(filters: AuditFilters): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) { @@ -277,14 +290,19 @@ function buildAuditLogQuery(filters: AuditFilters): string { return qs ? `?${qs}` : ''; } +/** Each handler invocation fetches effective capabilities exactly once, then + * runs the guard against the cached set. The previous shape called + * `requireAnyCapability` (one round-trip) followed by the backend call + * (another round-trip), doubling traffic for every page of the audit log. */ +async function guardAuditLogAccess(): Promise { + const { capabilities } = await getEffectiveCapabilitiesFn(); + checkAnyCapability(capabilities, AUDIT_LOG_REQUIRED_CAPS); +} + export const getAuditLogPageFn = createServerFn({ method: 'GET' }) .inputValidator(auditFilterSchema) .handler(async ({ data }: { data: AuditFilters }): Promise => { - await requireAnyCapability([ - SystemCapabilities.MANAGE_ROLES, - SystemCapabilities.MANAGE_USERS, - SystemCapabilities.MANAGE_GROUPS, - ]); + await guardAuditLogAccess(); const withDefaults: AuditFilters = { limit: AUDIT_LOG_PAGE_SIZE, ...data }; const response = await apiFetch(`/api/admin/audit-log${buildAuditLogQuery(withDefaults)}`); if (!response.ok) { @@ -310,14 +328,37 @@ export const auditLogQueryOptions = ( staleTime: 60_000, }); +export const getAuditLogEntryFn = createServerFn({ method: 'GET' }) + .inputValidator(z.object({ id: z.string().min(1).max(128) })) + .handler( + async ({ + data, + }: { + data: { id: string }; + }): Promise<{ entry: z.infer | null }> => { + await guardAuditLogAccess(); + const response = await apiFetch(`/api/admin/audit-log/${encodeURIComponent(data.id)}`); + if (response.status === 404) return { entry: null }; + if (!response.ok) { + await extractApiError(response, 'Failed to fetch audit log entry'); + } + const json = (await response.json()) as { entry: unknown }; + return { entry: adminAuditLogEntrySchema.parse(json.entry) }; + }, + ); + +export const auditLogEntryQueryOptions = (id: string | undefined) => + queryOptions({ + queryKey: ['auditLogEntry', id] as const, + queryFn: () => getAuditLogEntryFn({ data: { id: id ?? '' } }), + enabled: !!id, + staleTime: 60_000, + }); + export const exportAuditLogServerFn = createServerFn({ method: 'POST' }) .inputValidator(auditFilterSchema) .handler(async ({ data }: { data: AuditFilters }): Promise<{ csv: string }> => { - await requireAnyCapability([ - SystemCapabilities.MANAGE_ROLES, - SystemCapabilities.MANAGE_USERS, - SystemCapabilities.MANAGE_GROUPS, - ]); + await guardAuditLogAccess(); const response = await apiFetch(`/api/admin/audit-log/export.csv${buildAuditLogQuery(data)}`, { method: 'GET', headers: { Accept: 'text/csv' }, From ea79bd62a05cd9efe10cf2d4b6a857bf8419618e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:13:49 -0700 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Extract=20`useDeb?= =?UTF-8?q?ouncedFilter`=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/index.ts | 1 + src/hooks/useDebouncedFilter.ts | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/hooks/useDebouncedFilter.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e29189f..8991119 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,6 +2,7 @@ export * from './useActiveSection'; export * from './useAnnouncement'; export * from './useCapabilities'; export * from './useCommandMenu'; +export * from './useDebouncedFilter'; export * from './useHighlightRef'; export * from './useLocalize'; export * from './useProfileMutations'; diff --git a/src/hooks/useDebouncedFilter.ts b/src/hooks/useDebouncedFilter.ts new file mode 100644 index 0000000..e109074 --- /dev/null +++ b/src/hooks/useDebouncedFilter.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface DebouncedFilter { + readonly value: string; + readonly debouncedValue: string; + readonly onChange: (next: string) => void; +} + +/** Two-state debounced text filter: `value` mirrors keystrokes for controlled + * inputs; `debouncedValue` is the value the consumer should feed into the + * actual filter / query key. `onCommit` fires once per quiescent settle so + * callers can perform side effects (e.g. resetting pagination). */ +export function useDebouncedFilter( + initial: string, + onCommit: () => void, + delay = 300, +): DebouncedFilter { + const [value, setValue] = useState(initial); + const [debouncedValue, setDebouncedValue] = useState(initial); + const timerRef = useRef>(undefined); + const commitRef = useRef(onCommit); + commitRef.current = onCommit; + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const onChange = useCallback( + (next: string) => { + setValue(next); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setDebouncedValue(next); + commitRef.current(); + }, delay); + }, + [delay], + ); + + return { value, debouncedValue, onChange }; +} From 403b263b95ffea187085565875f04714aef7c6e7 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:14:08 -0700 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Audit=20lo?= =?UTF-8?q?g=20deep-links,=20exports,=20clipboard,=20and=20TZ=20correctnes?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../grants/AuditLogDetailDrawer.tsx | 101 ++++++++- src/components/grants/AuditLogTab.tsx | 213 ++++++++---------- src/locales/en/translation.json | 8 +- 3 files changed, 183 insertions(+), 139 deletions(-) diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx index a45c6c0..190e23c 100644 --- a/src/components/grants/AuditLogDetailDrawer.tsx +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -17,10 +17,23 @@ interface AuditLogDetailDrawerProps { entry: t.AuditLogEntryWithDiff | null; open: boolean; onClose: () => void; - onCopyPermalink: () => void; + /** Resolves to `true` when the clipboard write succeeded so the drawer only + * flips to its "Copied!" affordance after a real success. */ + onCopyPermalink: (entryId: string) => Promise; + /** Render a "no entry found" message instead of the detail body when the + * deep-linked id couldn't be located (e.g. the entry was purged). */ + notFound?: boolean; } -function CopyableMono({ value, ariaLabel }: { value: string; ariaLabel: string }): ReactElement { +function CopyableMono({ + value, + ariaLabel, + onCopyFailed, +}: { + value: string; + ariaLabel: string; + onCopyFailed?: () => void; +}): ReactElement { const [copied, setCopied] = useState(false); const timerRef = useRef | undefined>(undefined); useEffect(() => { @@ -29,19 +42,26 @@ function CopyableMono({ value, ariaLabel }: { value: string; ariaLabel: string } }; }, []); - const handleCopy = useCallback(() => { - if (typeof navigator !== 'undefined' && navigator.clipboard) { - void navigator.clipboard.writeText(value); + const handleCopy = useCallback(async () => { + if (typeof navigator === 'undefined' || !navigator.clipboard) { + onCopyFailed?.(); + return; + } + try { + await navigator.clipboard.writeText(value); + } catch { + onCopyFailed?.(); + return; } setCopied(true); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); - }, [value]); + }, [value, onCopyFailed]); return (