diff --git a/package.json b/package.json index b28deed..1fc4993 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@clickhouse/click-ui": "0.2.0-rc.4", - "@librechat/data-schemas": "^0.0.48", + "@librechat/data-schemas": "^0.0.52", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "0.10.0", "@tanstack/react-query": "5.95.2", diff --git a/server.ts b/server.ts index b93e574..ee243dc 100644 --- a/server.ts +++ b/server.ts @@ -32,6 +32,43 @@ function getCacheHeaders(filePath: string): Record { return {}; } +// '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'", + "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('; '); + +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(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'); + 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; @@ -41,8 +78,11 @@ async function buildStaticRoutes(): Promise Response>> { for await (const path of new Glob('**/*').scan(CLIENT_DIR)) { const file = Bun.file(`${CLIENT_DIR}/${path}`); const cache = getCacheHeaders(path); - routes[`/${path}`] = () => - new Response(file, { headers: { 'Content-Type': file.type, ...cache } }); + routes[`/${path}`] = () => { + const res = new Response(file, { headers: { 'Content-Type': file.type, ...cache } }); + applySecurityHeaders(res.headers); + return res; + }; } return routes; } @@ -63,6 +103,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..190e23c --- /dev/null +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -0,0 +1,384 @@ +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 { getScopeTypeConfig } from '@/constants'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +interface AuditLogDetailDrawerProps { + entry: t.AuditLogEntryWithDiff | null; + open: boolean; + onClose: () => 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, + onCopyFailed, +}: { + value: string; + ariaLabel: string; + onCopyFailed?: () => void; +}): ReactElement { + const [copied, setCopied] = useState(false); + const timerRef = useRef | undefined>(undefined); + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + 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, onCopyFailed]); + + 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, + notFound = false, +}: AuditLogDetailDrawerProps): ReactElement | null { + const localize = useLocalize(); + + // 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(async () => { + if (!entry) return; + const ok = await onCopyPermalink(entry.id); + if (!ok) return; + setCopied(true); + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = setTimeout(() => setCopied(false), 1500); + }, [entry, onCopyPermalink]); + + if (notFound) { + return ( + { + if (!isOpen) 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')} + + +
+
+

+ {localize('com_audit_detail_not_found')} +

+
+
+
+
+
+
+ ); + } + + if (!latestEntry) return null; + + const targetConfig = getScopeTypeConfig(latestEntry.targetPrincipalType); + const summaryKey = + latestEntry.action === 'grant_assigned' + ? 'com_audit_detail_summary_assigned' + : 'com_audit_detail_summary_removed'; + + const before = latestEntry.before ?? []; + const after = latestEntry.after ?? []; + const hasDiff = before.length > 0 || after.length > 0; + + return ( + { + if (!isOpen) 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')} + +
+ +
+ +
+
+

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

+ +
+ +
+ + {formatTimestamp(latestEntry.timestamp)} + + +
+
+ + +
+ + {latestEntry.actorName} + + +
+
+ + +
+ + + + {localize(targetConfig.labelKey)} + + } + /> + + {latestEntry.targetName} + + + +
+
+ + +
+ + {capabilityLabel(latestEntry.capability, localize)} + + +
+
+ + + + +
+ + {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/AuditLogRow.tsx b/src/components/grants/AuditLogRow.tsx deleted file mode 100644 index 3b1b2e0..0000000 --- a/src/components/grants/AuditLogRow.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Icon } from '@clickhouse/click-ui'; -import type * as t from '@/types'; -import { capabilityLabel, formatTimestamp } from './auditLogUtils'; -import { getScopeTypeConfig } from '@/constants'; -import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; - -export function AuditLogRow({ entry, isLast }: t.AuditLogRowProps) { - const localize = useLocalize(); - const targetConfig = getScopeTypeConfig(entry.targetPrincipalType); - - return ( - - - - {entry.action === 'grant_assigned' - ? localize('com_audit_action_assigned') - : localize('com_audit_action_removed')} - - - - - - - {localize(targetConfig.labelKey)} - - {entry.targetName} - - - -
- - {capabilityLabel(entry.capability, localize)} - - {entry.capability} -
- - {entry.actorName} - - {formatTimestamp(entry.timestamp)} - - - ); -} diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index b1884eb..efa2a45 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -1,63 +1,252 @@ -import { Icon } from '@clickhouse/click-ui'; -import { useQuery } from '@tanstack/react-query'; -import { useState, useMemo, useCallback } from 'react'; +import { PrincipalType } from 'librechat-data-provider'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Badge, Button, DatePicker, Icon, Select, TextField } from '@clickhouse/click-ui'; +import type { AuditAction } from '@librechat/data-schemas'; +import type { AuditFilters } from '@/server'; 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 { AuditLogRow } from './AuditLogRow'; -import { useLocalize } from '@/hooks'; +import { + ACTION_BADGE_STATE, + ACTION_LABEL_KEY, + capabilityLabel, + dateToIsoDate, + formatTimestamp, + isoDateToDate, + localDayBoundaryIso, +} from './auditLogUtils'; +import { + AUDIT_LOG_PAGE_SIZE, + auditLogEntryQueryOptions, + auditLogQueryOptions, + exportAuditLogServerFn, +} from '@/server'; +import { + EmptyState, + LoadingState, + Pagination, + ScreenReaderAnnouncer, + SearchInput, +} from '@/components/shared'; +import { useAnnouncement, useDebouncedFilter, useLocalize } from '@/hooks'; +import { AuditLogDetailDrawer } from './AuditLogDetailDrawer'; +import { getScopeTypeConfig } from '@/constants'; import { cn } from '@/utils'; +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; +/** Radix `Select.Item` cannot use `value=""` (Radix reserves empty string for + * "no selection"). Use a non-empty sentinel and translate to `''` in state. */ +const TARGET_TYPE_ALL = '__all__'; + +/** + * Wraps a click-ui DatePicker so only the trigger button is tab-focusable. + * click-ui renders both a PopoverTrigger button AND an inner readonly input, + * which produces two stops in the tab order. The input has no exposed `tabIndex` + * prop, so we reach for the DOM node once after mount and set it to -1. The + * class hooks the CSS rule that rounds the trigger's focus outline to match + * the wrapper border. + */ +function DatePickerCell({ children }: { children: React.ReactNode }) { + const ref = useRef(null); + useEffect(() => { + const node = ref.current; + if (!node) return; + const input = node.querySelector('input'); + if (input) input.tabIndex = -1; + }, []); + return ( +
+ {children} +
+ ); +} + +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 [search, setSearch] = useState(''); - const [actionFilter, setActionFilter] = useState('all'); + const navigate = useNavigate({ from: '/grants' }); + const { entryId } = useSearch({ from: '/_app/grants' }); + + const [actionFilter, setActionFilter] = useState([]); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); - const [exporting, setExporting] = useState(false); - const filters = useMemo( - () => ({ - search: search || undefined, - action: actionFilter !== 'all' ? actionFilter : undefined, - from: dateFrom || undefined, - to: dateTo || undefined, - }), - [search, actionFilter, dateFrom, dateTo], + /** Bumped each clear so DatePicker remounts and drops its internal selection state. */ + const [dateResetNonce, setDateResetNonce] = useState(0); + const [targetTypeFilter, setTargetTypeFilter] = useState(''); + + const [currentPage, setCurrentPage] = useState(1); + const { message: announcement, announce } = useAnnouncement(); + + const resetToFirstPage = useCallback(() => setCurrentPage(1), []); + const searchFilter = useDebouncedFilter('', resetToFirstPage); + const actorIdFilter = useDebouncedFilter('', resetToFirstPage); + const targetIdFilter = useDebouncedFilter('', resetToFirstPage); + const capabilityFilter = useDebouncedFilter('', resetToFirstPage); + + const filters = useMemo>(() => { + const trimmed = searchFilter.debouncedValue.trim(); + return { + search: trimmed ? trimmed : undefined, + action: actionFilter.length ? actionFilter : undefined, + from: localDayBoundaryIso(dateFrom, 'start'), + to: localDayBoundaryIso(dateTo, 'end'), + actorId: actorIdFilter.debouncedValue || undefined, + targetPrincipalId: targetIdFilter.debouncedValue || undefined, + targetPrincipalType: targetTypeFilter ? targetTypeFilter : undefined, + capability: capabilityFilter.debouncedValue || undefined, + }; + }, [ + searchFilter.debouncedValue, + actionFilter, + dateFrom, + dateTo, + actorIdFilter.debouncedValue, + targetIdFilter.debouncedValue, + capabilityFilter.debouncedValue, + targetTypeFilter, + ]); + + const { data, isPending, isFetching, isError } = useQuery({ + ...auditLogQueryOptions(currentPage, filters), + placeholderData: keepPreviousData, + }); + + const pageEntries = useMemo(() => data?.entries ?? [], [data]); + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / AUDIT_LOG_PAGE_SIZE)); + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [currentPage, totalPages]); + + // Reset to page 1 whenever non-debounced filters change. Debounced filter + // handlers (search, actor id, target id, capability) reset inline within + // the same setTimeout so the page change lands with the new query key. + useEffect(() => { + setCurrentPage(1); + }, [actionFilter, dateFrom, dateTo, targetTypeFilter]); + + useEffect(() => { + if (isFetching) return; + announce(localize('com_a11y_audit_filter_changed', { count: pageEntries.length })); + }, [ + searchFilter.debouncedValue, + actionFilter, + dateFrom, + dateTo, + actorIdFilter.debouncedValue, + targetIdFilter.debouncedValue, + capabilityFilter.debouncedValue, + targetTypeFilter, + isFetching, + pageEntries.length, + announce, + localize, + ]); + + const entryOnPage = useMemo( + () => (entryId ? (pageEntries.find((e) => e.id === entryId) ?? null) : null), + [pageEntries, entryId], ); - const { data: entries = [], isLoading } = useQuery(auditLogQueryOptions(filters)); + // Fall back to a direct single-entry fetch when the deep-linked id isn't on + // the current page (older entries, or arriving via permalink with no filters + // loaded yet). Skip the round-trip whenever the row is already in `pageEntries`. + const entryFetch = useQuery({ + ...auditLogEntryQueryOptions(entryId), + enabled: !!entryId && !entryOnPage, + }); - const handleSearchChange = useCallback((value: string) => { - setSearch(value); - }, []); + const selectedEntry: t.AuditLogEntryWithDiff | null = + entryOnPage ?? entryFetch.data?.entry ?? null; + const entryNotFound = + !!entryId && !entryOnPage && entryFetch.isSuccess && entryFetch.data?.entry === null; - const handleActionFilter = useCallback((filter: t.ActionFilter) => { - setActionFilter(filter); - }, []); + 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( + async (id: string): Promise => { + if (typeof window === 'undefined') return false; + if (typeof navigator === 'undefined' || !navigator.clipboard) { + announce(localize('com_a11y_copy_failed')); + return false; + } + const url = `${window.location.origin}/grants?tab=audit-log&entryId=${encodeURIComponent(id)}`; + try { + await navigator.clipboard.writeText(url); + return true; + } catch { + announce(localize('com_a11y_copy_failed')); + return false; + } + }, + [announce, localize], + ); + + const handleRowKeyDown = useCallback( + (e: React.KeyboardEvent, id: string) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openEntry(id); + } + }, + [openEntry], + ); + + const [exporting, setExporting] = useState(false); + + // Always export via the backend: the client only holds the current page (≤50 + // rows) of a filter that may match thousands. The previous two-path approach + // silently truncated CSVs whenever the result set fell between the page size + // and the old `CLIENT_EXPORT_THRESHOLD`. 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); + const { csv } = await exportAuditLogServerFn({ data: filters }); + downloadCsv(csv); } finally { setExporting(false); } }, [filters]); - if (isLoading) { - return ; - } + const showLoading = isPending && !data; + const exportLabel = localize('com_audit_export_server'); return ( -
+
-
- {(['all', 'grant_assigned', 'grant_removed'] as t.ActionFilter[]).map((filter) => ( - - ))} +
+ {AUDIT_ACTIONS.map((act) => { + const selected = actionFilter.includes(act); + return ( +
-
-
+ +
+ + +
+ +
+
@@ -164,10 +406,33 @@ export function AuditLogTab() { - {entries.map((entry, i) => ( - - ))} - {entries.length === 0 && ( + {showLoading && ( + + + + + + )} + {!showLoading && isError && ( + + + + + + )} + {!showLoading && + !isError && + pageEntries.map((entry, i) => ( + openEntry(entry.id)} + onKeyDown={(e) => handleRowKeyDown(e, entry.id)} + localize={localize} + /> + ))} + {!showLoading && !isError && pageEntries.length === 0 && ( @@ -178,11 +443,89 @@ 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: total })} +

+
+ + + +
); } + +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/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..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( @@ -75,6 +69,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/GrantsPage.tsx b/src/components/grants/GrantsPage.tsx index e986106..e3300b4 100644 --- a/src/components/grants/GrantsPage.tsx +++ b/src/components/grants/GrantsPage.tsx @@ -1,33 +1,41 @@ -// import { Tabs } from '@clickhouse/click-ui'; +import { Tabs } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { GrantManagementTab } from './GrantManagementTab'; -// import { AuditLogTab } from './AuditLogTab'; -import { useLocalize } from '@/hooks'; +import { useCapabilities, useLocalize } from '@/hooks'; +import { SystemCapabilities } from '@/constants'; +import { AuditLogTab } from './AuditLogTab'; -export function GrantsPage({ - activeTab: _activeTab, - onTabChange: _onTabChange, -}: t.GrantsPageProps) { +export function GrantsPage({ activeTab, onTabChange }: t.GrantsPageProps) { const localize = useLocalize(); + const { hasCapability } = useCapabilities(); + const canReadAuditLog = hasCapability(SystemCapabilities.READ_AUDIT_LOG); + /** A stale `?tab=audit-log` URL from a previous session shouldn't strand a user + * with revoked audit access on an empty page — silently render management + * until they pick a tab themselves. */ + const resolvedTab: t.GrantsPageProps['activeTab'] = + activeTab === 'audit-log' && !canReadAuditLog ? 'management' : activeTab; return (
- {/* TODO: restore audit-log tab when backend is ready */} - {/* + {localize('com_grants_tab_management')} - {localize('com_grants_tab_audit_log')} + {canReadAuditLog && ( + {localize('com_grants_tab_audit_log')} + )} - - - */} + {canReadAuditLog && } + - +
+ {resolvedTab === 'management' && } + {resolvedTab === 'audit-log' && canReadAuditLog && } +
); } diff --git a/src/components/grants/auditLogUtils.test.ts b/src/components/grants/auditLogUtils.test.ts new file mode 100644 index 0000000..5bd476c --- /dev/null +++ b/src/components/grants/auditLogUtils.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest'; +import { PrincipalType } from 'librechat-data-provider'; +import type { AdminAuditLogEntry } from '@librechat/data-schemas'; +import { + ACTION_BADGE_STATE, + auditLogToCsv, + capabilityLabel, + dateToIsoDate, + formatTimestamp, + isoDateToDate, + localDayBoundaryIso, +} from './auditLogUtils'; + +const UTF8_BOM = ''; + +const sampleEntry: AdminAuditLogEntry = { + id: 'a1', + action: 'grant_assigned', + actorId: 'u-1', + actorName: 'Alice Admin', + targetPrincipalType: PrincipalType.USER, + targetPrincipalId: 'u-2', + targetName: 'Bob User', + capability: 'manage:configs', + 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_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'); + expect(out.length).toBeGreaterThan(0); + expect(out).not.toBe('2026-05-10T14:30:00.000Z'); + }); + + 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', () => { + it('returns the localized label when the locale key resolves', () => { + const localize = (key: string) => (key === 'com_cap_manage_configs' ? 'Manage configs' : key); + expect(capabilityLabel('manage:configs', localize)).toBe('Manage configs'); + }); + + it('returns the raw capability when no locale match is found', () => { + expect(capabilityLabel('custom:unknown', identityLocalize)).toBe('custom:unknown'); + }); + + it('converts all colons in the capability to underscores in the lookup key', () => { + let observed = ''; + const localize = (key: string) => { + observed = key; + return key; + }; + capabilityLabel('manage:configs:mcp', localize); + expect(observed).toBe('com_cap_manage_configs_mcp'); + }); +}); + +describe('auditLogToCsv', () => { + it('emits a header row and one row per entry', () => { + 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(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([], identityLocalize)).toBe(UTF8_BOM + expectedHeader + '\r\n'); + }); + + it('quotes and escapes cells containing commas, quotes, or newlines', () => { + const tricky: AdminAuditLogEntry = { + ...sampleEntry, + actorName: 'Alice, "the admin"', + targetName: 'Line1\nLine2', + }; + 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},`); + }); + } + + 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 0231831..64c5ec4 100644 --- a/src/components/grants/auditLogUtils.ts +++ b/src/components/grants/auditLogUtils.ts @@ -1,14 +1,91 @@ -import type * as t from '@/types'; +import type { AdminAuditLogEntry, AuditAction } from '@librechat/data-schemas'; -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 ACTION_BADGE_STATE: Record = { + grant_assigned: 'success', + grant_removed: 'danger', }; -export function formatTimestamp(iso: string): string { +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' }, + { 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 extends never + ? true + : never; +const _csvColumnsExhaustive: _CsvColumnsExhaustive = true; +void _csvColumnsExhaustive; + +// 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(undefined, { + return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric', @@ -25,3 +102,32 @@ export function capabilityLabel(cap: string, localize: (key: string) => string): const label = localize(key); 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 = hasFormulaPrefix(value) ? `'${value}` : value; + if (/[",\n\r]/.test(guarded)) { + return `"${guarded.replace(/"/g, '""')}"`; + } + return guarded; +} + +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.key] ?? ''))).join(','), + ); + 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 3d1c164..ffe61d6 100644 --- a/src/components/grants/index.ts +++ b/src/components/grants/index.ts @@ -1,9 +1,15 @@ -export { ACTION_FILTER_LABELS, capabilityLabel, formatTimestamp } 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'; export { GrantsPage } from './GrantsPage'; 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/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 }; +} 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], + ); } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9719e25..051d3a5 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2,6 +2,10 @@ "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_a11y_copy_failed": "Copy failed. Your browser may have blocked clipboard access.", + "com_audit_detail_copied": "Copied!", "com_ui_remove_item": "Remove {{name}}", "com_ui_save": "Save", "com_ui_add": "Add", @@ -948,6 +952,7 @@ "com_cap_manage_prompts": "Manage prompts", "com_cap_read_assistants": "Read assistants", "com_cap_manage_assistants": "Manage assistants", + "com_cap_read_audit_log": "Read audit log", "com_cap_desc_access_admin": "Full admin panel access", "com_cap_desc_read_users": "View user list and profiles", "com_cap_desc_manage_users": "Create, edit, and delete users", @@ -966,6 +971,7 @@ "com_cap_desc_manage_prompts": "Create, edit, and delete prompts", "com_cap_desc_read_assistants": "View assistant configurations", "com_cap_desc_manage_assistants": "Create, edit, and delete assistants", + "com_cap_desc_read_audit_log": "View and export the audit log of grant changes", "com_access_denied_title": "Access denied", "com_access_denied_description": "You don't have permission to view this page. Contact your administrator.", "com_cap_no_permission": "You need {{cap}} to perform this action", @@ -980,16 +986,45 @@ "com_audit_col_capability": "Capability", "com_audit_action_assigned": "Granted", "com_audit_action_removed": "Revoked", - "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": "{{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_filter_actor_id": "Actor", + "com_audit_filter_target_id": "Target", + "com_audit_filter_target_type": "Target type", + "com_audit_filter_capability": "Capability", + "com_audit_export_server": "Export all matching", + "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", + "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_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_no_changes": "No detailed changes recorded", + "com_audit_detail_copy_permalink": "Copy link", + "com_audit_detail_not_found": "Audit log entry not found. It may have been purged or the link is incorrect.", + "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", "com_nav_expand_sidebar": "Expand sidebar", "com_nav_collapse_sidebar": "Collapse sidebar", 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 dfe9bfd..3fa7083 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -11,7 +11,7 @@ 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'; -import type { AdminAuditLogEntry, AdminSystemGrant } from '@librechat/data-schemas'; +import type { AdminSystemGrant } from '@librechat/data-schemas'; import { apiFetch, extractApiError } from './utils/api'; // ── Helpers ────────────────────────────────────────────────────────── @@ -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. @@ -219,28 +226,136 @@ export const revokeCapabilityFn = createServerFn({ method: 'POST' }) }, ); -// ── Audit Log (stubbed -- no backend endpoint yet) ─────────────────── +// ── 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(), - action: z.enum(['grant_assigned', 'grant_removed']).optional(), - from: z.string().optional(), - to: z.string().optional(), + search: z.string().max(200).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(), + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(500).optional(), }); -type AuditFilters = z.infer; +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(), + before: z.array(z.string()).optional(), + after: z.array(z.string()).optional(), +}); + +const auditLogPageResponseSchema = z.object({ + entries: z.array(adminAuditLogEntrySchema), + total: z.number().int().min(0), +}); + +export type AuditLogPage = z.infer; + +export const AUDIT_LOG_PAGE_SIZE = 50; + +/** + * The LibreChat backend is the source of truth for audit-log access: the + * `/api/admin/audit-log` route enforces `ACCESS_ADMIN`, and any future + * tightening (e.g. a dedicated `READ_AUDIT_LOG` capability) belongs there. A + * BFF-layer guard duplicating that check costs a `/effective` round-trip per + * page request without buying real protection — the backend rejects the same + * requests we would. + */ +function buildAuditLogQuery(filters: AuditFilters): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + 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 getAuditLogFn = createServerFn({ method: 'GET' }) +export const getAuditLogPageFn = createServerFn({ method: 'GET' }) .inputValidator(auditFilterSchema) - .handler(async (): Promise<{ entries: AdminAuditLogEntry[] }> => ({ entries: [] })); + .handler(async ({ data }: { data: AuditFilters }): Promise => { + 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'); + } + return auditLogPageResponseSchema.parse(await response.json()); + }); -export const auditLogQueryOptions = (filters: AuditFilters = {}) => - queryOptions({ - queryKey: ['auditLog', filters], - queryFn: () => getAuditLogFn({ data: filters }).then((r) => r.entries), - staleTime: 30_000, +export const auditLogQueryOptions = ( + page: number, + filters: Omit = {}, +) => + 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, }); -export const exportAuditLogCsvFn = createServerFn({ method: 'POST' }) +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 }> => { + 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 () => ({ csv: '' })); + .handler(async ({ data }: { data: AuditFilters }): Promise<{ csv: string }> => { + 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..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,13 +970,3 @@ body > div:has(+ .modal-frost) { background-color: var(--cui-color-feedback-user-bg); 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..be43f91 100644 --- a/src/types/grant.ts +++ b/src/types/grant.ts @@ -1,7 +1,12 @@ -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'; +export interface AuditLogEntryWithDiff extends AdminAuditLogEntry { + before?: readonly string[]; + after?: readonly string[]; +} + export interface PrincipalRow { principalType: PrincipalType; principalId: string; @@ -10,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; 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 {
{localize('com_grants_title')}
- - {row.isActive ? localize('com_ui_active') : localize('com_ui_paused')} - +