Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 143 additions & 7 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"dependencies": {
"@clickhouse/click-ui": "0.9.1",
"@librechat/data-schemas": "^0.0.56",
"@librechat/data-schemas": "^0.0.74",
"@radix-ui/react-dialog": "1.1.15",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-devtools": "0.10.0",
Expand All @@ -43,7 +43,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
"js-yaml": "^4.2.0",
"librechat-data-provider": "^0.8.509",
"librechat-data-provider": "^0.8.524",
"lucide-react": "^0.545.0",
"prom-client": "^15.1.3",
"react": "^19.2.7",
Expand Down
1 change: 1 addition & 0 deletions src/components/access/RolePermissionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const PERMISSION_TYPE_ORDER: PermissionTypes[] = [
PermissionTypes.REMOTE_AGENTS,
PermissionTypes.SKILLS,
PermissionTypes.SHARED_LINKS,
PermissionTypes.SCHEDULES,
PermissionTypes.BOOKMARKS,
PermissionTypes.MULTI_CONVO,
PermissionTypes.TEMPORARY_CHAT,
Expand Down
15 changes: 15 additions & 0 deletions src/components/configuration/configMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export const SECTION_META: Record<
descriptionKey: 'com_config_section_messageFilter_desc',
tab: 'features',
},
filters: {
titleKey: 'com_config_section_filters',
descriptionKey: 'com_config_section_filters_desc',
tab: 'features',
},
langfuse: {
titleKey: 'com_config_section_langfuse',
descriptionKey: 'com_config_section_langfuse_desc',
Expand Down Expand Up @@ -189,6 +194,16 @@ export const SECTION_META: Record<
descriptionKey: 'com_config_section_skillSync_desc',
tab: 'system',
},
permissions: {
titleKey: 'com_config_section_permissions',
descriptionKey: 'com_config_section_permissions_desc',
tab: 'system',
},
openapi: {
titleKey: 'com_config_section_openapi',
descriptionKey: 'com_config_section_openapi_desc',
tab: 'system',
},
};

/** Sections omitted from the UI entirely (legacy fields pending removal from the schema). */
Expand Down
42 changes: 27 additions & 15 deletions src/components/grants/AuditLogDetailDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import * as Dialog from '@radix-ui/react-dialog';
import { PrincipalType } from 'librechat-data-provider';
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,
auditCapability,
ACTION_SUMMARY_KEY,
auditGrantee,
auditSubject,
auditTargetConfig,
capabilityLabel,
formatTimestamp,
} from './auditLogUtils';
import { LoadingState } from '@/components/shared';
import { getScopeTypeConfig } from '@/constants';
import { useLocalize } from '@/hooks';
import { cn } from '@/utils';

Expand Down Expand Up @@ -360,13 +361,10 @@ export function AuditLogDetailDrawer({

if (!latestEntry) return null;

const targetConfig = getScopeTypeConfig(latestEntry.target.type as PrincipalType);
const capability = auditCapability(latestEntry);
const targetConfig = auditTargetConfig(latestEntry.target.type);
const subject = auditSubject(latestEntry, localize);
const grantee = auditGrantee(latestEntry);
const targetLabel = latestEntry.target.name ?? latestEntry.target.id ?? '';
const summaryKey =
latestEntry.action === 'grant.assigned'
? 'com_audit_detail_summary_assigned'
: 'com_audit_detail_summary_removed';

const before = latestEntry.before ?? [];
const after = latestEntry.after ?? [];
Expand Down Expand Up @@ -420,10 +418,11 @@ export function AuditLogDetailDrawer({
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-5 px-4 py-4">
<p className="text-sm text-(--cui-color-text-default)">
{localize(summaryKey, {
{localize(ACTION_SUMMARY_KEY[latestEntry.action], {
actor: latestEntry.actor.name,
capability: capabilityLabel(capability, localize),
capability: subject.label,
target: targetLabel,
grantee: grantee ? `${grantee.type} ${grantee.id}` : '',
})}
</p>

Expand Down Expand Up @@ -479,13 +478,26 @@ export function AuditLogDetailDrawer({
</div>
</DetailRow>

{grantee && (
<DetailRow label={localize('com_audit_detail_grantee')}>
<div className="flex flex-col gap-0.5">
<span className="text-sm text-(--cui-color-text-default)">
{grantee.type}
</span>
<CopyableMono
value={grantee.id}
ariaLabel={`Copy ${localize('com_audit_detail_grantee')} ID`}
onCopyFailed={onCopyFailed}
/>
</div>
</DetailRow>
)}

<DetailRow label={localize('com_audit_detail_capability')}>
<div className="flex flex-col gap-0.5">
<span className="text-sm text-(--cui-color-text-default)">
{capabilityLabel(capability, localize)}
</span>
<span className="text-sm text-(--cui-color-text-default)">{subject.label}</span>
<CopyableMono
value={capability}
value={subject.value}
ariaLabel={`Copy ${localize('com_audit_detail_capability')}`}
onCopyFailed={onCopyFailed}
/>
Expand Down
24 changes: 12 additions & 12 deletions src/components/grants/AuditLogTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PrincipalType } from 'librechat-data-provider';
import { useNavigate, useSearch } from '@tanstack/react-router';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { PrincipalType, ResourceType } from 'librechat-data-provider';
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';
Expand All @@ -9,9 +9,9 @@ import type * as t from '@/types';
import {
ACTION_BADGE_STATE,
ACTION_LABEL_KEY,
auditCapability,
auditSubject,
auditTargetConfig,
buildEntryPermalink,
capabilityLabel,
dateToIsoDate,
formatTimestamp,
isoDateToDate,
Expand All @@ -31,15 +31,15 @@ import {
SearchInput,
} from '@/components/shared';
import { useAnnouncement, useDebouncedFilter, useLocalize } from '@/hooks';
import { getScopeTypeConfig, isAuditEntryId } from '@/constants';
import { AuditLogDetailDrawer } from './AuditLogDetailDrawer';
import { AUDIT_ACTIONS, isAuditEntryId } from '@/constants';
import { cn } from '@/utils';

const AUDIT_ACTIONS: readonly AuditAction[] = ['grant.assigned', 'grant.removed'] as const;
const TARGET_TYPE_OPTIONS: readonly PrincipalType[] = [
const TARGET_TYPE_OPTIONS: readonly t.AuditTargetType[] = [
PrincipalType.USER,
PrincipalType.GROUP,
PrincipalType.ROLE,
ResourceType.AGENT,
] 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. */
Expand Down Expand Up @@ -109,7 +109,7 @@ export function AuditLogTab() {
const [dateTo, setDateTo] = useState('');
/** Bumped each clear so DatePicker remounts and drops its internal selection state. */
const [dateResetNonce, setDateResetNonce] = useState(0);
const [targetTypeFilter, setTargetTypeFilter] = useState<PrincipalType | ''>('');
const [targetTypeFilter, setTargetTypeFilter] = useState<t.AuditTargetType | ''>('');

const [currentPage, setCurrentPage] = useState(1);
const { message: announcement, announce } = useAnnouncement();
Expand Down Expand Up @@ -502,7 +502,7 @@ export function AuditLogTab() {
label={localize('com_audit_filter_target_type')}
value={targetTypeFilter === '' ? TARGET_TYPE_ALL : targetTypeFilter}
onSelect={(v) => {
setTargetTypeFilter(v === TARGET_TYPE_ALL ? '' : (v as PrincipalType));
setTargetTypeFilter(v === TARGET_TYPE_ALL ? '' : (v as t.AuditTargetType));
resetToFirstPage();
}}
placeholder={localize('com_ui_all')}
Expand Down Expand Up @@ -663,8 +663,8 @@ function AuditLogTableRow({
onKeyDown: (e: React.KeyboardEvent<HTMLTableRowElement>) => void;
localize: ReturnType<typeof useLocalize>;
}) {
const targetConfig = getScopeTypeConfig(entry.target.type as PrincipalType);
const capability = auditCapability(entry);
const targetConfig = auditTargetConfig(entry.target.type);
const subject = auditSubject(entry, localize);
return (
<tr
role="button"
Expand Down Expand Up @@ -704,10 +704,10 @@ function AuditLogTableRow({
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="text-(--cui-color-text-default)">
{capabilityLabel(capability, localize)}
{subject.label}
</span>
<span aria-hidden="true" className="text-[10px] text-(--cui-color-text-muted)">
{capability}
{subject.value}
</span>
</div>
</td>
Expand Down
61 changes: 61 additions & 0 deletions src/components/grants/auditLogUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,82 @@
import { describe, it, expect } from 'vitest';
import {
ACTION_BADGE_STATE,
ACTION_LABEL_KEY,
ACTION_SUMMARY_KEY,
auditCapability,
auditGrantee,
auditSubject,
auditTargetConfig,
buildEntryPermalink,
capabilityLabel,
dateToIsoDate,
formatTimestamp,
isoDateToDate,
localDayBoundaryIso,
} from './auditLogUtils';
import translation from '@/locales/en/translation.json';
import { AUDIT_ACTIONS } from '@/constants';

const identityLocalize = (k: string) => k;

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');
expect(ACTION_BADGE_STATE['permission.insights_assigned']).toBe('success');
expect(ACTION_BADGE_STATE['permission.insights_removed']).toBe('danger');
});

it('has a localized label and summary for every audit action', () => {
const localeKeys = new Set(Object.keys(translation));
for (const action of AUDIT_ACTIONS) {
expect(localeKeys.has(ACTION_LABEL_KEY[action])).toBe(true);
expect(localeKeys.has(ACTION_SUMMARY_KEY[action])).toBe(true);
}
});
});

describe('auditSubject', () => {
it('uses the capability for grant entries', () => {
expect(
auditSubject(
{ action: 'grant.assigned', metadata: { capability: 'manage:users' } },
identityLocalize,
),
).toEqual({ label: 'manage:users', value: 'manage:users' });
});

it('reports the VIEW_INSIGHTS permission for Insights entries', () => {
expect(
auditSubject(
{ action: 'permission.insights_removed', metadata: { principalType: 'user' } },
identityLocalize,
),
).toEqual({ label: 'com_audit_insights_permission', value: 'VIEW_INSIGHTS' });
});
});

describe('auditGrantee', () => {
it('reads the affected principal from metadata', () => {
expect(auditGrantee({ metadata: { principalType: 'group', principalId: 'g1' } })).toEqual({
type: 'group',
id: 'g1',
});
});

it('returns undefined for grant entries without principal metadata', () => {
expect(auditGrantee({ metadata: { capability: 'manage:users' } })).toBeUndefined();
expect(auditGrantee({ metadata: undefined })).toBeUndefined();
});
});

describe('auditTargetConfig', () => {
it('labels agent targets as agents', () => {
expect(auditTargetConfig('agent').labelKey).toBe('com_audit_target_agent');
});

it('uses the scope config for principal targets', () => {
expect(auditTargetConfig('role').labelKey).toBe('com_scope_roles');
});
});

Expand Down
56 changes: 56 additions & 0 deletions src/components/grants/auditLogUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ResourceType } from 'librechat-data-provider';
import type { AdminAuditLogEntry, AuditAction } from '@librechat/data-schemas';
import type { PrincipalType } from 'librechat-data-provider';
import type * as t from '@/types';
import { getScopeTypeConfig } from '@/constants';

/** The capability a grant entry concerns now lives in `metadata.capability`
* (other event categories omit it). Returns '' when absent or non-string. */
Expand All @@ -10,13 +14,65 @@ export function auditCapability(entry: Pick<AdminAuditLogEntry, 'metadata'>): st
export const ACTION_BADGE_STATE: Record<AuditAction, 'success' | 'danger'> = {
'grant.assigned': 'success',
'grant.removed': 'danger',
'permission.insights_assigned': 'success',
'permission.insights_removed': 'danger',
};

export const ACTION_LABEL_KEY: Record<AuditAction, string> = {
'grant.assigned': 'com_audit_action_assigned',
'grant.removed': 'com_audit_action_removed',
'permission.insights_assigned': 'com_audit_action_insights_assigned',
'permission.insights_removed': 'com_audit_action_insights_removed',
};

export const ACTION_SUMMARY_KEY: Record<AuditAction, string> = {
'grant.assigned': 'com_audit_detail_summary_assigned',
'grant.removed': 'com_audit_detail_summary_removed',
'permission.insights_assigned': 'com_audit_detail_summary_insights_assigned',
'permission.insights_removed': 'com_audit_detail_summary_insights_removed',
};

const INSIGHTS_PERMISSION = 'VIEW_INSIGHTS';

function isInsightsAction(action: AuditAction): boolean {
return action === 'permission.insights_assigned' || action === 'permission.insights_removed';
}

/** Capability grants carry `metadata.capability`; Insights entries change the
* agent-level `VIEW_INSIGHTS` permission bit instead. */
export function auditSubject(
entry: Pick<AdminAuditLogEntry, 'action' | 'metadata'>,
localize: (key: string) => string,
): t.AuditSubject {
if (isInsightsAction(entry.action)) {
return { label: localize('com_audit_insights_permission'), value: INSIGHTS_PERMISSION };
}
const capability = auditCapability(entry);
return { label: capabilityLabel(capability, localize), value: capability };
}

/** Insights entries target the agent; the affected principal lives in metadata. */
export function auditGrantee(
entry: Pick<AdminAuditLogEntry, 'metadata'>,
): t.AuditGrantee | undefined {
const type = entry.metadata?.principalType;
const id = entry.metadata?.principalId;
if (typeof type !== 'string' || typeof id !== 'string') return undefined;
return { type, id };
}

const AGENT_TARGET_CONFIG: Pick<t.ScopeTypeConfigEntry, 'icon' | 'labelKey'> = {
icon: 'sparkle',
labelKey: 'com_audit_target_agent',
};

export function auditTargetConfig(
targetType: string,
): Pick<t.ScopeTypeConfigEntry, 'icon' | 'labelKey'> {
if (targetType === ResourceType.AGENT) return AGENT_TARGET_CONFIG;
return getScopeTypeConfig(targetType as PrincipalType);
}

/** 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
Expand Down
1 change: 1 addition & 0 deletions src/components/grants/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
ACTION_BADGE_STATE,
ACTION_LABEL_KEY,
ACTION_SUMMARY_KEY,
capabilityLabel,
formatTimestamp,
} from './auditLogUtils';
Expand Down
2 changes: 2 additions & 0 deletions src/constants/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
export const AUDIT_ACTIONS = [
'grant.assigned',
'grant.removed',
'permission.insights_assigned',
'permission.insights_removed',
] as const satisfies readonly AuditAction[];

export const AUDIT_CATEGORIES = [
Expand Down
Loading
Loading