🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)

WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features

🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
This commit is contained in:
Danny Avila 2025-06-23 10:22:27 -04:00
parent aa42759ffd
commit 66bd419baa
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
147 changed files with 17564 additions and 645 deletions

View file

@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import useAgentCategories from '../useAgentCategories';
import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// Mock the useLocalize hook
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: () => (key: string) => {
// Simple mock implementation that returns the key as the translation
return key === 'com_ui_agent_category_general' ? 'General (Translated)' : key;
},
}));
describe('useAgentCategories', () => {
it('should return processed categories with correct structure', () => {
const { result } = renderHook(() => useAgentCategories());
// Check that we have the expected number of categories
expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length);
// Check that the first category has the expected structure
const firstCategory = result.current.categories[0];
const firstOriginalCategory = AGENT_CATEGORIES[0];
expect(firstCategory.value).toBe(firstOriginalCategory.value);
// Check that labels are properly translated
expect(firstCategory.label).toBe('General (Translated)');
expect(firstCategory.className).toBe('w-full');
// Check the empty category
expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value);
expect(result.current.emptyCategory.label).toBeTruthy();
});
});

View file

@ -0,0 +1,360 @@
import { renderHook } from '@testing-library/react';
import { useDynamicAgentQuery } from '../useDynamicAgentQuery';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
// Mock the data provider queries
jest.mock('~/data-provider', () => ({
useGetPromotedAgentsQuery: jest.fn(),
useGetAgentsByCategoryQuery: jest.fn(),
useSearchAgentsQuery: jest.fn(),
}));
const mockUseGetPromotedAgentsQuery = useGetPromotedAgentsQuery as jest.MockedFunction<
typeof useGetPromotedAgentsQuery
>;
const mockUseGetAgentsByCategoryQuery = useGetAgentsByCategoryQuery as jest.MockedFunction<
typeof useGetAgentsByCategoryQuery
>;
const mockUseSearchAgentsQuery = useSearchAgentsQuery as jest.MockedFunction<
typeof useSearchAgentsQuery
>;
describe('useDynamicAgentQuery', () => {
const defaultMockQueryResult = {
data: undefined,
isLoading: false,
error: null,
isFetching: false,
refetch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Set default mock returns
mockUseGetPromotedAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseGetAgentsByCategoryQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseSearchAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
});
describe('Search Query Type', () => {
it('should use search query when searchQuery is provided', () => {
const mockSearchResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseSearchAgentsQuery.mockReturnValue(mockSearchResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
// Should call search query with correct parameters
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
category: 'hr',
page: 1,
limit: 6,
},
expect.objectContaining({
enabled: true,
staleTime: 120000,
refetchOnWindowFocus: false,
keepPreviousData: true,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 1,
}),
);
// Should return search query result
expect(result.current.data).toBe(mockSearchResult.data);
expect(result.current.queryType).toBe('search');
});
it('should not include category in search when category is "all" or "promoted"', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
page: 1,
limit: 6,
// No category parameter should be included
},
expect.any(Object),
);
});
});
describe('Promoted Query Type', () => {
it('should use promoted query when category is "promoted" and no search', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '',
page: 2,
limit: 8,
}),
);
// Should call promoted query with correct parameters (no showAll)
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 2,
limit: 8,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockPromotedResult.data);
expect(result.current.queryType).toBe('promoted');
});
});
describe('All Agents Query Type', () => {
it('should use promoted query with showAll when category is "all" and no search', () => {
const mockAllResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
// Mock the second call to useGetPromotedAgentsQuery (for "all" category)
mockUseGetPromotedAgentsQuery
.mockReturnValueOnce(defaultMockQueryResult as any) // First call for promoted
.mockReturnValueOnce(mockAllResult as any); // Second call for all
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should call promoted query with showAll parameter
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 1,
limit: 6,
showAll: 'true',
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.queryType).toBe('all');
});
});
describe('Category Query Type', () => {
it('should use category query for specific categories', () => {
const mockCategoryResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockCategoryResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'finance',
searchQuery: '',
page: 3,
limit: 10,
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'finance',
page: 3,
limit: 10,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockCategoryResult.data);
expect(result.current.queryType).toBe('category');
});
});
describe('Query Configuration', () => {
it('should apply correct query configuration to all queries', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
const expectedConfig = expect.objectContaining({
staleTime: 120000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
});
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expectedConfig,
);
});
it('should enable only the correct query based on query type', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Category query should be enabled
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
// Other queries should be disabled
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
});
describe('Default Parameters', () => {
it('should use default page and limit when not provided', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'general',
searchQuery: '',
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'general',
page: 1,
limit: 6,
},
expect.any(Object),
);
});
});
describe('Return Values', () => {
it('should return all necessary query properties', () => {
const mockResult = {
data: { agents: [{ id: '1', name: 'Test Agent' }] },
isLoading: true,
error: null,
isFetching: false,
refetch: jest.fn(),
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'it',
searchQuery: '',
page: 1,
limit: 6,
}),
);
expect(result.current).toEqual({
data: mockResult.data,
isLoading: mockResult.isLoading,
error: mockResult.error,
isFetching: mockResult.isFetching,
refetch: mockResult.refetch,
queryType: 'category',
});
});
});
describe('Edge Cases', () => {
it('should handle empty search query as no search', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '', // Empty string should not trigger search
page: 1,
limit: 6,
}),
);
// Should use promoted query, not search query
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
it('should fallback to promoted query for unknown query types', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [] },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'unknown-category',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should determine this as 'category' type and use category query
expect(result.current.queryType).toBe('category');
});
});
});

View file

@ -1,4 +1,7 @@
export { default as useAgentsMap } from './useAgentsMap';
export { default as useSelectAgent } from './useSelectAgent';
export { default as useAgentCategories } from './useAgentCategories';
export { useDynamicAgentQuery } from './useDynamicAgentQuery';
export type { ProcessedAgentCategory } from './useAgentCategories';
export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig';

View file

@ -0,0 +1,57 @@
import { useMemo } from 'react';
import useLocalize from '~/hooks/useLocalize';
import { useGetAgentCategoriesQuery } from '~/data-provider';
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// This interface matches the structure used by the ControlCombobox component
export interface ProcessedAgentCategory {
label: string; // Translated label
value: string; // Category value
className?: string;
}
/**
* Custom hook that provides processed and translated agent categories from API
*
* @returns Object containing categories, emptyCategory, and loading state
*/
const useAgentCategories = () => {
const localize = useLocalize();
// Fetch categories from API
const categoriesQuery = useGetAgentCategoriesQuery({
staleTime: 1000 * 60 * 15, // 15 minutes
});
const categories = useMemo((): ProcessedAgentCategory[] => {
if (!categoriesQuery.data) return [];
// Filter out special categories (promoted, all) and convert to form format
return categoriesQuery.data
.filter((category) => category.value !== 'promoted' && category.value !== 'all')
.map((category) => ({
label: category.label || category.value,
value: category.value,
className: 'w-full',
}));
}, [categoriesQuery.data]);
const emptyCategory = useMemo(
(): ProcessedAgentCategory => ({
label: localize(EMPTY_AGENT_CATEGORY.label),
value: EMPTY_AGENT_CATEGORY.value,
className: 'w-full',
}),
[localize],
);
return {
categories,
emptyCategory,
isLoading: categoriesQuery.isLoading,
error: categoriesQuery.error,
};
};
export default useAgentCategories;

View file

@ -0,0 +1,112 @@
import { useMemo } from 'react';
import type { UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
interface UseDynamicAgentQueryParams {
category: string;
searchQuery: string;
page?: number;
limit?: number;
}
/**
* Single dynamic query hook that replaces 4 separate conditional queries
* Determines the appropriate query based on category and search state
*/
export const useDynamicAgentQuery = ({
category,
searchQuery,
page = 1,
limit = 6,
}: UseDynamicAgentQueryParams) => {
// Shared query configuration optimized to prevent unnecessary loading states
const queryConfig: UseQueryOptions<t.AgentListResponse> = useMemo(
() => ({
staleTime: 1000 * 60 * 2, // 2 minutes - agents don't change frequently
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
// Removed placeholderData due to TypeScript compatibility - keepPreviousData is sufficient
}),
[],
);
// Determine query type and parameters based on current state
const queryType = useMemo(() => {
if (searchQuery) return 'search';
if (category === 'promoted') return 'promoted';
if (category === 'all') return 'all';
return 'category';
}, [category, searchQuery]);
// Search query - when user is searching
const searchQuery_result = useSearchAgentsQuery(
{
q: searchQuery,
...(category !== 'all' && category !== 'promoted' && { category }),
page,
limit,
},
{
...queryConfig,
enabled: queryType === 'search',
},
);
// Promoted agents query - for "Top Picks" tab
const promotedQuery = useGetPromotedAgentsQuery(
{ page, limit },
{
...queryConfig,
enabled: queryType === 'promoted',
},
);
// All agents query - for "All" tab (promoted endpoint with showAll parameter)
const allAgentsQuery = useGetPromotedAgentsQuery(
{ page, limit, showAll: 'true' },
{
...queryConfig,
enabled: queryType === 'all',
},
);
// Category-specific query - for individual categories
const categoryQuery = useGetAgentsByCategoryQuery(
{ category, page, limit },
{
...queryConfig,
enabled: queryType === 'category',
},
);
// Return the active query based on current state
const activeQuery = useMemo(() => {
switch (queryType) {
case 'search':
return searchQuery_result;
case 'promoted':
return promotedQuery;
case 'all':
return allAgentsQuery;
case 'category':
return categoryQuery;
default:
return promotedQuery; // fallback
}
}, [queryType, searchQuery_result, promotedQuery, allAgentsQuery, categoryQuery]);
return {
...activeQuery,
queryType, // Expose query type for debugging/logging
};
};

View file

@ -125,8 +125,7 @@ export const useEndpoints = ({
if (ep === EModelEndpoint.agents && agents.length > 0) {
result.models = agents.map((agent) => ({
name: agent.id,
isGlobal:
(instanceProjectId != null && agent.projectIds?.includes(instanceProjectId)) ?? false,
isGlobal: agent.isPublic ?? false,
}));
result.agentNames = agents.reduce((acc, agent) => {
acc[agent.id] = agent.name || '';

View file

@ -1,13 +1,13 @@
import { useMemo } from 'react';
import { Blocks, MCPIcon, AttachmentIcon } from '@librechat/client';
import { MessageSquareQuote, ArrowRightToLine, Settings2, Database, Bookmark } from 'lucide-react';
import { Database, Bookmark, Settings2, ArrowRightToLine, MessageSquareQuote } from 'lucide-react';
import {
isAssistantsEndpoint,
isAgentsEndpoint,
Permissions,
EModelEndpoint,
PermissionTypes,
isParamEndpoint,
EModelEndpoint,
Permissions,
isAgentsEndpoint,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TInterfaceConfig, TEndpointsConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';

View file

@ -31,3 +31,4 @@ export { default as useDocumentTitle } from './useDocumentTitle';
export { default as useSpeechToText } from './Input/useSpeechToText';
export { default as useTextToSpeech } from './Input/useTextToSpeech';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
export { useResourcePermissions } from './useResourcePermissions';

View file

@ -0,0 +1,25 @@
import {
useGetEffectivePermissionsQuery,
hasPermissions,
} from 'librechat-data-provider/react-query';
/**
* fetches resource permissions once and returns a function to check any permission
* More efficient when checking multiple permissions for the same resource
* @param resourceType - Type of resource (e.g., 'agent')
* @param resourceId - ID of the resource
* @returns Object with hasPermission function and loading state
*/
export const useResourcePermissions = (resourceType: string, resourceId: string) => {
const { data, isLoading } = useGetEffectivePermissionsQuery(resourceType, resourceId);
const hasPermission = (requiredPermission: number): boolean => {
return data ? hasPermissions(data.permissionBits, requiredPermission) : false;
};
return {
hasPermission,
isLoading,
permissionBits: data?.permissionBits || 0,
};
};