Compare commits

...

24 Commits

Author SHA1 Message Date
Danny Avila
d12122f3db ci: update button accessibility labels in AgentDetail tests 2025-06-27 00:25:07 -04:00
Danny Avila
fddf1196c9 ci: update AclEntry model tests 2025-06-27 00:24:49 -04:00
Danny Avila
90dd0198db chore: remove unused PermissionTypes import from Role model 2025-06-26 23:41:15 -04:00
Danny Avila
7464214f0f ci: update access middleware tests 2025-06-26 23:40:51 -04:00
Danny Avila
451e426e4c chore: update localization for agent detail component and clean up imports 2025-06-26 22:44:15 -04:00
Danny Avila
1b9f155fb8 refactor: remove redundant properties from IAgent interface in agent schema 2025-06-26 22:41:23 -04:00
Danny Avila
e6b9d6b84b refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic
- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.
2025-06-26 22:41:13 -04:00
Atef Bellaaj
4f7fbdf1c5 eslint fix 2025-06-26 19:32:47 -04:00
Atef Bellaaj
c6e1c65fe7 ESLint fixes 2025-06-26 19:32:30 -04:00
Atef Bellaaj
33c4ef03c3 Fix Unit tests WIP 2025-06-26 19:32:06 -04:00
Atef Bellaaj
37c423eb00 Fix linting WIP 2025-06-26 19:31:49 -04:00
Atef Bellaaj
4c40469951 fix: update agent permission check logic in AgentPanel to simplify condition 2025-06-26 19:28:10 -04:00
Atef Bellaaj
770c810650 feat: add validation and error messages for agent name in AgentConfig and AgentPanel 2025-06-26 19:28:10 -04:00
Atef Bellaaj
39e39ca7f5 fix: remove minlength validation for support contact name in agent schema 2025-06-26 19:28:09 -04:00
Atef Bellaaj
94c1f5f518 selected view only agents injected in the drop down 2025-06-26 19:28:09 -04:00
Atef Bellaaj
6a28d01b20 refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency
- Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
  - Add countPromotedAgents function to Agent model for promoted agents count
  - Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
  - Move getAgentCategories from marketplace to v1 controller with same functionality
  - Update agent mutations to invalidate marketplace queries and handle multiple permission levels
  - Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
  - Consolidate agent data access patterns for better maintainability and consistency
  - Remove duplicate marketplace route definitions and middleware
2025-06-26 19:28:09 -04:00
Danny Avila
3f6d7ab7c7 - Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
- Replace manual pagination in AgentGrid with infinite query pattern
  - Update imports to use local data provider instead of librechat-data-provider
  - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
  - Improve agent access control by adding requiredPermission validation in backend
  - Remove manual cursor/state management in favor of infinite query built-ins
  - Maintain existing search and category filtering functionality
2025-06-26 19:28:08 -04:00
Atef Bellaaj
6ebcfdf3e2 feat: add icon property to ProcessedAgentCategory interface 2025-06-26 19:28:08 -04:00
Danny Avila
2eef94d58d refactor: unify agent marketplace to single endpoint with cursor pagination
- Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests
2025-06-26 19:28:07 -04:00
Atef Bellaaj
be7476d530 - Move AgentCategory from api/models to @packages/data-schemas structure
- Add schema, types, methods, and model following codebase conventions
  - Implement auto-seeding of default categories during AppService startup
  - Update marketplace controller to use new data-schemas methods
  - Remove old model file and standalone seed script
2025-06-26 19:28:07 -04:00
Atef Bellaaj
bb149bccc6 refactored and moved agent category methods and schema to data-schema package 2025-06-26 19:28:07 -04:00
“Praneeth
4d753d44e2 bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status 2025-06-26 19:28:06 -04:00
Danny Avila
c6d4629fd1 🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804) 2025-06-26 19:28:04 -04:00
Danny Avila
d471209ced 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
2025-06-26 19:22:39 -04:00
147 changed files with 17512 additions and 845 deletions

View File

@@ -485,6 +485,21 @@ SAML_IMAGE_URL=
# SAML_USE_AUTHN_RESPONSE_SIGNED=
#===============================================#
# Microsoft Graph API / Entra ID Integration #
#===============================================#
# Enable Entra ID people search integration in permissions/sharing system
# When enabled, the people picker will search both local database and Entra ID
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
# When enabled, entra id groups owners will be considered as members of the group
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Microsoft Graph API scopes needed for people/group search
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
# LDAP
LDAP_URL=
LDAP_BIND_DN=

3
.vscode/launch.json vendored
View File

@@ -8,7 +8,8 @@
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/api/server/index.js",
"env": {
"NODE_ENV": "production"
"NODE_ENV": "production",
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
},
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"

View File

@@ -4,7 +4,7 @@ const { logger } = require('@librechat/data-schemas');
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
// Default category value for new agents
const {
getProjectByName,
addAgentIdsToProject,
@@ -12,7 +12,9 @@ const {
removeAgentFromAllProjects,
} = require('./Project');
const { getCachedTools } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
// Category values are now imported from shared constants
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@@ -23,7 +25,7 @@ const { Agent } = require('~/db/models');
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
const { author, ...versionData } = agentData;
const { author: _author, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
@@ -34,6 +36,7 @@ const createAgent = async (agentData) => {
updatedAt: timestamp,
},
],
category: agentData.category || 'general',
};
return (await Agent.create(initialAgentData)).toObject();
};
@@ -126,29 +129,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.author.toString() === req.user.id) {
return agent;
}
if (!agent.projectIds) {
return null;
}
const cache = getLogStores(CONFIG_STORE);
/** @type {TStartupConfig} */
const cachedStartupConfig = await cache.get(STARTUP_CONFIG);
let { instanceProjectId } = cachedStartupConfig ?? {};
if (!instanceProjectId) {
instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString();
}
for (const projectObjectId of agent.projectIds) {
const projectId = projectObjectId.toString();
if (projectId === instanceProjectId) {
return agent;
}
}
return agent;
};
/**
@@ -178,7 +159,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
'actionsHash', // Exclude actionsHash from direct comparison
];
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
return null;
@@ -197,54 +178,116 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
let isMatch = true;
for (const field of importantFields) {
if (!wouldBeVersion[field] && !lastVersion[field]) {
const wouldBeValue = wouldBeVersion[field];
const lastVersionValue = lastVersion[field];
// Skip if both are undefined/null
if (!wouldBeValue && !lastVersionValue) {
continue;
}
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
if (wouldBeVersion[field].length !== lastVersion[field].length) {
// Handle arrays
if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
// Normalize: treat undefined/null as empty array for comparison
let wouldBeArr;
if (Array.isArray(wouldBeValue)) {
wouldBeArr = wouldBeValue;
} else if (wouldBeValue == null) {
wouldBeArr = [];
} else {
wouldBeArr = [wouldBeValue];
}
let lastVersionArr;
if (Array.isArray(lastVersionValue)) {
lastVersionArr = lastVersionValue;
} else if (lastVersionValue == null) {
lastVersionArr = [];
} else {
lastVersionArr = [lastVersionValue];
}
if (wouldBeArr.length !== lastVersionArr.length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects like tool_kwargs
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
// Handle arrays of objects
else if (
wouldBeArr.length > 0 &&
typeof wouldBeArr[0] === 'object' &&
wouldBeArr[0] !== null
) {
const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeVersion[field]].sort();
const sortedVersion = [...lastVersion[field]].sort();
const sortedWouldBe = [...wouldBeArr].sort();
const sortedVersion = [...lastVersionArr].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
} else if (field === 'model_parameters') {
const wouldBeParams = wouldBeVersion[field] || {};
const lastVersionParams = lastVersion[field] || {};
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
}
// Handle objects
else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
const lastVersionObj =
typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
// For empty objects, normalize the comparison
const wouldBeKeys = Object.keys(wouldBeValue);
const lastVersionKeys = Object.keys(lastVersionObj);
// If both are empty objects, they're equal
if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
continue;
}
// Otherwise do a deep comparison
if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
isMatch = false;
break;
}
}
// Handle primitive values
else {
// For primitives, handle the case where one is undefined and the other is a default value
if (wouldBeValue !== lastVersionValue) {
// Special handling for boolean false vs undefined
if (
typeof wouldBeValue === 'boolean' &&
wouldBeValue === false &&
lastVersionValue === undefined
) {
continue;
}
// Special handling for empty string vs undefined
if (
typeof wouldBeValue === 'string' &&
wouldBeValue === '' &&
lastVersionValue === undefined
) {
continue;
}
isMatch = false;
break;
}
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
}
}
@@ -273,7 +316,14 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
const {
__v,
_id,
id: __id,
versions,
author: _author,
...versionData
} = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
let actionsHash = null;
@@ -464,8 +514,113 @@ const deleteAgent = async (searchParameter) => {
return agent;
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
*/
const getListAgentsByAccess = async ({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) => {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible agents with other filters
const baseQuery = { ...otherParams };
if (accessibleIds.length > 0) {
baseQuery._id = { $in: accessibleIds };
}
// Add cursor condition
if (after) {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
let query = Agent.find(baseQuery, {
id: 1,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested
if (isPaginated) {
query = query.limit(normalizedLimit + 1);
}
const agents = await query.lean();
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
if (agent.author) {
agent.author = agent.author.toString();
}
return agent;
});
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastAgent = agents[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastAgent.updatedAt.toISOString(),
_id: lastAgent._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0].id : null,
last_id: data.length > 0 ? data[data.length - 1].id : null,
has_more: hasMore,
after: nextCursor,
};
};
/**
* Get all agents.
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
* @param {Object} searchParameter - The search parameters to find matching agents.
* @param {string} searchParameter.author - The user ID of the agent's author.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
@@ -484,13 +639,15 @@ const getListAgents = async (searchParameter) => {
const agents = (
await Agent.find(query, {
id: 1,
_id: 0,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: 1,
category: 1,
}).lean()
).map((agent) => {
if (agent.author?.toString() !== author) {
@@ -656,6 +813,14 @@ const generateActionMetadataHash = async (actionIds, actions) => {
return hashHex;
};
/**
* Counts the number of promoted agents.
* @returns {Promise<number>} - The count of promoted agents
*/
const countPromotedAgents = async () => {
const count = await Agent.countDocuments({ is_promoted: true });
return count;
};
/**
* Load a default agent based on the endpoint
@@ -673,6 +838,8 @@ module.exports = {
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
countPromotedAgents,
};

View File

@@ -1633,7 +1633,7 @@ describe('models/Agent', () => {
expect(result.version).toBe(1);
});
test('should return null when user is not author and agent has no projectIds', async () => {
test('should return agent even when user is not author (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1654,7 +1654,11 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeFalsy();
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Test Agent');
});
test('should handle ephemeral agent with no MCP servers', async () => {
@@ -1762,7 +1766,7 @@ describe('models/Agent', () => {
}
});
test('should handle loadAgent with agent from different project', async () => {
test('should return agent from different project (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1785,7 +1789,11 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeFalsy();
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Project Agent');
});
});
});

View File

@@ -2,7 +2,6 @@ const {
CacheKeys,
SystemRoles,
roleDefaults,
PermissionTypes,
permissionsSchema,
removeNullishValues,
} = require('librechat-data-provider');

View File

@@ -52,6 +52,7 @@
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",

View File

@@ -0,0 +1,437 @@
/**
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
*/
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
getAvailableRoles,
ensurePrincipalExists,
getEffectivePermissions,
ensureGroupPrincipalExists,
bulkUpdateResourcePermissions,
} = require('~/server/services/PermissionService');
const { AclEntry } = require('~/db/models');
const {
searchPrincipals: searchLocalPrincipals,
sortPrincipalsByRelevance,
calculateRelevanceScore,
} = require('~/models');
const {
searchEntraIdPrincipals,
entraIdPrincipalFeatureEnabled,
} = require('~/server/services/GraphApiService');
/**
* Generic controller for resource permission endpoints
* Delegates validation and logic to PermissionService
*/
/**
* Bulk update permissions for a resource (grant, update, remove)
* @route PUT /api/{resourceType}/{resourceId}/permissions
* @param {Object} req - Express request object
* @param {Object} req.params - Route parameters
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
* @param {string} req.params.resourceId - Resource ID
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
* @param {Object} res - Express response object
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
*/
const updateResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
/** @type {TUpdateResourcePermissionsRequest} */
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
const { id: userId } = req.user;
// Prepare principals for the service call
const updatedPrincipals = [];
const revokedPrincipals = [];
// Add updated principals
if (updated && Array.isArray(updated)) {
updatedPrincipals.push(...updated);
}
// Add public permission if enabled
if (isPublic && publicAccessRoleId) {
updatedPrincipals.push({
type: 'public',
id: null,
accessRoleId: publicAccessRoleId,
});
}
// Prepare authentication context for enhanced group member fetching
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
const authContext =
useEntraId && accessToken
? {
accessToken,
sub: req.user.openidId,
}
: null;
// Ensure updated principals exist in the database before processing permissions
const validatedPrincipals = [];
for (const principal of updatedPrincipals) {
try {
let principalId;
if (principal.type === 'public') {
principalId = null; // Public principals don't need database records
} else if (principal.type === 'user') {
principalId = await ensurePrincipalExists(principal);
} else if (principal.type === 'group') {
// Pass authContext to enable member fetching for Entra ID groups when available
principalId = await ensureGroupPrincipalExists(principal, authContext);
} else {
logger.error(`Unsupported principal type: ${principal.type}`);
continue; // Skip invalid principal types
}
// Update the principal with the validated ID for ACL operations
validatedPrincipals.push({
...principal,
id: principalId,
});
} catch (error) {
logger.error('Error ensuring principal exists:', {
principal: {
type: principal.type,
id: principal.id,
name: principal.name,
source: principal.source,
},
error: error.message,
});
// Continue with other principals instead of failing the entire operation
continue;
}
}
// Add removed principals
if (removed && Array.isArray(removed)) {
revokedPrincipals.push(...removed);
}
// If public is disabled, add public to revoked list
if (!isPublic) {
revokedPrincipals.push({
type: 'public',
id: null,
});
}
const results = await bulkUpdateResourcePermissions({
resourceType,
resourceId,
updatedPrincipals: validatedPrincipals,
revokedPrincipals,
grantedBy: userId,
});
/** @type {TUpdateResourcePermissionsResponse} */
const response = {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
public: isPublic || false,
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
};
res.status(200).json(response);
} catch (error) {
logger.error('Error updating resource permissions:', error);
res.status(400).json({
error: 'Failed to update permissions',
details: error.message,
});
}
};
/**
* Get principals with their permission roles for a resource (UI-friendly format)
* Uses efficient aggregation pipeline to join User/Group data in single query
* @route GET /api/permissions/{resourceType}/{resourceId}
*/
const getResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
// Use aggregation pipeline for efficient single-query data retrieval
const results = await AclEntry.aggregate([
// Match ACL entries for this resource
{
$match: {
resourceType,
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
? mongoose.Types.ObjectId.createFromHexString(resourceId)
: resourceId,
},
},
// Lookup AccessRole information
{
$lookup: {
from: 'accessroles',
localField: 'roleId',
foreignField: '_id',
as: 'role',
},
},
// Lookup User information (for user principals)
{
$lookup: {
from: 'users',
localField: 'principalId',
foreignField: '_id',
as: 'userInfo',
},
},
// Lookup Group information (for group principals)
{
$lookup: {
from: 'groups',
localField: 'principalId',
foreignField: '_id',
as: 'groupInfo',
},
},
// Project final structure
{
$project: {
principalType: 1,
principalId: 1,
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
userInfo: { $arrayElemAt: ['$userInfo', 0] },
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
},
},
]);
const principals = [];
let publicPermission = null;
// Process aggregation results
for (const result of results) {
if (result.principalType === 'public') {
publicPermission = {
public: true,
publicAccessRoleId: result.accessRoleId,
};
} else if (result.principalType === 'user' && result.userInfo) {
principals.push({
type: 'user',
id: result.userInfo._id.toString(),
name: result.userInfo.name || result.userInfo.username,
email: result.userInfo.email,
avatar: result.userInfo.avatar,
source: !result.userInfo._id ? 'entra' : 'local',
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
} else if (result.principalType === 'group' && result.groupInfo) {
principals.push({
type: 'group',
id: result.groupInfo._id.toString(),
name: result.groupInfo.name,
email: result.groupInfo.email,
description: result.groupInfo.description,
avatar: result.groupInfo.avatar,
source: result.groupInfo.source || 'local',
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
}
}
// Return response in format expected by frontend
const response = {
resourceType,
resourceId,
principals,
public: publicPermission?.public || false,
...(publicPermission?.publicAccessRoleId && {
publicAccessRoleId: publicPermission.publicAccessRoleId,
}),
};
res.status(200).json(response);
} catch (error) {
logger.error('Error getting resource permissions principals:', error);
res.status(500).json({
error: 'Failed to get permissions principals',
details: error.message,
});
}
};
/**
* Get available roles for a resource type
* @route GET /api/{resourceType}/roles
*/
const getResourceRoles = async (req, res) => {
try {
const { resourceType } = req.params;
const roles = await getAvailableRoles({ resourceType });
res.status(200).json(
roles.map((role) => ({
accessRoleId: role.accessRoleId,
name: role.name,
description: role.description,
permBits: role.permBits,
})),
);
} catch (error) {
logger.error('Error getting resource roles:', error);
res.status(500).json({
error: 'Failed to get roles',
details: error.message,
});
}
};
/**
* Get user's effective permission bitmask for a resource
* @route GET /api/{resourceType}/{resourceId}/effective
*/
const getUserEffectivePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
const { id: userId } = req.user;
const permissionBits = await getEffectivePermissions({
userId,
resourceType,
resourceId,
});
res.status(200).json({
permissionBits,
});
} catch (error) {
logger.error('Error getting user effective permissions:', error);
res.status(500).json({
error: 'Failed to get effective permissions',
details: error.message,
});
}
};
/**
* Search for users and groups to grant permissions
* Supports hybrid local database + Entra ID search when configured
* @route GET /api/permissions/search-principals
*/
const searchPrincipals = async (req, res) => {
try {
const { q: query, limit = 20, type } = req.query;
if (!query || query.trim().length === 0) {
return res.status(400).json({
error: 'Query parameter "q" is required and must not be empty',
});
}
if (query.trim().length < 2) {
return res.status(400).json({
error: 'Query must be at least 2 characters long',
});
}
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
const typeFilter = ['user', 'group'].includes(type) ? type : null;
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
let allPrincipals = [...localResults];
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
if (useEntraId && localResults.length < searchLimit) {
try {
const graphTypeMap = {
user: 'users',
group: 'groups',
null: 'all',
};
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
if (accessToken) {
const graphResults = await searchEntraIdPrincipals(
accessToken,
req.user.openidId,
query.trim(),
graphTypeMap[typeFilter],
searchLimit - localResults.length,
);
const localEmails = new Set(
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
);
const localGroupSourceIds = new Set(
localResults.map((p) => p.idOnTheSource).filter(Boolean),
);
for (const principal of graphResults) {
const isDuplicateByEmail =
principal.email && localEmails.has(principal.email.toLowerCase());
const isDuplicateBySourceId =
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
allPrincipals.push(principal);
}
}
}
} catch (graphError) {
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
}
}
const scoredResults = allPrincipals.map((item) => ({
...item,
_searchScore: calculateRelevanceScore(item, query.trim()),
}));
allPrincipals = sortPrincipalsByRelevance(scoredResults)
.slice(0, searchLimit)
.map((result) => {
const { _searchScore, ...resultWithoutScore } = result;
return resultWithoutScore;
});
res.status(200).json({
query: query.trim(),
limit: searchLimit,
type: typeFilter,
results: allPrincipals,
count: allPrincipals.length,
sources: {
local: allPrincipals.filter((r) => r.source === 'local').length,
entra: allPrincipals.filter((r) => r.source === 'entra').length,
},
});
} catch (error) {
logger.error('Error searching principals:', error);
res.status(500).json({
error: 'Failed to search principals',
details: error.message,
});
}
};
module.exports = {
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
getUserEffectivePermissions,
searchPrincipals,
};

View File

@@ -1,11 +1,10 @@
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const {
Tools,
Constants,
FileSources,
SystemRoles,
FileSources,
EToolResources,
actionDelimiter,
} = require('librechat-data-provider');
@@ -14,18 +13,24 @@ const {
createAgent,
updateAgent,
deleteAgent,
getListAgents,
getListAgentsByAccess,
countPromotedAgents,
revertAgentVersion,
} = require('~/models/Agent');
const {
grantPermission,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
} = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { revertAgentVersion } = require('~/models/Agent');
const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models');
const systemTools = {
[Tools.execute_code]: true,
@@ -69,6 +74,27 @@ const createAgentHandler = async (req, res) => {
agentData.id = `agent_${nanoid()}`;
const agent = await createAgent(agentData);
// Automatically grant owner permissions to the creator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
);
} catch (permissionError) {
logger.error(
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
permissionError,
);
}
res.status(201).json(agent);
} catch (error) {
logger.error('[/Agents] Error creating agent', error);
@@ -87,21 +113,14 @@ const createAgentHandler = async (req, res) => {
* @returns {Promise<Agent>} 200 - success response - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentHandler = async (req, res) => {
const getAgentHandler = async (req, res, expandProperties = false) => {
try {
const id = req.params.id;
const author = req.user.id;
let query = { id, author };
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
query = {
$or: [{ id, $in: globalProject.agentIds }, query],
};
}
const agent = await getAgent(query);
// Permissions are validated by middleware before calling this function
// Simply load the agent by ID
const agent = await getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
@@ -118,23 +137,45 @@ const getAgentHandler = async (req, res) => {
}
agent.author = agent.author.toString();
// @deprecated - isCollaborative replaced by ACL permissions
agent.isCollaborative = !!agent.isCollaborative;
// Check if agent is public
const isPublic = await hasPublicPermission({
resourceType: 'agent',
resourceId: agent._id,
requiredPermissions: PermissionBits.VIEW,
});
agent.isPublic = isPublic;
if (agent.author !== author) {
delete agent.author;
}
if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) {
if (!expandProperties) {
// VIEW permission: Basic agent info only
return res.status(200).json({
_id: agent._id,
id: agent.id,
name: agent.name,
description: agent.description,
avatar: agent.avatar,
author: agent.author,
provider: agent.provider,
model: agent.model,
projectIds: agent.projectIds,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: agent.isCollaborative,
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
});
}
// EDIT permission: Full agent details including sensitive configuration
return res.status(200).json(agent);
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
@@ -154,42 +195,20 @@ const getAgentHandler = async (req, res) => {
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const { projectIds, removeProjectIds, ...updateData } = req.body;
const isAdmin = req.user.role === SystemRoles.ADMIN;
const { _id, ...updateData } = req.body;
const existingAgent = await getAgent({ id });
const isAuthor = existingAgent.author.toString() === req.user.id;
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
/** @type {boolean} */
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
let updatedAgent =
Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, {
updatingUserId: req.user.id,
skipVersioning: isProjectUpdate,
})
: existingAgent;
if (isProjectUpdate) {
updatedAgent = await updateAgentProjects({
user: req.user,
agentId: id,
projectIds,
removeProjectIds,
});
}
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
@@ -307,6 +326,26 @@ const duplicateAgentHandler = async (req, res) => {
newAgentData.actions = agentActions;
const newAgent = await createAgent(newAgentData);
// Automatically grant owner permissions to the duplicator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: newAgent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
);
} catch (permissionError) {
logger.error(
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
permissionError,
);
}
return res.status(201).json({
agent: newAgent,
actions: newActionsList,
@@ -333,7 +372,7 @@ const deleteAgentHandler = async (req, res) => {
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
await deleteAgent({ id, author: req.user.id });
await deleteAgent({ id });
return res.json({ message: 'Agent deleted' });
} catch (error) {
logger.error('[/Agents/:id] Error deleting Agent', error);
@@ -342,7 +381,7 @@ const deleteAgentHandler = async (req, res) => {
};
/**
*
* Lists agents using ACL-aware permissions (ownership + explicit shares).
* @route GET /Agents
* @param {object} req - Express Request
* @param {object} req.query - Request query
@@ -351,9 +390,64 @@ const deleteAgentHandler = async (req, res) => {
*/
const getListAgentsHandler = async (req, res) => {
try {
const data = await getListAgents({
author: req.user.id,
const userId = req.user.id;
const { category, search, limit, cursor, promoted } = req.query;
let requiredPermission = req.query.requiredPermission;
if (typeof requiredPermission === 'string') {
requiredPermission = parseInt(requiredPermission, 10);
if (isNaN(requiredPermission)) {
requiredPermission = PermissionBits.VIEW;
}
} else if (typeof requiredPermission !== 'number') {
requiredPermission = PermissionBits.VIEW;
}
// Base filter
const filter = {};
// Handle category filter - only apply if category is defined
if (category !== undefined && category.trim() !== '') {
filter.category = category;
}
// Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
// Handle search filter
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
resourceType: 'agent',
requiredPermissions: requiredPermission,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW,
});
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
otherParams: filter,
limit,
after: cursor,
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
agent.isPublic = true;
}
return agent;
});
}
return res.json(data);
} catch (error) {
logger.error('[/Agents] Error listing Agents', error);
@@ -431,7 +525,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
};
promises.push(
await updateAgent({ id: agent_id, author: req.user.id }, data, {
await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
}),
);
@@ -511,7 +605,48 @@ const revertAgentVersionHandler = async (req, res) => {
res.status(500).json({ error: error.message });
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await getCategoriesWithCounts();
const promotedCount = await countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
formattedCategories.push({
value: 'all',
label: 'All',
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
@@ -521,4 +656,5 @@ module.exports = {
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
getAgentCategories,
};

View File

@@ -118,6 +118,8 @@ const startServer = async () => {
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);

View File

@@ -0,0 +1,97 @@
const { logger } = require('@librechat/data-schemas');
const { Constants, isAgentsEndpoint } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getAgent } = require('~/models/Agent');
/**
* Agent ID resolver function for agent_id from request body
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
* This is used specifically for chat routes where agent_id comes from request body
*
* @param {string} agentCustomId - Custom agent ID from request body
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentIdFromBody = async (agentCustomId) => {
// Handle ephemeral agents - they don't need permission checks
if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
return null; // No permission check needed for ephemeral agents
}
return await getAgent({ id: agentCustomId });
};
/**
* Middleware factory that creates middleware to check agent access permissions from request body.
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
* instead of route parameters.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for agent chat (requires VIEW permission)
* router.post('/chat',
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
* buildEndpointOption,
* chatController
* );
*/
const canAccessAgentFromBody = (options) => {
const { requiredPermission } = options;
// Validate required options
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
const { endpoint, agent_id } = req.body;
let agentId = agent_id;
if (!isAgentsEndpoint(endpoint)) {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
// Skip permission checks for ephemeral agents
if (agentId === Constants.EPHEMERAL_AGENT_ID) {
return next();
}
const agentAccessMiddleware = canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
idResolver: () => resolveAgentIdFromBody(agentId),
});
const tempReq = {
...req,
params: {
...req.params,
agent_id: agentId,
},
};
return agentAccessMiddleware(tempReq, res, next);
} catch (error) {
logger.error('Failed to validate agent access permissions', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate agent access permissions',
});
}
};
};
module.exports = {
canAccessAgentFromBody,
};

View File

@@ -0,0 +1,58 @@
const { getAgent } = require('~/models/Agent');
const { canAccessResource } = require('./canAccessResource');
/**
* Agent ID resolver function
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
*
* @param {string} agentCustomId - Custom agent ID from route parameter
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentId = async (agentCustomId) => {
return await getAgent({ id: agentCustomId });
};
/**
* Agent-specific middleware factory that creates middleware to check agent access permissions.
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing agents
* router.get('/agents/:id',
* canAccessAgentResource({ requiredPermission: 1 }),
* getAgent
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/agents/:agent_id',
* canAccessAgentResource({
* requiredPermission: 2,
* resourceIdParam: 'agent_id'
* }),
* updateAgent
* );
*/
const canAccessAgentResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
}
return canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam,
idResolver: resolveAgentId,
});
};
module.exports = {
canAccessAgentResource,
};

View File

@@ -0,0 +1,384 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
describe('canAccessAgentResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id.toString(), role: 'test-role' },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessAgentResource({})).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam', () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessAgentResource({
requiredPermission: 2,
resourceIdParam: 'agent_id',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real agents', () => {
test('should allow access when user is the agent author', async () => {
// Create an agent owned by the test user
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Other User Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: 'user',
principalId: otherUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Limited Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should handle non-existent agent', async () => {
req.params.id = 'agent_nonexistent';
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'agent not found',
});
});
test('should use custom resourceIdParam', async () => {
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Custom Param Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.agent_id = agent.id; // Using custom param name
const middleware = canAccessAgentResource({
requiredPermission: 1,
resourceIdParam: 'agent_id',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let agent;
beforeEach(async () => {
agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Permission Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
});
test('should support view permission (1)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with agent operations', () => {
test('should work with agent CRUD operations', async () => {
const agentId = `agent_${Date.now()}`;
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Integration Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
description: 'Testing integration',
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.id = agentId;
// Test view access
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the agent
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { description: 'Updated description' });
// Test edit access
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,157 @@
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
/**
* Generic base middleware factory that creates middleware to check resource access permissions.
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
*
* @param {Object} options - Configuration options
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
* @returns {Function} Express middleware function
*
* @example
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
* router.get('/prompts/:promptId',
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
* getPrompt
* );
*
* @example
* // Usage with custom ID resolver (for resources that use custom string IDs)
* router.get('/agents/:id',
* canAccessResource({
* resourceType: 'agent',
* requiredPermission: 1,
* resourceIdParam: 'id',
* idResolver: (customId) => resolveAgentId(customId)
* }),
* getAgent
* );
*/
const canAccessResource = (options) => {
const {
resourceType,
requiredPermission,
resourceIdParam = 'resourceId',
idResolver = null,
} = options;
if (!resourceType || typeof resourceType !== 'string') {
throw new Error('canAccessResource: resourceType is required and must be a string');
}
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessResource: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
// Extract resource ID from route parameters
const rawResourceId = req.params[resourceIdParam];
if (!rawResourceId) {
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
return res.status(400).json({
error: 'Bad Request',
message: `${resourceIdParam} is required`,
});
}
// Check if user is authenticated
if (!req.user || !req.user.id) {
logger.warn(
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
);
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
// if system admin let through
if (req.user.role === SystemRoles.ADMIN) {
return next();
}
const userId = req.user.id;
let resourceId = rawResourceId;
let resourceInfo = null;
// Resolve custom ID to ObjectId if resolver is provided
if (idResolver) {
logger.debug(
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
);
const resolutionResult = await idResolver(rawResourceId);
if (!resolutionResult) {
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
return res.status(404).json({
error: 'Not Found',
message: `${resourceType} not found`,
});
}
// Handle different resolver return formats
if (typeof resolutionResult === 'string' || resolutionResult._id) {
resourceId = resolutionResult._id || resolutionResult;
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
} else {
resourceId = resolutionResult;
}
logger.debug(
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
);
}
// Check permissions using PermissionService with ObjectId
const hasPermission = await checkPermission({
userId,
resourceType,
resourceId,
requiredPermission,
});
if (hasPermission) {
logger.debug(
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
);
req.resourceAccess = {
resourceType,
resourceId, // MongoDB ObjectId for ACL operations
customResourceId: rawResourceId, // Original ID from route params
permission: requiredPermission,
userId,
...(resourceInfo && { resourceInfo }),
};
return next();
}
logger.warn(
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
`(required permission: ${requiredPermission})`,
);
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions to access this ${resourceType}`,
});
} catch (error) {
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check resource access permissions',
});
}
};
};
module.exports = {
canAccessResource,
};

View File

@@ -0,0 +1,9 @@
const { canAccessResource } = require('./canAccessResource');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
module.exports = {
canAccessResource,
canAccessAgentResource,
canAccessAgentFromBody,
};

View File

@@ -8,6 +8,7 @@ const concurrentLimiter = require('./concurrentLimiter');
const validateEndpoint = require('./validateEndpoint');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const accessResources = require('./accessResources');
const setBalanceConfig = require('./setBalanceConfig');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
@@ -29,6 +30,7 @@ module.exports = {
...validate,
...limiters,
...roles,
...accessResources,
noIndex,
checkBan,
uaParser,

View File

@@ -0,0 +1,357 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { Role } = require('~/db/models');
// Mock the logger from @librechat/data-schemas
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
},
}));
// Mock the cache to use a simple in-memory implementation
const mockCache = new Map();
jest.mock('~/cache/getLogStores', () => {
return jest.fn(() => ({
get: jest.fn(async (key) => mockCache.get(key)),
set: jest.fn(async (key, value) => mockCache.set(key, value)),
clear: jest.fn(async () => mockCache.clear()),
}));
});
describe('Access Middleware', () => {
let mongoServer;
let req, res, next;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
mockCache.clear(); // Clear the cache between tests
// Create test roles
await Role.create({
name: 'user',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: false,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
await Role.create({
name: 'admin',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: true,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
// Create limited role with no AGENTS permissions
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
});
req = {
user: { id: 'user123', role: 'user' },
body: {},
originalUrl: '/test',
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('checkAccess', () => {
test('should return false if user is not provided', async () => {
const result = await checkAccess({
user: null,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if user lacks required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has any of multiple permissions', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should check body properties when permission is not directly granted', async () => {
const req = { body: { id: 'agent123' } };
const result = await checkAccess({
req,
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.UPDATE],
bodyProps: {
[Permissions.UPDATE]: ['id'],
},
checkObject: req.body,
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if role is not found', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'nonexistent' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return false if role has no permissions for the requested type', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'limited' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should handle admin role with all permissions', async () => {
const createResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(createResult).toBe(true);
const shareResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARED_GLOBAL],
getRoleByName,
});
expect(shareResult).toBe(true);
});
});
describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should check body properties when configured', async () => {
req.body = { agentId: 'agent123', description: 'test' };
const bodyProps = {
[Permissions.CREATE]: ['agentId'],
};
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
bodyProps,
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should handle database errors gracefully', async () => {
// Mock getRoleByName to throw an error
const mockGetRoleByName = jest
.fn()
.mockRejectedValue(new Error('Database connection failed'));
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: mockGetRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
});
test('should work with multiple permission types', async () => {
req.user.role = 'admin';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing user gracefully', async () => {
req.user = null;
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should handle role with no AGENTS permissions', async () => {
await Role.create({
name: 'noaccess',
permissions: {
// Explicitly set AGENTS with all permissions false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
},
});
req.user.role = 'noaccess';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
});
});

View File

@@ -0,0 +1,62 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const {
getUserEffectivePermissions,
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
searchPrincipals,
} = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const router = express.Router();
// Apply common middleware
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
/**
* Generic routes for resource permissions
* Pattern: /api/permissions/{resourceType}/{resourceId}
*/
/**
* GET /api/permissions/search-principals
* Search for users and groups to grant permissions
*/
router.get('/search-principals', searchPrincipals);
/**
* GET /api/permissions/{resourceType}/roles
* Get available roles for a resource type
*/
router.get('/:resourceType/roles', getResourceRoles);
/**
* GET /api/permissions/{resourceType}/{resourceId}
* Get all permissions for a specific resource
*/
router.get('/:resourceType/:resourceId', getResourcePermissions);
/**
* PUT /api/permissions/{resourceType}/{resourceId}
* Bulk update permissions for a specific resource
*/
router.put(
'/:resourceType/:resourceId',
canAccessResource({
resourceType: 'agent',
requiredPermission: PermissionBits.SHARE,
resourceIdParam: 'resourceId',
}),
updateResourcePermissions,
);
/**
* GET /api/permissions/{resourceType}/{resourceId}/effective
* Get user's effective permissions for a specific resource
*/
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
module.exports = router;

View File

@@ -1,9 +1,8 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const {
SystemRoles,
Permissions,
PermissionTypes,
actionDelimiter,
@@ -12,6 +11,7 @@ const {
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware');
const { getAgent, updateAgent } = require('~/models/Agent');
const { getRoleByName } = require('~/models/Role');
@@ -23,12 +23,6 @@ const checkAgentCreate = generateCheckAccess({
getRoleByName,
});
// If the user has ADMIN role
// then action edition is possible even if not owner of the assistant
const isAdmin = (req) => {
return req.user.role === SystemRoles.ADMIN;
};
/**
* Retrieves all user's actions
* @route GET /actions/
@@ -37,9 +31,8 @@ const isAdmin = (req) => {
*/
router.get('/', async (req, res) => {
try {
const admin = isAdmin(req);
// If admin, get all actions, otherwise only user's actions
const searchParams = admin ? {} : { user: req.user.id };
// Get all actions for the user (admin permissions handled by middleware if needed)
const searchParams = { user: req.user.id };
res.json(await getActions(searchParams));
} catch (error) {
res.status(500).json({ error: error.message });
@@ -55,106 +48,111 @@ router.get('/', async (req, res) => {
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
* @returns {Object} 200 - success response - application/json
*/
router.post('/:agent_id', checkAgentCreate, async (req, res) => {
try {
const { agent_id } = req.params;
router.post(
'/:agent_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
checkAgentCreate,
async (req, res) => {
try {
const { agent_id } = req.params;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
const admin = isAdmin(req);
// If admin, can edit any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
// TODO: share agents
initialPromises.push(getAgent(agentQuery));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
agentQuery,
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
// Permissions already validated by middleware - load agent directly
initialPromises.push(getAgent({ id: agent_id }));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
{ id: agent_id },
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
},
);
/**
* Deletes an action for a specific agent.
@@ -163,52 +161,56 @@ router.post('/:agent_id', checkAgentCreate, async (req, res) => {
* @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json
*/
router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => {
try {
const { agent_id, action_id } = req.params;
const admin = isAdmin(req);
router.delete(
'/:agent_id/:action_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
checkAgentCreate,
async (req, res) => {
try {
const { agent_id, action_id } = req.params;
// If admin, can delete any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
const agent = await getAgent(agentQuery);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
const { tools = [], actions = [] } = agent;
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
// Permissions already validated by middleware - load agent directly
const agent = await getAgent({ id: agent_id });
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
return true;
});
domain = await domainParser(domain, true);
const { tools = [], actions = [] } = agent;
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
}
return true;
});
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
{ id: agent_id },
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
await deleteAction({ action_id });
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
agentQuery,
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
// If admin, can delete any action, otherwise only user's actions
const actionQuery = admin ? { action_id } : { action_id, user: req.user.id };
await deleteAction(actionQuery);
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
},
);
module.exports = router;

View File

@@ -1,4 +1,5 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
@@ -7,6 +8,7 @@ const {
// validateModel,
validateConvoAccess,
buildEndpointOption,
canAccessAgentFromBody,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request');
@@ -23,8 +25,12 @@ const checkAgentAccess = generateCheckAccess({
skipCheck: skipAgentCheck,
getRoleByName,
});
const checkAgentResourceAccess = canAccessAgentFromBody({
requiredPermission: PermissionBits.VIEW,
});
router.use(checkAgentAccess);
router.use(checkAgentResourceAccess);
router.use(validateConvoAccess);
router.use(buildEndpointOption);
router.use(setHeaders);

View File

@@ -37,4 +37,6 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
// Add marketplace routes
module.exports = router;

View File

@@ -1,7 +1,8 @@
const express = require('express');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionBits } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { requireJwtAuth, canAccessAgentResource } = require('~/server/middleware');
const v1 = require('~/server/controllers/agents/v1');
const { getRoleByName } = require('~/models/Role');
const actions = require('./actions');
@@ -44,6 +45,11 @@ router.use('/actions', actions);
*/
router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', v1.getAgentCategories);
/**
* Creates an agent.
* @route POST /agents
@@ -53,13 +59,38 @@ router.use('/tools', tools);
router.post('/', checkAgentCreate, v1.createAgent);
/**
* Retrieves an agent.
* Retrieves basic agent information (VIEW permission required).
* Returns safe, non-sensitive agent data for viewing purposes.
* @route GET /agents/:id
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Success response - application/json
* @returns {Agent} 200 - Basic agent info - application/json
*/
router.get('/:id', checkAgentAccess, v1.getAgent);
router.get(
'/:id',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.getAgent,
);
/**
* Retrieves full agent details including sensitive configuration (EDIT permission required).
* Returns complete agent data for editing/configuration purposes.
* @route GET /agents/:id/expanded
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Full agent details - application/json
*/
router.get(
'/:id/expanded',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
(req, res) => v1.getAgent(req, res, true), // Expanded version
);
/**
* Updates an agent.
* @route PATCH /agents/:id
@@ -67,7 +98,15 @@ router.get('/:id', checkAgentAccess, v1.getAgent);
* @param {AgentUpdateParams} req.body - The agent update parameters.
* @returns {Agent} 200 - Success response - application/json
*/
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
router.patch(
'/:id',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.updateAgent,
);
/**
* Duplicates an agent.
@@ -75,7 +114,15 @@ router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - Success response - application/json
*/
router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
router.post(
'/:id/duplicate',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.duplicateAgent,
);
/**
* Deletes an agent.
@@ -83,7 +130,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - success response - application/json
*/
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
router.delete(
'/:id',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.DELETE,
resourceIdParam: 'id',
}),
v1.deleteAgent,
);
/**
* Reverts an agent to a previous version.
@@ -110,6 +165,14 @@ router.get('/', checkAgentAccess, v1.getListAgents);
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
* @returns {Object} 200 - success response - application/json
*/
avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar);
avatar.post(
'/:agent_id/avatar/',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
v1.uploadAgentAvatar,
);
module.exports = { v1: router, avatar };

View File

@@ -1,3 +1,4 @@
const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants');
const categories = require('./categories');
const tokenizer = require('./tokenizer');
@@ -28,6 +29,7 @@ const user = require('./user');
const mcp = require('./mcp');
module.exports = {
mcp,
edit,
auth,
keys,
@@ -55,5 +57,5 @@ module.exports = {
assistants,
categories,
staticRoute,
mcp,
accessPermissions,
};

View File

@@ -9,6 +9,7 @@ const {
setBalanceConfig,
checkDomainAllowed,
} = require('~/server/middleware');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
@@ -35,6 +36,7 @@ const oauthHandler = async (req, res) => {
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res);
} else {
await setAuthTokens(req.user._id, res);

View File

@@ -1,5 +1,7 @@
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View File

@@ -17,6 +17,7 @@ const {
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
const { initializeFirebase } = require('./Files/Firebase/initialize');
const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface');
@@ -26,7 +27,6 @@ const { processModelSpecs } = require('./start/modelSpecs');
const { initializeS3 } = require('./Files/S3/initialize');
const { loadAndFormatTools } = require('./ToolService');
const { isEnabled } = require('~/server/utils');
const { initializeRoles } = require('~/models');
const { setCachedTools } = require('./Config');
const paths = require('~/config/paths');
@@ -37,6 +37,8 @@ const paths = require('~/config/paths');
*/
const AppService = async (app) => {
await initializeRoles();
await seedDefaultRoles();
await ensureDefaultCategories();
/** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults();

View File

@@ -28,6 +28,8 @@ jest.mock('./Files/Firebase/initialize', () => ({
}));
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View File

@@ -0,0 +1,525 @@
const client = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { Client } = require('@microsoft/microsoft-graph-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
/**
* @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from 'librechat-data-provider'
*/
/**
* Checks if Entra ID principal search feature is enabled based on environment variables and user authentication
* @param {Object} user - User object from request
* @param {string} user.provider - Authentication provider
* @param {string} user.openidId - OpenID subject identifier
* @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID
*/
const entraIdPrincipalFeatureEnabled = (user) => {
return (
isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) &&
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
user?.provider === 'openid' &&
user?.openidId
);
};
/**
* Creates a Microsoft Graph client with on-behalf-of token exchange
* @param {string} accessToken - OpenID Connect access token from user
* @param {string} sub - Subject identifier from token claims
* @returns {Promise<Client>} Authenticated Graph API client
*/
const createGraphClient = async (accessToken, sub) => {
try {
// Reason: Use existing OpenID configuration and token exchange pattern from openidStrategy.js
const openidConfig = getOpenIdConfig();
const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub);
const graphClient = Client.init({
authProvider: (done) => {
done(null, exchangedToken);
},
});
return graphClient;
} catch (error) {
logger.error('[createGraphClient] Error creating Graph client:', error);
throw error;
}
};
/**
* Exchange OpenID token for Graph API access using on-behalf-of flow
* Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes
* @param {Configuration} config - OpenID configuration
* @param {string} accessToken - Original access token
* @param {string} sub - Subject identifier
* @returns {Promise<string>} Graph API access token
*/
const exchangeTokenForGraphAccess = async (config, accessToken, sub) => {
try {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const cacheKey = `${sub}:graph`;
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
return cachedToken.access_token;
}
const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All';
const scopeString = graphScopes
.split(',')
.map((scope) => `https://graph.microsoft.com/${scope}`)
.join(' ');
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: scopeString,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
await tokensCache.set(
cacheKey,
{
access_token: grantResponse.access_token,
},
grantResponse.expires_in * 1000,
);
return grantResponse.access_token;
} catch (error) {
logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error);
throw error;
}
};
/**
* Search for principals (people and groups) using Microsoft Graph API
* Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} query - Search query string
* @param {string} type - Type filter ('users', 'groups', or 'all')
* @param {number} limit - Maximum number of results
* @returns {Promise<TPrincipalSearchResult[]>} Array of principal search results
*/
const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
const graphClient = await createGraphClient(accessToken, sub);
let allResults = [];
if (type === 'users' || type === 'all') {
const contactResults = await searchContacts(graphClient, query, limit);
allResults.push(...contactResults);
}
if (allResults.length >= limit) {
return allResults.slice(0, limit);
}
if (type === 'users') {
const userResults = await searchUsers(graphClient, query, limit);
allResults.push(...userResults);
} else if (type === 'groups') {
const groupResults = await searchGroups(graphClient, query, limit);
allResults.push(...groupResults);
} else if (type === 'all') {
const [userResults, groupResults] = await Promise.all([
searchUsers(graphClient, query, limit),
searchGroups(graphClient, query, limit),
]);
allResults.push(...userResults, ...groupResults);
}
const seenIds = new Set();
const uniqueResults = allResults.filter((result) => {
if (seenIds.has(result.idOnTheSource)) {
return false;
}
seenIds.add(result.idOnTheSource);
return true;
});
return uniqueResults.slice(0, limit);
} catch (error) {
logger.error('[searchEntraIdPrincipals] Error searching principals:', error);
return [];
}
};
/**
* Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return [];
}
};
/**
* Get current user's owned Entra ID groups from Microsoft Graph
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient
.api('/me/ownedObjects/microsoft.graph.group')
.select('id')
.get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
}
};
/**
* Get group members from Microsoft Graph API
* Recursively fetches all members using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of member IDs (idOnTheSource values)
*/
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allMembers;
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];
}
};
/**
* Get group owners from Microsoft Graph API
* Recursively fetches all owners using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
*/
const getGroupOwners = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allOwners = [];
let nextLink = `/groups/${groupId}/owners`;
while (nextLink) {
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const owners = ownersResponse.value || [];
allOwners.push(...owners.map((member) => member.id));
nextLink = ownersResponse['@odata.nextLink']
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allOwners;
} catch (error) {
logger.error('[getGroupOwners] Error fetching group owners:', error);
return [];
}
};
/**
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
* Returns mapped TPrincipalSearchResult objects for users only
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user contact results
*/
const searchContacts = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
if (
process.env.OPENID_GRAPH_SCOPES &&
!process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read')
) {
logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search');
return [];
}
// Reason: Search only for OrganizationUser (person) type, not groups
const filter = "personType/subclass eq 'OrganizationUser'";
let apiCall = graphClient
.api('/me/people')
.search(`"${query}"`)
.select(
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones',
)
.header('ConsistencyLevel', 'eventual')
.filter(filter)
.top(limit);
const contactsResponse = await apiCall.get();
return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchContacts] Error searching contacts:', error);
return [];
}
};
/**
* Search for users using Microsoft Graph /users endpoint
* Returns mapped TPrincipalSearchResult objects
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user results
*/
const searchUsers = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
// Reason: Search users by display name, email, and user principal name
const usersResponse = await graphClient
.api('/users')
.search(
`"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`,
)
.select(
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones',
)
.header('ConsistencyLevel', 'eventual')
.top(limit)
.get();
return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchUsers] Error searching users:', error);
return [];
}
};
/**
* Search for groups using Microsoft Graph /groups endpoint
* Returns mapped TPrincipalSearchResult objects, includes all group types
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped group results
*/
const searchGroups = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
// Reason: Search all groups by display name and email without filtering group types
const groupsResponse = await graphClient
.api('/groups')
.search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`)
.select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions')
.header('ConsistencyLevel', 'eventual')
.top(limit)
.get();
return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchGroups] Error searching groups:', error);
return [];
}
};
/**
* Test Graph API connectivity and permissions
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Object>} Test results with available permissions
*/
const testGraphApiAccess = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const results = {
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [],
};
// Test User.Read permission
try {
await graphClient.api('/me').select('id,displayName').get();
results.userAccess = true;
} catch (error) {
results.errors.push(`User.Read: ${error.message}`);
}
// Test People.Read permission with OrganizationUser filter
try {
await graphClient
.api('/me/people')
.filter("personType/subclass eq 'OrganizationUser'")
.top(1)
.get();
results.peopleAccess = true;
} catch (error) {
results.errors.push(`People.Read (OrganizationUser): ${error.message}`);
}
// Test People.Read permission with UnifiedGroup filter
try {
await graphClient
.api('/me/people')
.filter("personType/subclass eq 'UnifiedGroup'")
.top(1)
.get();
results.groupsAccess = true;
} catch (error) {
results.errors.push(`People.Read (UnifiedGroup): ${error.message}`);
}
// Test /users endpoint access (requires User.Read.All or similar)
try {
await graphClient
.api('/users')
.search('"displayName:test"')
.select('id,displayName,userPrincipalName')
.top(1)
.get();
results.usersEndpointAccess = true;
} catch (error) {
results.errors.push(`Users endpoint: ${error.message}`);
}
// Test /groups endpoint access (requires Group.Read.All or similar)
try {
await graphClient
.api('/groups')
.search('"displayName:test"')
.select('id,displayName,mail')
.top(1)
.get();
results.groupsEndpointAccess = true;
} catch (error) {
results.errors.push(`Groups endpoint: ${error.message}`);
}
return results;
} catch (error) {
logger.error('[testGraphApiAccess] Error testing Graph API access:', error);
return {
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [error.message],
};
}
};
/**
* Map Graph API user object to TPrincipalSearchResult format
* @param {TGraphUser} user - Raw user object from Graph API
* @returns {TPrincipalSearchResult} Mapped user result
*/
const mapUserToTPrincipalSearchResult = (user) => {
return {
id: null,
type: 'user',
name: user.displayName,
email: user.mail || user.userPrincipalName,
username: user.userPrincipalName,
source: 'entra',
idOnTheSource: user.id,
};
};
/**
* Map Graph API group object to TPrincipalSearchResult format
* @param {TGraphGroup} group - Raw group object from Graph API
* @returns {TPrincipalSearchResult} Mapped group result
*/
const mapGroupToTPrincipalSearchResult = (group) => {
return {
id: null,
type: 'group',
name: group.displayName,
email: group.mail || group.userPrincipalName,
description: group.description,
source: 'entra',
idOnTheSource: group.id,
};
};
/**
* Map Graph API /me/people contact object to TPrincipalSearchResult format
* Handles both user and group contacts from the people endpoint
* @param {TGraphPerson} contact - Raw contact object from Graph API /me/people
* @returns {TPrincipalSearchResult} Mapped contact result
*/
const mapContactToTPrincipalSearchResult = (contact) => {
const isGroup = contact.personType?.class === 'Group';
const primaryEmail = contact.scoredEmailAddresses?.[0]?.address;
return {
id: null,
type: isGroup ? 'group' : 'user',
name: contact.displayName,
email: primaryEmail,
username: !isGroup ? contact.userPrincipalName : undefined,
source: 'entra',
idOnTheSource: contact.id,
};
};
module.exports = {
getGroupMembers,
getGroupOwners,
createGraphClient,
getUserEntraGroups,
getUserOwnedEntraGroups,
testGraphApiAccess,
searchEntraIdPrincipals,
exchangeTokenForGraphAccess,
entraIdPrincipalFeatureEnabled,
};

View File

@@ -0,0 +1,720 @@
jest.mock('@microsoft/microsoft-graph-client');
jest.mock('~/strategies/openidStrategy');
jest.mock('~/cache/getLogStores');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
debug: jest.fn(),
},
createAxiosInstance: jest.fn(() => ({
create: jest.fn(),
defaults: {},
})),
}));
jest.mock('~/utils', () => ({
logAxiosError: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
const mongoose = require('mongoose');
const client = require('openid-client');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { Client } = require('@microsoft/microsoft-graph-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const GraphApiService = require('./GraphApiService');
describe('GraphApiService', () => {
let mongoServer;
let mockGraphClient;
let mockTokensCache;
let mockOpenIdConfig;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(() => {
// Clean up environment variables
delete process.env.OPENID_GRAPH_SCOPES;
});
beforeEach(async () => {
jest.clearAllMocks();
await mongoose.connection.dropDatabase();
// Set up environment variable for People.Read scope
process.env.OPENID_GRAPH_SCOPES = 'User.Read,People.Read,Group.Read.All';
// Mock Graph client
mockGraphClient = {
api: jest.fn().mockReturnThis(),
search: jest.fn().mockReturnThis(),
filter: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(),
get: jest.fn(),
};
Client.init.mockReturnValue(mockGraphClient);
// Mock tokens cache
mockTokensCache = {
get: jest.fn(),
set: jest.fn(),
};
getLogStores.mockReturnValue(mockTokensCache);
// Mock OpenID config
mockOpenIdConfig = {
client_id: 'test-client-id',
issuer: 'https://test-issuer.com',
};
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
// Mock openid-client (using the existing jest mock configuration)
if (client.genericGrantRequest) {
client.genericGrantRequest.mockResolvedValue({
access_token: 'mocked-graph-token',
expires_in: 3600,
});
}
});
describe('Dependency Contract Tests', () => {
it('should fail if getOpenIdConfig interface changes', () => {
// Reason: Ensure getOpenIdConfig returns expected structure
const config = getOpenIdConfig();
expect(config).toBeDefined();
expect(typeof config).toBe('object');
// Add specific property checks that GraphApiService depends on
expect(config).toHaveProperty('client_id');
expect(config).toHaveProperty('issuer');
// Ensure the function is callable
expect(typeof getOpenIdConfig).toBe('function');
});
it('should fail if openid-client.genericGrantRequest interface changes', () => {
// Reason: Ensure client.genericGrantRequest maintains expected signature
if (client.genericGrantRequest) {
expect(typeof client.genericGrantRequest).toBe('function');
// Test that it accepts the expected parameters
const mockCall = client.genericGrantRequest(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: 'test-scope',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
expect(mockCall).toBeDefined();
}
});
it('should fail if Microsoft Graph Client interface changes', () => {
// Reason: Ensure Graph Client maintains expected fluent API
expect(typeof Client.init).toBe('function');
const client = Client.init({ authProvider: jest.fn() });
expect(client).toHaveProperty('api');
expect(typeof client.api).toBe('function');
});
});
describe('createGraphClient', () => {
it('should create graph client with exchanged token', async () => {
const accessToken = 'test-access-token';
const sub = 'test-user-id';
const result = await GraphApiService.createGraphClient(accessToken, sub);
expect(getOpenIdConfig).toHaveBeenCalled();
expect(Client.init).toHaveBeenCalledWith({
authProvider: expect.any(Function),
});
expect(result).toBe(mockGraphClient);
});
it('should handle token exchange errors gracefully', async () => {
if (client.genericGrantRequest) {
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
}
await expect(GraphApiService.createGraphClient('invalid-token', 'test-user')).rejects.toThrow(
'Token exchange failed',
);
});
});
describe('exchangeTokenForGraphAccess', () => {
it('should return cached token if available', async () => {
const cachedToken = { access_token: 'cached-token' };
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
expect(result).toBe('cached-token');
expect(mockTokensCache.get).toHaveBeenCalledWith('test-user:graph');
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).not.toHaveBeenCalled();
}
});
it('should exchange token and cache result', async () => {
mockTokensCache.get.mockResolvedValue(null);
const result = await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope:
'https://graph.microsoft.com/User.Read https://graph.microsoft.com/People.Read https://graph.microsoft.com/Group.Read.All',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
}
expect(mockTokensCache.set).toHaveBeenCalledWith(
'test-user:graph',
{ access_token: 'mocked-graph-token' },
3600000,
);
expect(result).toBe('mocked-graph-token');
});
it('should use custom scopes from environment', async () => {
const originalEnv = process.env.OPENID_GRAPH_SCOPES;
process.env.OPENID_GRAPH_SCOPES = 'Custom.Read,Custom.Write';
mockTokensCache.get.mockResolvedValue(null);
await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope:
'https://graph.microsoft.com/Custom.Read https://graph.microsoft.com/Custom.Write',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
}
process.env.OPENID_GRAPH_SCOPES = originalEnv;
});
});
describe('searchEntraIdPrincipals', () => {
// Mock data used by multiple tests
const mockContactsResponse = {
value: [
{
id: 'contact-user-1',
displayName: 'John Doe',
userPrincipalName: 'john@company.com',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
},
{
id: 'contact-group-1',
displayName: 'Marketing Team',
mail: 'marketing@company.com',
personType: { class: 'Group', subclass: 'UnifiedGroup' },
scoredEmailAddresses: [{ address: 'marketing@company.com', relevanceScore: 0.8 }],
},
],
};
const mockUsersResponse = {
value: [
{
id: 'dir-user-1',
displayName: 'Jane Smith',
userPrincipalName: 'jane@company.com',
mail: 'jane@company.com',
},
],
};
const mockGroupsResponse = {
value: [
{
id: 'dir-group-1',
displayName: 'Development Team',
mail: 'dev@company.com',
},
],
};
beforeEach(() => {
// Reset mock call history for each test
jest.clearAllMocks();
// Re-apply the Client.init mock after clearAllMocks
Client.init.mockReturnValue(mockGraphClient);
// Re-apply openid-client mock
if (client.genericGrantRequest) {
client.genericGrantRequest.mockResolvedValue({
access_token: 'mocked-graph-token',
expires_in: 3600,
});
}
// Re-apply cache mock
mockTokensCache.get.mockResolvedValue(null); // Force token exchange
mockTokensCache.set.mockResolvedValue();
getLogStores.mockReturnValue(mockTokensCache);
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
});
it('should return empty results for short queries', async () => {
const result = await GraphApiService.searchEntraIdPrincipals('token', 'user', 'a', 'all', 10);
expect(result).toEqual([]);
expect(mockGraphClient.api).not.toHaveBeenCalled();
});
it('should search contacts first and additional users for users type', async () => {
// Mock responses for this specific test
const contactsFilteredResponse = {
value: [
{
id: 'contact-user-1',
displayName: 'John Doe',
userPrincipalName: 'john@company.com',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
},
],
};
mockGraphClient.get
.mockResolvedValueOnce(contactsFilteredResponse) // contacts call
.mockResolvedValueOnce(mockUsersResponse); // users call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'john',
'users',
10,
);
// Should call contacts first with user filter
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"john"');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
// Should call users endpoint for additional results
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.search).toHaveBeenCalledWith(
'"displayName:john" OR "userPrincipalName:john" OR "mail:john" OR "givenName:john" OR "surname:john"',
);
// Should return TPrincipalSearchResult array
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2); // 1 from contacts + 1 from users
expect(result[0]).toMatchObject({
id: null,
type: 'user',
name: 'John Doe',
email: 'john@company.com',
source: 'entra',
idOnTheSource: 'contact-user-1',
});
});
it('should search groups endpoint only for groups type', async () => {
// Mock responses for this specific test - only groups endpoint called
mockGraphClient.get.mockResolvedValueOnce(mockGroupsResponse); // only groups call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'team',
'groups',
10,
);
// Should NOT call contacts for groups type
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/me/people');
// Should call groups endpoint only
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.search).toHaveBeenCalledWith(
'"displayName:team" OR "mail:team" OR "mailNickname:team"',
);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1); // 1 from groups only
});
it('should search all endpoints for all type', async () => {
// Mock responses for this specific test
mockGraphClient.get
.mockResolvedValueOnce(mockContactsResponse) // contacts call (both user and group)
.mockResolvedValueOnce(mockUsersResponse) // users call
.mockResolvedValueOnce(mockGroupsResponse); // groups call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
// Should call contacts first with user filter
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
// Should call both users and groups endpoints
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(4); // 2 from contacts + 1 from users + 1 from groups
});
it('should early exit if contacts reach limit', async () => {
// Mock contacts to return exactly the limit
const limitedContactsResponse = {
value: Array(10).fill({
id: 'contact-1',
displayName: 'Contact User',
mail: 'contact@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
}),
};
mockGraphClient.get.mockResolvedValueOnce(limitedContactsResponse);
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
// Should call contacts first
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
// Should not call users endpoint since limit was reached
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/users');
expect(result).toHaveLength(10);
});
it('should deduplicate results based on idOnTheSource', async () => {
// Mock responses with duplicate IDs
const duplicateContactsResponse = {
value: [
{
id: 'duplicate-id',
displayName: 'John Doe',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
},
],
};
const duplicateUsersResponse = {
value: [
{
id: 'duplicate-id', // Same ID as contact
displayName: 'John Doe',
mail: 'john@company.com',
},
],
};
mockGraphClient.get
.mockResolvedValueOnce(duplicateContactsResponse)
.mockResolvedValueOnce(duplicateUsersResponse);
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'john',
'users',
10,
);
// Should only return one result despite duplicate IDs
expect(result).toHaveLength(1);
expect(result[0].idOnTheSource).toBe('duplicate-id');
});
it('should handle Graph API errors gracefully', async () => {
mockGraphClient.get.mockRejectedValue(new Error('Graph API error'));
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
expect(result).toEqual([]);
});
});
describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => {
const mockGroupsResponse = {
value: [
{
id: 'group-1',
},
{
id: 'group-2',
},
],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
it('should handle empty response', async () => {
const mockGroupsResponse = {
value: [],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should test all permissions and return success results', async () => {
// Mock successful responses for all tests
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me test
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser test
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup test
.mockResolvedValueOnce({ value: [] }) // /users endpoint test
.mockResolvedValueOnce({ value: [] }); // /groups endpoint test
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: true,
groupsAccess: true,
usersEndpointAccess: true,
groupsEndpointAccess: true,
errors: [],
});
// Verify all endpoints were tested
expect(mockGraphClient.api).toHaveBeenCalledWith('/me');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
expect(mockGraphClient.filter).toHaveBeenCalledWith("personType/subclass eq 'UnifiedGroup'");
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
});
it('should handle partial failures and record errors', async () => {
// Mock mixed success/failure responses
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
.mockRejectedValueOnce(new Error('People access denied')) // people OrganizationUser fail
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
.mockRejectedValueOnce(new Error('Users endpoint access denied')) // /users fail
.mockResolvedValueOnce({ value: [] }); // /groups success
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: false,
groupsAccess: true,
usersEndpointAccess: false,
groupsEndpointAccess: true,
errors: [
'People.Read (OrganizationUser): People access denied',
'Users endpoint: Users endpoint access denied',
],
});
});
it('should handle complete Graph client creation failure', async () => {
// Mock token exchange failure to test error handling
if (client.genericGrantRequest) {
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
}
const result = await GraphApiService.testGraphApiAccess('invalid-token', 'user');
expect(result).toEqual({
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: ['Token exchange failed'],
});
});
it('should record all permission errors', async () => {
// Mock all requests to fail
mockGraphClient.get
.mockRejectedValueOnce(new Error('User.Read denied'))
.mockRejectedValueOnce(new Error('People.Read OrganizationUser denied'))
.mockRejectedValueOnce(new Error('People.Read UnifiedGroup denied'))
.mockRejectedValueOnce(new Error('Users directory access denied'))
.mockRejectedValueOnce(new Error('Groups directory access denied'));
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [
'User.Read: User.Read denied',
'People.Read (OrganizationUser): People.Read OrganizationUser denied',
'People.Read (UnifiedGroup): People.Read UnifiedGroup denied',
'Users endpoint: Users directory access denied',
'Groups endpoint: Groups directory access denied',
],
});
});
it('should test new endpoints with correct search patterns', async () => {
// Mock successful responses for endpoint testing
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup
.mockResolvedValueOnce({ value: [] }) // /users
.mockResolvedValueOnce({ value: [] }); // /groups
await GraphApiService.testGraphApiAccess('token', 'user');
// Verify /users endpoint test
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,userPrincipalName');
// Verify /groups endpoint test
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,mail');
});
it('should handle endpoint-specific permission failures', async () => {
// Mock specific endpoint failures
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser success
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
.mockRejectedValueOnce(new Error('Insufficient privileges')) // /users fail (User.Read.All needed)
.mockRejectedValueOnce(new Error('Access denied to groups')); // /groups fail (Group.Read.All needed)
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: true,
groupsAccess: true,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [
'Users endpoint: Insufficient privileges',
'Groups endpoint: Access denied to groups',
],
});
});
});
});

View File

@@ -0,0 +1,721 @@
const mongoose = require('mongoose');
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils');
const {
entraIdPrincipalFeatureEnabled,
getUserEntraGroups,
getUserOwnedEntraGroups,
getGroupMembers,
getGroupOwners,
} = require('~/server/services/GraphApiService');
const {
findGroupByExternalId,
findRoleByIdentifier,
getUserPrincipals,
createGroup,
createUser,
updateUser,
findUser,
grantPermission: grantPermissionACL,
findAccessibleResources: findAccessibleResourcesACL,
hasPermission,
getEffectivePermissions: getEffectivePermissionsACL,
findEntriesByPrincipalsAndResource,
} = require('~/models');
const { AclEntry, AccessRole, Group } = require('~/db/models');
/** @type {boolean|null} */
let transactionSupportCache = null;
/**
* @import { TPrincipal } from 'librechat-data-provider'
*/
/**
* Grant a permission to a principal for a resource using a role
* @param {Object} params - Parameters for granting role-based permission
* @param {string} params.principalType - 'user', 'group', or 'public'
* @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for 'public')
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {string} params.accessRoleId - The ID of the role (e.g., 'agent_viewer', 'agent_editor')
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID granting the permission
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
* @returns {Promise<Object>} The created or updated ACL entry
*/
const grantPermission = async ({
principalType,
principalId,
resourceType,
resourceId,
accessRoleId,
grantedBy,
session,
}) => {
try {
if (!['user', 'group', 'public'].includes(principalType)) {
throw new Error(`Invalid principal type: ${principalType}`);
}
if (principalType !== 'public' && !principalId) {
throw new Error('Principal ID is required for user and group principals');
}
if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) {
throw new Error(`Invalid principal ID: ${principalId}`);
}
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
throw new Error(`Invalid resource ID: ${resourceId}`);
}
// Get the role to determine permission bits
const role = await findRoleByIdentifier(accessRoleId);
if (!role) {
throw new Error(`Role ${accessRoleId} not found`);
}
// Ensure the role is for the correct resource type
if (role.resourceType !== resourceType) {
throw new Error(
`Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`,
);
}
return await grantPermissionACL(
principalType,
principalId,
resourceType,
resourceId,
role.permBits,
grantedBy,
session,
role._id,
);
} catch (error) {
logger.error(`[PermissionService.grantPermission] Error: ${error.message}`);
throw error;
}
};
/**
* Check if a user has specific permission bits on a resource
* @param {Object} params - Parameters for checking permissions
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<boolean>} Whether the user has the required permission bits
*/
const checkPermission = async ({ userId, resourceType, resourceId, requiredPermission }) => {
try {
if (typeof requiredPermission !== 'number' || requiredPermission < 1) {
throw new Error('requiredPermission must be a positive number');
}
// Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals(userId);
if (principals.length === 0) {
return false;
}
return await hasPermission(principals, resourceType, resourceId, requiredPermission);
} catch (error) {
logger.error(`[PermissionService.checkPermission] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermission must be')) {
throw error;
}
return false;
}
};
/**
* Get effective permission bitmask for a user on a resource
* @param {Object} params - Parameters for getting effective permissions
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @returns {Promise<number>} Effective permission bitmask
*/
const getEffectivePermissions = async ({ userId, resourceType, resourceId }) => {
try {
// Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals(userId);
if (principals.length === 0) {
return 0;
}
return await getEffectivePermissionsACL(principals, resourceType, resourceId);
} catch (error) {
logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`);
return 0;
}
};
/**
* Find all resources of a specific type that a user has access to with specific permission bits
* @param {Object} params - Parameters for finding accessible resources
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<Array>} Array of resource IDs
*/
const findAccessibleResources = async ({ userId, resourceType, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Get all principals for the user (user + groups + public)
const principalsList = await getUserPrincipals(userId);
if (principalsList.length === 0) {
return [];
}
return await findAccessibleResourcesACL(principalsList, resourceType, requiredPermissions);
} catch (error) {
logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return [];
}
};
/**
* Find all publicly accessible resources of a specific type
* @param {Object} params - Parameters for finding publicly accessible resources
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<Array>} Array of resource IDs
*/
const findPubliclyAccessibleResources = async ({ resourceType, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Find all public ACL entries where the public principal has at least the required permission bits
const entries = await AclEntry.find({
principalType: 'public',
resourceType,
permBits: { $bitsAllSet: requiredPermissions },
}).distinct('resourceId');
return entries;
} catch (error) {
logger.error(`[PermissionService.findPubliclyAccessibleResources] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return [];
}
};
/**
* Get available roles for a resource type
* @param {Object} params - Parameters for getting available roles
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @returns {Promise<Array>} Array of role definitions
*/
const getAvailableRoles = async ({ resourceType }) => {
try {
return await AccessRole.find({ resourceType }).lean();
} catch (error) {
logger.error(`[PermissionService.getAvailableRoles] Error: ${error.message}`);
return [];
}
};
/**
* Ensures a principal exists in the database based on TPrincipal data
* Creates user if it doesn't exist locally (for Entra ID users)
* @param {Object} principal - TPrincipal object from frontend
* @param {string} principal.type - 'user', 'group', or 'public'
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
* @param {string} principal.name - Display name
* @param {string} [principal.email] - Email address
* @param {string} [principal.source] - 'local' or 'entra'
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
* @returns {Promise<string|null>} Returns the principalId for database operations, null for public
*/
const ensurePrincipalExists = async function (principal) {
if (principal.type === 'public') {
return null;
}
if (principal.id) {
return principal.id;
}
if (principal.type === 'user' && principal.source === 'entra') {
if (!principal.email || !principal.idOnTheSource) {
throw new Error('Entra ID user principals must have email and idOnTheSource');
}
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
if (!existingUser) {
existingUser = await findUser({ email: principal.email.toLowerCase() });
}
if (existingUser) {
if (!existingUser.idOnTheSource && principal.idOnTheSource) {
await updateUser(existingUser._id, {
idOnTheSource: principal.idOnTheSource,
provider: 'openid',
});
}
return existingUser._id.toString();
}
const userData = {
name: principal.name,
email: principal.email.toLowerCase(),
emailVerified: false,
provider: 'openid',
idOnTheSource: principal.idOnTheSource,
};
const userId = await createUser(userData, true, false);
return userId.toString();
}
if (principal.type === 'group') {
throw new Error('Group principals should be handled by group-specific methods');
}
throw new Error(`Unsupported principal type: ${principal.type}`);
};
/**
* Ensures a group principal exists in the database based on TPrincipal data
* Creates group if it doesn't exist locally (for Entra ID groups)
* For Entra ID groups, always synchronizes member IDs when authentication context is provided
* @param {Object} principal - TPrincipal object from frontend
* @param {string} principal.type - Must be 'group'
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
* @param {string} principal.name - Display name
* @param {string} [principal.email] - Email address
* @param {string} [principal.description] - Group description
* @param {string} [principal.source] - 'local' or 'entra'
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
* @param {Object} [authContext] - Optional authentication context for fetching member data
* @param {string} [authContext.accessToken] - Access token for Graph API calls
* @param {string} [authContext.sub] - Subject identifier
* @returns {Promise<string>} Returns the groupId for database operations
*/
const ensureGroupPrincipalExists = async function (principal, authContext = null) {
if (principal.type !== 'group') {
throw new Error(`Invalid principal type: ${principal.type}. Expected 'group'`);
}
if (principal.source === 'entra') {
if (!principal.name || !principal.idOnTheSource) {
throw new Error('Entra ID group principals must have name and idOnTheSource');
}
let memberIds = [];
if (authContext && authContext.accessToken && authContext.sub) {
try {
memberIds = await getGroupMembers(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
// Include group owners as members if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownerIds = await getGroupOwners(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
if (ownerIds && ownerIds.length > 0) {
memberIds.push(...ownerIds);
// Remove duplicates
memberIds = [...new Set(memberIds)];
}
}
} catch (error) {
logger.error('Failed to fetch group members from Graph API:', error);
}
}
let existingGroup = await findGroupByExternalId(principal.idOnTheSource, 'entra');
if (!existingGroup && principal.email) {
existingGroup = await Group.findOne({ email: principal.email.toLowerCase() }).lean();
}
if (existingGroup) {
const updateData = {};
let needsUpdate = false;
if (!existingGroup.idOnTheSource && principal.idOnTheSource) {
updateData.idOnTheSource = principal.idOnTheSource;
updateData.source = 'entra';
needsUpdate = true;
}
if (principal.description && existingGroup.description !== principal.description) {
updateData.description = principal.description;
needsUpdate = true;
}
if (principal.email && existingGroup.email !== principal.email.toLowerCase()) {
updateData.email = principal.email.toLowerCase();
needsUpdate = true;
}
if (authContext && authContext.accessToken && authContext.sub) {
updateData.memberIds = memberIds;
needsUpdate = true;
}
if (needsUpdate) {
await Group.findByIdAndUpdate(existingGroup._id, { $set: updateData }, { new: true });
}
return existingGroup._id.toString();
}
const groupData = {
name: principal.name,
source: 'entra',
idOnTheSource: principal.idOnTheSource,
memberIds: memberIds, // Store idOnTheSource values of group members (empty if no auth context)
};
if (principal.email) {
groupData.email = principal.email.toLowerCase();
}
if (principal.description) {
groupData.description = principal.description;
}
const newGroup = await createGroup(groupData);
return newGroup._id.toString();
}
if (principal.id && authContext == null) {
return principal.id;
}
throw new Error(`Unsupported group principal source: ${principal.source}`);
};
/**
* Synchronize user's Entra ID group memberships on sign-in
* Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database
* Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled
* @param {Object} user - User object with authentication context
* @param {string} user.openidId - User's OpenID subject identifier
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
* @param {string} user.provider - Authentication provider ('openid')
* @param {string} accessToken - Access token for Graph API calls
* @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions
* @returns {Promise<void>}
*/
const syncUserEntraGroupMemberships = async (user, accessToken, session = null) => {
try {
if (!entraIdPrincipalFeatureEnabled(user) || !accessToken || !user.idOnTheSource) {
return;
}
const memberGroupIds = await getUserEntraGroups(accessToken, user.openidId);
let allGroupIds = [...(memberGroupIds || [])];
// Include owned groups if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId);
if (ownedGroupIds && ownedGroupIds.length > 0) {
allGroupIds.push(...ownedGroupIds);
// Remove duplicates
allGroupIds = [...new Set(allGroupIds)];
}
}
if (!allGroupIds || allGroupIds.length === 0) {
return;
}
const sessionOptions = session ? { session } : {};
await Group.updateMany(
{
idOnTheSource: { $in: allGroupIds },
source: 'entra',
memberIds: { $ne: user.idOnTheSource },
},
{ $addToSet: { memberIds: user.idOnTheSource } },
sessionOptions,
);
await Group.updateMany(
{
source: 'entra',
memberIds: user.idOnTheSource,
idOnTheSource: { $nin: allGroupIds },
},
{ $pull: { memberIds: user.idOnTheSource } },
sessionOptions,
);
} catch (error) {
logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error);
}
};
/**
* Check if public has a specific permission on a resource
* @param {Object} params - Parameters for checking public permission
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<boolean>} Whether public has the required permission bits
*/
const hasPublicPermission = async ({ resourceType, resourceId, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Use public principal to check permissions
const publicPrincipal = [{ principalType: 'public' }];
const entries = await findEntriesByPrincipalsAndResource(
publicPrincipal,
resourceType,
resourceId,
);
// Check if any entry has the required permission bits
return entries.some((entry) => (entry.permBits & requiredPermissions) === requiredPermissions);
} catch (error) {
logger.error(`[PermissionService.hasPublicPermission] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return false;
}
};
/**
* Bulk update permissions for a resource (grant, update, revoke)
* Efficiently handles multiple permission changes in a single transaction
*
* @param {Object} params - Parameters for bulk permission update
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {Array<TPrincipal>} params.updatedPrincipals - Array of principals to grant/update permissions for
* @param {Array<TPrincipal>} params.revokedPrincipals - Array of principals to revoke permissions from
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID making the changes
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
* @returns {Promise<Object>} Results object with granted, updated, revoked arrays and error details
*/
const bulkUpdateResourcePermissions = async ({
resourceType,
resourceId,
updatedPrincipals = [],
revokedPrincipals = [],
grantedBy,
session,
}) => {
const supportsTransactions = await getTransactionSupport(mongoose, transactionSupportCache);
transactionSupportCache = supportsTransactions;
let localSession = session;
let shouldEndSession = false;
try {
if (!Array.isArray(updatedPrincipals)) {
throw new Error('updatedPrincipals must be an array');
}
if (!Array.isArray(revokedPrincipals)) {
throw new Error('revokedPrincipals must be an array');
}
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
throw new Error(`Invalid resource ID: ${resourceId}`);
}
if (!localSession && supportsTransactions) {
localSession = await mongoose.startSession();
localSession.startTransaction();
shouldEndSession = true;
}
const sessionOptions = localSession ? { session: localSession } : {};
const roles = await AccessRole.find({ resourceType }).lean();
const rolesMap = new Map();
roles.forEach((role) => {
rolesMap.set(role.accessRoleId, role);
});
const results = {
granted: [],
updated: [],
revoked: [],
errors: [],
};
const bulkWrites = [];
for (const principal of updatedPrincipals) {
try {
if (!principal.accessRoleId) {
results.errors.push({
principal,
error: 'accessRoleId is required for updated principals',
});
continue;
}
const role = rolesMap.get(principal.accessRoleId);
if (!role) {
results.errors.push({
principal,
error: `Role ${principal.accessRoleId} not found`,
});
continue;
}
const query = {
principalType: principal.type,
resourceType,
resourceId,
};
if (principal.type !== 'public') {
query.principalId = principal.id;
}
const update = {
$set: {
permBits: role.permBits,
roleId: role._id,
grantedBy,
grantedAt: new Date(),
},
$setOnInsert: {
principalType: principal.type,
resourceType,
resourceId,
...(principal.type !== 'public' && {
principalId: principal.id,
principalModel: principal.type === 'user' ? 'User' : 'Group',
}),
},
};
bulkWrites.push({
updateOne: {
filter: query,
update: update,
upsert: true,
},
});
results.granted.push({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
idOnTheSource: principal.idOnTheSource,
accessRoleId: principal.accessRoleId,
memberCount: principal.memberCount,
memberIds: principal.memberIds,
});
} catch (error) {
results.errors.push({
principal,
error: error.message,
});
}
}
if (bulkWrites.length > 0) {
await AclEntry.bulkWrite(bulkWrites, sessionOptions);
}
const deleteQueries = [];
for (const principal of revokedPrincipals) {
try {
const query = {
principalType: principal.type,
resourceType,
resourceId,
};
if (principal.type !== 'public') {
query.principalId = principal.id;
}
deleteQueries.push(query);
results.revoked.push({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
idOnTheSource: principal.idOnTheSource,
memberCount: principal.memberCount,
});
} catch (error) {
results.errors.push({
principal,
error: error.message,
});
}
}
if (deleteQueries.length > 0) {
await AclEntry.deleteMany(
{
$or: deleteQueries,
},
sessionOptions,
);
}
if (shouldEndSession && supportsTransactions) {
await localSession.commitTransaction();
}
return results;
} catch (error) {
if (shouldEndSession && supportsTransactions) {
await localSession.abortTransaction();
}
logger.error(`[PermissionService.bulkUpdateResourcePermissions] Error: ${error.message}`);
throw error;
} finally {
if (shouldEndSession && localSession) {
localSession.endSession();
}
}
};
module.exports = {
grantPermission,
checkPermission,
getEffectivePermissions,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
getAvailableRoles,
bulkUpdateResourcePermissions,
ensurePrincipalExists,
ensureGroupPrincipalExists,
syncUserEntraGroupMemberships,
};

File diff suppressed because it is too large Load Diff

View File

@@ -365,6 +365,7 @@ async function setupOpenId() {
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
};
const balanceConfig = await getBalanceConfig();
@@ -375,6 +376,7 @@ async function setupOpenId() {
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {

View File

@@ -4,6 +4,7 @@
"description": "",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"data-provider": "cd .. && npm run build:data-provider",
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",

View File

@@ -1,5 +1,10 @@
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import type { Agent, AgentProvider, AgentModelParameters } from 'librechat-data-provider';
import type {
Agent,
AgentProvider,
AgentModelParameters,
SupportContact,
} from 'librechat-data-provider';
import type { OptionWithIcon, ExtendedFile } from './types';
export type TAgentOption = OptionWithIcon &
@@ -7,6 +12,7 @@ export type TAgentOption = OptionWithIcon &
knowledge_files?: Array<[string, ExtendedFile]>;
context_files?: Array<[string, ExtendedFile]>;
code_files?: Array<[string, ExtendedFile]>;
_id?: string;
};
export type TAgentCapabilities = {
@@ -30,4 +36,6 @@ export type AgentForm = {
agent_ids?: string[];
[AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number;
support_contact?: SupportContact;
category: string;
} & TAgentCapabilities;

View File

@@ -16,6 +16,7 @@ const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],

View File

@@ -17,7 +17,7 @@ export default function OpenSidebar({
variant="outline"
data-testid="open-sidebar-button"
aria-label={localize('com_nav_open_sidebar')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));

View File

@@ -1,13 +1,13 @@
import React, { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import React, { useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { TooltipAnchor, Button } from '~/components/ui';
import { useLocalize, useNewConvo } from '~/hooks';
import { useLocalize, useNewConvo, useHasAccess } from '~/hooks';
import { AuthContext } from '~/hooks/AuthContext';
import { LayoutGrid } from 'lucide-react';
import store from '~/store';
export default function NewChat({
@@ -29,6 +29,11 @@ export default function NewChat({
const navigate = useNavigate();
const localize = useLocalize();
const { conversation } = store.useCreateConversationAtom(index);
const authContext = useContext(AuthContext);
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
@@ -50,6 +55,22 @@ export default function NewChat({
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
);
const handleAgentMarketplace = useCallback(() => {
navigate('/agents');
if (isSmallScreen) {
toggleNav();
}
}, [navigate, isSmallScreen, toggleNav]);
// Check if auth is ready (avoid race conditions)
const authReady =
authContext?.isAuthenticated !== undefined &&
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
// Show agent marketplace when auth is ready and user has access
// Note: endpointsConfig[agents] is null, but we can still show the marketplace
const showAgentMarketplace = authReady && hasAccessToAgents;
return (
<>
<div className="flex items-center justify-between py-[2px] md:py-2">
@@ -88,6 +109,29 @@ export default function NewChat({
/>
</div>
</div>
{/* Agent Marketplace button - separate row like ChatGPT */}
{showAgentMarketplace && (
<div className="flex px-2 pb-4 pt-2 md:px-3">
<TooltipAnchor
description={localize('com_nav_agents_marketplace')}
render={
<Button
variant="outline"
data-testid="nav-agents-marketplace-button"
aria-label={localize('com_nav_agents_marketplace')}
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
onClick={handleAgentMarketplace}
>
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-base font-medium">
{localize('com_nav_agents_marketplace')}
</span>
</Button>
}
/>
</div>
)}
{subHeaders != null ? subHeaders : null}
</>
);

View File

@@ -9,6 +9,10 @@ import {
PlaneTakeoffIcon,
GraduationCapIcon,
TerminalSquareIcon,
// NEW: Add these for agent categories
Users as UsersIcon,
Beaker as BeakerIcon,
Settings as SettingsIcon,
} from 'lucide-react';
import { cn } from '~/utils';
@@ -22,6 +26,13 @@ const categoryIconMap: Record<string, React.ElementType> = {
code: TerminalSquareIcon,
travel: PlaneTakeoffIcon,
teach_or_explain: GraduationCapIcon,
// NEW: Agent categories
general: BoxIcon,
hr: UsersIcon,
rd: BeakerIcon,
it: TerminalSquareIcon,
sales: LineChartIcon,
aftersales: SettingsIcon,
};
const categoryColorMap: Record<string, string> = {
@@ -34,6 +45,13 @@ const categoryColorMap: Record<string, string> = {
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
// NEW: Agent categories
general: 'text-blue-500',
hr: 'text-green-500',
rd: 'text-purple-500',
it: 'text-red-500',
sales: 'text-orange-500',
aftersales: 'text-yellow-500',
};
export default function CategoryIcon({

View File

@@ -14,7 +14,12 @@ import type {
AgentCreateParams,
AgentListResponse,
} from 'librechat-data-provider';
import { useUploadAgentAvatarMutation, useGetFileConfig } from '~/data-provider';
import {
useUploadAgentAvatarMutation,
useGetFileConfig,
allAgentViewAndEditQueryKeys,
invalidateAgentMarketplaceQueries,
} from '~/data-provider';
import { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
@@ -57,30 +62,31 @@ function Avatar({
const newUrl = data.avatar?.filepath ?? '';
setPreviewUrl(newUrl);
const res = queryClient.getQueryData<AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
((keys) => {
keys.forEach((key) => {
const res = queryClient.getQueryData<AgentListResponse>([QueryKeys.agents, key]);
if (!res?.data) {
return;
}
if (!res?.data) {
return;
}
const agents = res.data.map((agent) => {
if (agent.id === agent_id) {
return {
...agent,
...data,
};
}
return agent;
});
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...res,
data: agents,
});
const agents = res.data.map((agent) => {
if (agent.id === agent_id) {
return {
...agent,
...data,
};
}
return agent;
});
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, key], {
...res,
data: agents,
});
});
})(allAgentViewAndEditQueryKeys);
invalidateAgentMarketplaceQueries(queryClient);
setProgress(1);
},
onError: (error) => {

View File

@@ -0,0 +1,93 @@
import React from 'react';
import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import { renderAgentAvatar, getContactDisplayName } from '~/utils/agents';
import { cn } from '~/utils';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
onClick: () => void; // Callback when card is clicked
className?: string; // Additional CSS classes
}
/**
* Card component to display agent information
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
return (
<div
className={cn(
'group relative flex overflow-hidden rounded-2xl',
'cursor-pointer transition-colors duration-200',
'aspect-[5/2.5] w-full',
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
className,
)}
onClick={onClick}
aria-label={localize('com_agents_agent_card_label', {
name: agent.name,
description: agent.description || localize('com_agents_no_description'),
})}
aria-describedby={`agent-${agent.id}-description`}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
{/* Agent avatar section - left side, responsive */}
<div className="flex flex-shrink-0 items-center">
{renderAgentAvatar(agent, { size: 'md' })}
</div>
{/* Agent info section - right side, responsive */}
<div className="flex min-w-0 flex-1 flex-col justify-center">
{/* Agent name - responsive text sizing */}
<h3 className="mb-1 line-clamp-1 text-base font-bold text-gray-900 dark:text-white sm:mb-2 sm:text-lg">
{agent.name}
</h3>
{/* Agent description - responsive text sizing and spacing */}
<p
id={`agent-${agent.id}-description`}
className={cn(
'mb-1 line-clamp-2 text-xs leading-relaxed text-gray-600 dark:text-gray-300',
'sm:mb-2 sm:text-sm',
)}
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
>
{agent.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
)}
</p>
{/* Owner info - responsive text sizing */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
<span className="font-light">{localize('com_agents_created_by')}</span>
<span className="ml-1 font-bold">{displayName}</span>
</div>
);
}
return null;
})()}
</div>
</div>
</div>
);
};
export default AgentCard;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
interface AgentCategoryDisplayProps {
category?: string;
className?: string;
showIcon?: boolean;
iconClassName?: string;
showEmptyFallback?: boolean;
}
/**
* Component to display an agent category with proper translation
*
* @param category - The category value (e.g., "general", "hr", etc.)
* @param className - Optional className for the container
* @param showIcon - Whether to show the category icon
* @param iconClassName - Optional className for the icon
* @param showEmptyFallback - Whether to show a fallback for empty categories
*/
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
category,
className = '',
showIcon = true,
iconClassName = 'h-4 w-4 mr-2',
showEmptyFallback = false,
}) => {
const { categories, emptyCategory } = useAgentCategories();
// Find the category in our processed categories list
const categoryItem = categories.find((c) => c.value === category);
// Handle empty string case differently than undefined/null
if (category === '') {
if (!showEmptyFallback) {
return null;
}
// Show the empty category placeholder
return (
<div className={cn('flex items-center text-gray-400', className)}>
<span>{emptyCategory.label}</span>
</div>
);
}
// No category or unknown category
if (!category || !categoryItem) {
return null;
}
return (
<div className={cn('flex items-center', className)}>
{showIcon && categoryItem.icon && (
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
)}
<span>{categoryItem.label}</span>
</div>
);
};
export default AgentCategoryDisplay;

View File

@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useFormContext,
Controller,
useWatch,
ControllerRenderProps,
FieldValues,
FieldPath,
} from 'react-hook-form';
import ControlCombobox from '~/components/ui/ControlCombobox';
import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
/**
* Custom hook to handle category synchronization
*/
const useCategorySync = (agent_id: string | null) => {
const [handled, setHandled] = useState(false);
return {
syncCategory: <T extends FieldPath<FieldValues>>(
field: ControllerRenderProps<FieldValues, T>,
) => {
// Only run once and only for new agents
if (!handled && agent_id === '' && !field.value) {
field.onChange('general');
setHandled(true);
}
},
};
};
/**
* A component for selecting agent categories with form validation
*/
const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation();
const formContext = useFormContext();
const { categories } = useAgentCategories();
// Always call useWatch
const agent_id = useWatch({
name: 'id',
control: formContext.control,
});
// Use custom hook for category sync
const { syncCategory } = useCategorySync(agent_id);
// Transform categories to the format expected by ControlCombobox
const comboboxItems = categories.map((category) => ({
label: category.label,
value: category.value,
}));
const getCategoryDisplayValue = (value: string) => {
const categoryItem = comboboxItems.find((c) => c.value === value);
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
};
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
return (
<Controller
name="category"
control={formContext.control}
defaultValue="general"
render={({ field }) => {
// Sync category if needed (without using useEffect in render)
syncCategory(field);
const displayValue = getCategoryDisplayValue(field.value);
return (
<ControlCombobox
selectedValue={field.value}
displayValue={displayValue}
searchPlaceholder={searchPlaceholder}
setValue={(value) => {
field.onChange(value);
}}
items={comboboxItems}
className={cn(className)}
ariaLabel={ariaLabel}
isCollapsed={false}
showCarat={true}
/>
);
}}
/>
);
};
export default AgentCategorySelector;

View File

@@ -17,6 +17,7 @@ import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import AgentCategorySelector from './AgentCategorySelector';
import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
@@ -38,7 +39,10 @@ export default function AgentConfig({
const [showToolDialog, setShowToolDialog] = useState(false);
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
const { control } = methods;
const {
control,
formState: { errors },
} = methods;
const provider = useWatch({ control, name: 'provider' });
const model = useWatch({ control, name: 'model' });
const agent = useWatch({ control, name: 'agent' });
@@ -189,21 +193,33 @@ export default function AgentConfig({
/>
<label className={labelClass} htmlFor="name">
{localize('com_ui_name')}
<span className="text-red-500">*</span>
</label>
<Controller
name="name"
rules={{ required: localize('com_ui_agent_name_is_required') }}
control={control}
render={({ field }) => (
<input
{...field}
value={field.value ?? ''}
maxLength={256}
className={inputClass}
id="name"
type="text"
placeholder={localize('com_agents_name_placeholder')}
aria-label="Agent name"
/>
<>
<input
{...field}
value={field.value ?? ''}
maxLength={256}
className={inputClass}
id="name"
type="text"
placeholder={localize('com_agents_name_placeholder')}
aria-label="Agent name"
/>
<div
className={cn(
'mt-1 w-56 text-sm text-red-500',
errors.name ? 'visible h-auto' : 'invisible h-0',
)}
>
{errors.name ? errors.name.message : ' '}
</div>
</>
)}
/>
<Controller
@@ -238,6 +254,13 @@ export default function AgentConfig({
)}
/>
</div>
{/* Category */}
<div className="mb-4">
<label className={labelClass} htmlFor="category-selector">
{localize('com_ui_category')} <span className="text-red-500">*</span>
</label>
<AgentCategorySelector className="w-full" />
</div>
{/* Instructions */}
<Instructions />
{/* Model and Provider */}
@@ -357,6 +380,93 @@ export default function AgentConfig({
</div>
{/* MCP Section */}
{/* <MCPSection /> */}
{/* Support Contact (Optional) */}
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-2">
<span>
<label className="text-token-text-primary block font-medium">
{localize('com_ui_support_contact')}
</label>
</span>
</div>
<div className="space-y-3">
{/* Support Contact Name */}
<div className="flex flex-col">
<label
className="mb-1 flex items-center justify-between"
htmlFor="support-contact-name"
>
<span className="text-sm">{localize('com_ui_support_contact_name')}</span>
</label>
<Controller
name="support_contact.name"
control={control}
rules={{
minLength: {
value: 3,
message: localize('com_ui_support_contact_name_min_length', { minLength: 3 }),
},
}}
render={({ field, fieldState: { error } }) => (
<>
<input
{...field}
value={field.value ?? ''}
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
id="support-contact-name"
type="text"
placeholder={localize('com_ui_support_contact_name_placeholder')}
aria-label="Support contact name"
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
{error.message}
</span>
)}
</>
)}
/>
</div>
{/* Support Contact Email */}
<div className="flex flex-col">
<label
className="mb-1 flex items-center justify-between"
htmlFor="support-contact-email"
>
<span className="text-sm">{localize('com_ui_support_contact_email')}</span>
</label>
<Controller
name="support_contact.email"
control={control}
rules={{
pattern: {
value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
message: localize('com_ui_support_contact_email_invalid'),
},
}}
render={({ field, fieldState: { error } }) => (
<>
<input
{...field}
value={field.value ?? ''}
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
id="support-contact-email"
type="email"
placeholder={localize('com_ui_support_contact_email_placeholder')}
aria-label="Support contact email"
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
{error.message}
</span>
)}
</>
)}
/>
</div>
</div>
</div>
</div>
<ToolSelectDialog
isOpen={showToolDialog}

View File

@@ -0,0 +1,197 @@
import React, { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type t from 'librechat-data-provider';
import { AgentListResponse, PERMISSION_BITS, QueryKeys } from 'librechat-data-provider';
interface SupportContact {
name?: string;
email?: string;
}
interface AgentWithSupport extends t.Agent {
support_contact?: SupportContact;
}
import { useQueryClient } from '@tanstack/react-query';
import { Dialog, DialogContent, Button } from '~/components/ui';
import { renderAgentAvatar } from '~/utils/agents';
import useLocalize from '~/hooks/useLocalize';
import { DotsIcon } from '~/components/svg';
import { useToast } from '~/hooks';
interface AgentDetailProps {
agent: AgentWithSupport; // The agent data to display
isOpen: boolean; // Whether the detail dialog is open
onClose: () => void; // Callback when dialog is closed
}
/**
* Dialog for displaying agent details
*/
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
const localize = useLocalize();
const navigate = useNavigate();
const { showToast } = useToast();
const dialogRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const queryClient = useQueryClient();
// Close dropdown when clicking outside the dropdown menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
/**
* Navigate to chat with the selected agent
*/
const handleStartChat = () => {
if (agent) {
const keys = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }];
const listResp = queryClient.getQueryData<AgentListResponse>(keys);
if (listResp != null) {
if (!listResp.data.some((a) => a.id === agent.id)) {
const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))];
queryClient.setQueryData<AgentListResponse>(keys, { ...listResp, data: currentAgents });
}
}
navigate(`/c/new?agent_id=${agent.id}`);
}
};
/**
* Copy the agent's shareable link to clipboard
*/
const handleCopyLink = () => {
const baseUrl = new URL(window.location.origin);
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
navigator.clipboard
.writeText(chatUrl)
.then(() => {
showToast({
message: localize('com_agents_link_copied'),
});
})
.catch(() => {
showToast({
message: localize('com_agents_link_copy_failed'),
});
});
};
/**
* Format contact information with mailto links when appropriate
*/
const formatContact = () => {
if (!agent?.support_contact) return null;
const { name, email } = agent.support_contact;
if (name && email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{name}
</a>
);
}
if (email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{email}
</a>
);
}
if (name) {
return <span>{name}</span>;
}
return null;
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent ref={dialogRef} className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]">
{/* Context menu - top right */}
<div ref={dropdownRef} className="absolute right-12 top-5 z-50">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-text-secondary hover:bg-surface-hover hover:text-text-primary dark:hover:bg-surface-hover"
aria-label={localize('com_agents_more_options')}
aria-expanded={dropdownOpen}
aria-haspopup="menu"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(!dropdownOpen);
}}
>
<DotsIcon className="h-4 w-4" />
</Button>
{/* Simple dropdown menu */}
{dropdownOpen && (
<div className="absolute right-0 top-10 z-[9999] w-48 rounded-xl border border-border-light bg-surface-primary py-1 shadow-lg dark:bg-surface-secondary dark:shadow-2xl">
<button
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(false);
handleCopyLink();
}}
className="w-full px-3 py-2 text-left text-sm text-text-primary transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
{localize('com_agents_copy_link')}
</button>
</div>
)}
</div>
{/* Agent avatar - top center */}
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
{/* Agent name - center aligned below image */}
<div className="mt-3 text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{agent?.name || localize('com_agents_loading')}
</h2>
</div>
{/* Contact info - center aligned below name */}
{agent?.support_contact && formatContact() && (
<div className="mt-1 text-center text-sm text-gray-600 dark:text-gray-400">
{localize('com_agents_contact')}: {formatContact()}
</div>
)}
{/* Agent description - below contact */}
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-gray-700 dark:text-gray-300">
{agent?.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
)}
</div>
{/* Action button */}
<div className="mb-4 mt-6 flex justify-center">
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
{localize('com_agents_start_chat')}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AgentDetail;

View File

@@ -1,16 +1,21 @@
import { useWatch, useFormContext } from 'react-hook-form';
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
import {
SystemRoles,
Permissions,
PermissionTypes,
PERMISSION_BITS,
} from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps } from '~/common';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
import GrantAccessDialog from './Sharing/GrantAccessDialog';
import { useUpdateAgentMutation } from '~/data-provider';
import AdvancedButton from './Advanced/AdvancedButton';
import VersionButton from './Version/VersionButton';
import DuplicateAgent from './DuplicateAgent';
import AdminSettings from './AdminSettings';
import DeleteButton from './DeleteButton';
import { Spinner } from '~/components';
import ShareAgent from './ShareAgent';
import { Panel } from '~/common';
import VersionButton from './Version/VersionButton';
export default function AgentFooter({
activePanel,
@@ -32,12 +37,17 @@ export default function AgentFooter({
const { control } = methods;
const agent = useWatch({ control, name: 'agent' });
const agent_id = useWatch({ control, name: 'id' });
const hasAccessToShareAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.SHARED_GLOBAL,
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'agent',
agent?._id || '',
);
const canShareThisAgent = hasPermission(PERMISSION_BITS.SHARE);
const canDeleteThisAgent = hasPermission(PERMISSION_BITS.DELETE);
const renderSaveButton = () => {
if (createMutation.isLoading || updateMutation.isLoading) {
return <Spinner className="icon-md" aria-hidden="true" />;
@@ -59,18 +69,21 @@ export default function AgentFooter({
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
{/* Context Button */}
<div className="flex items-center justify-end gap-2">
<DeleteButton
agent_id={agent_id}
setCurrentAgentId={setCurrentAgentId}
createMutation={createMutation}
/>
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
hasAccessToShareAgents && (
<ShareAgent
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canDeleteThisAgent) &&
!permissionsLoading && (
<DeleteButton
agent_id={agent_id}
setCurrentAgentId={setCurrentAgentId}
createMutation={createMutation}
/>
)}
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisAgent) &&
hasAccessToShareAgents &&
!permissionsLoading && (
<GrantAccessDialog
agentDbId={agent?._id}
agentId={agent_id}
agentName={agent?.name ?? ''}
projectIds={agent?.projectIds ?? []}
isCollaborative={agent?.isCollaborative}
/>
)}
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}

View File

@@ -0,0 +1,288 @@
import React, { useMemo } from 'react';
import type t from 'librechat-data-provider';
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories } from '~/hooks/Agents';
import useLocalize from '~/hooks/useLocalize';
import { Button } from '~/components/ui';
import { Spinner } from '~/components/svg';
import { useHasData } from './SmartLoader';
import ErrorDisplay from './ErrorDisplay';
import AgentCard from './AgentCard';
import { cn } from '~/utils';
import { PERMISSION_BITS } from 'librechat-data-provider';
interface AgentGridProps {
category: string; // Currently selected category
searchQuery: string; // Current search query
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
}
/**
* Component for displaying a grid of agent cards
*/
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
const localize = useLocalize();
// Get category data from API
const { categories } = useAgentCategories();
// Build query parameters based on current state
const queryParams = useMemo(() => {
const params: {
requiredPermission: number;
category?: string;
search?: string;
limit: number;
promoted?: 0 | 1;
} = {
requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing
limit: 6,
};
// Handle search
if (searchQuery) {
params.search = searchQuery;
// Include category filter for search if it's not 'all' or 'promoted'
if (category !== 'all' && category !== 'promoted') {
params.category = category;
}
} else {
// Handle category-based queries
if (category === 'promoted') {
params.promoted = 1;
} else if (category !== 'all') {
params.category = category;
}
// For 'all' category, no additional filters needed
}
return params;
}, [category, searchQuery]);
// Use infinite query for marketplace agents
const {
data,
isLoading,
error,
isFetching,
fetchNextPage,
hasNextPage,
refetch,
isFetchingNextPage,
} = useMarketplaceAgentsInfiniteQuery(queryParams);
// Flatten all pages into a single array of agents
const currentAgents = useMemo(() => {
if (!data?.pages) return [];
return data.pages.flatMap((page) => page.data || []);
}, [data?.pages]);
// Check if we have meaningful data to prevent unnecessary loading states
const hasData = useHasData(data?.pages?.[0]);
/**
* Get category display name from API data or use fallback
*/
const getCategoryDisplayName = (categoryValue: string) => {
const categoryData = categories.find((cat) => cat.value === categoryValue);
if (categoryData) {
return categoryData.label;
}
// Fallback for special categories or unknown categories
if (categoryValue === 'promoted') {
return localize('com_agents_top_picks');
}
if (categoryValue === 'all') {
return 'All';
}
// Simple capitalization for unknown categories
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
};
/**
* Load more agents when "See More" button is clicked
*/
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
/**
* Get the appropriate title for the agents grid based on current state
*/
const getGridTitle = () => {
if (searchQuery) {
return localize('com_agents_results_for', { query: searchQuery });
}
return getCategoryDisplayName(category);
};
// Loading skeleton component
const loadingSkeleton = (
<div className="space-y-6">
<div className="mb-4">
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div>
<div className="h-4 w-64 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{Array(6)
.fill(0)
.map((_, index) => (
<div
key={index}
className={cn(
'flex h-[250px] animate-pulse flex-col overflow-hidden rounded-lg',
'bg-gray-200 dark:bg-gray-800',
)}
>
<div className="h-40 bg-gray-300 dark:bg-gray-700"></div>
<div className="flex-1 p-5">
<div className="mb-3 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-700"></div>
<div className="mb-2 h-3 w-full rounded bg-gray-300 dark:bg-gray-700"></div>
<div className="h-3 w-2/3 rounded bg-gray-300 dark:bg-gray-700"></div>
</div>
</div>
))}
</div>
</div>
);
// Handle error state with enhanced error display
if (error) {
return (
<ErrorDisplay
error={error || 'Unknown error occurred'}
onRetry={() => refetch()}
context={{
searchQuery,
category,
}}
/>
);
}
// Main content component with proper semantic structure
const mainContent = (
<div
className="space-y-6"
role="tabpanel"
id={`category-panel-${category}`}
aria-labelledby={`category-tab-${category}`}
aria-live="polite"
aria-busy={isLoading && !hasData}
>
{/* Grid title - only show for search results */}
{searchQuery && (
<div className="mb-4">
<h2
className="text-xl font-bold text-gray-900 dark:text-white"
id={`category-heading-${category}`}
aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
>
{getGridTitle()}
</h2>
</div>
)}
{/* Handle empty results with enhanced accessibility */}
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
<div
className="py-12 text-center text-gray-500"
role="status"
aria-live="polite"
aria-label={
searchQuery
? localize('com_agents_search_empty_heading')
: localize('com_agents_empty_state_heading')
}
>
<h3 className="mb-2 text-lg font-medium">
{searchQuery
? localize('com_agents_search_empty_heading')
: localize('com_agents_empty_state_heading')}
</h3>
<p className="text-sm">
{searchQuery
? localize('com_agents_no_results')
: localize('com_agents_none_in_category')}
</p>
</div>
) : (
<>
{/* Announcement for screen readers */}
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
{localize('com_agents_grid_announcement', {
count: currentAgents?.length || 0,
category: getCategoryDisplayName(category),
})}
</div>
{/* Agent grid - 2 per row with proper semantic structure */}
{currentAgents && currentAgents.length > 0 && (
<div
className="grid grid-cols-1 gap-6 md:grid-cols-2"
role="grid"
aria-label={localize('com_agents_grid_announcement', {
count: currentAgents.length,
category: getCategoryDisplayName(category),
})}
>
{currentAgents.map((agent: t.Agent, index: number) => (
<div key={`${agent.id}-${index}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
</div>
))}
</div>
)}
{/* Loading indicator when fetching more with accessibility */}
{isFetching && hasNextPage && (
<div
className="flex justify-center py-4"
role="status"
aria-live="polite"
aria-label={localize('com_agents_loading')}
>
<Spinner className="h-6 w-6 text-primary" />
<span className="sr-only">{localize('com_agents_loading')}</span>
</div>
)}
{/* Load more button with enhanced accessibility */}
{hasNextPage && !isFetching && (
<div className="mt-8 flex justify-center">
<Button
variant="outline"
onClick={handleLoadMore}
className={cn(
'min-w-[160px] border-2 border-gray-300 bg-white px-6 py-3 font-medium text-gray-700',
'shadow-sm transition-all duration-200 hover:border-gray-400 hover:bg-gray-50',
'hover:shadow-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
'dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:focus:ring-blue-400',
)}
aria-label={localize('com_agents_load_more_label', {
category: getCategoryDisplayName(category),
})}
>
{localize('com_agents_see_more')}
</Button>
</div>
)}
</>
)}
</div>
);
if (isLoading || (isFetching && !isFetchingNextPage)) {
return loadingSkeleton;
}
return mainContent;
};
export default AgentGrid;

View File

@@ -0,0 +1,301 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import { useDocumentTitle } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
import { TooltipAnchor, Button } from '~/components/ui';
import { NewChatIcon } from '~/components/svg';
import { OpenSidebar } from '~/components/Chat/Menus';
import { SidePanelGroup } from '~/components/SidePanel';
import { MarketplaceProvider } from './MarketplaceContext';
import CategoryTabs from './CategoryTabs';
import AgentDetail from './AgentDetail';
import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid';
import store from '~/store';
interface AgentMarketplaceProps {
className?: string;
}
/**
* AgentMarketplace - Main component for browsing and discovering agents
*
* Provides tabbed navigation for different agent categories,
* search functionality, and detailed agent view through a modal dialog.
* Uses URL parameters for state persistence and deep linking.
*/
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
const localize = useLocalize();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { category } = useParams();
const setHideSidePanel = useSetRecoilState(store.hideSidePanel);
const hideSidePanel = useRecoilValue(store.hideSidePanel);
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
// Get URL parameters (default to 'promoted' instead of 'all')
const activeTab = category || 'promoted';
const searchQuery = searchParams.get('q') || '';
const selectedAgentId = searchParams.get('agent_id') || '';
// Local state
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
// Set page title
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
// Ensure right sidebar is always visible in marketplace
useEffect(() => {
setHideSidePanel(false);
// Also try to force expand via localStorage
localStorage.setItem('hideSidePanel', 'false');
localStorage.setItem('fullPanelCollapse', 'false');
}, [setHideSidePanel, hideSidePanel]);
// Ensure endpoints config is loaded first (required for agent queries)
useGetEndpointsQuery();
// Fetch categories using existing query pattern
const categoriesQuery = useGetAgentCategoriesQuery({
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
/**
* Handle agent card selection
*
* @param agent - The selected agent object
*/
const handleAgentSelect = (agent: t.Agent) => {
// Update URL with selected agent
const newParams = new URLSearchParams(searchParams);
newParams.set('agent_id', agent.id);
setSearchParams(newParams);
setSelectedAgent(agent);
setIsDetailOpen(true);
};
/**
* Handle closing the agent detail dialog
*/
const handleDetailClose = () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('agent_id');
setSearchParams(newParams);
setSelectedAgent(null);
setIsDetailOpen(false);
};
/**
* Handle category tab selection changes
*
* @param tabValue - The selected category value
*/
const handleTabChange = (tabValue: string) => {
const currentSearchParams = searchParams.toString();
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
// Navigate to the selected category
if (tabValue === 'promoted') {
navigate(`/agents${searchParamsStr}`);
} else {
navigate(`/agents/${tabValue}${searchParamsStr}`);
}
};
/**
* Handle search query changes
*
* @param query - The search query string
*/
const handleSearch = (query: string) => {
const newParams = new URLSearchParams(searchParams);
if (query.trim()) {
newParams.set('q', query.trim());
// Switch to "all" category when starting a new search
navigate(`/agents/all?${newParams.toString()}`);
} else {
newParams.delete('q');
// Preserve current category when clearing search
const currentCategory = activeTab;
if (currentCategory === 'promoted') {
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
} else {
navigate(
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
);
}
}
};
/**
* Handle new chat button click
*/
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
navigate('/c/new');
};
// Check if a detail view should be open based on URL
useEffect(() => {
setIsDetailOpen(!!selectedAgentId);
}, [selectedAgentId]);
// Layout configuration for SidePanelGroup
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
}, []);
const defaultCollapsed = useMemo(() => {
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
return (
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
<MarketplaceProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{/* Simplified header for agents marketplace - only show nav controls when needed */}
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="mx-1 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && (
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="agents-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />
</Button>
}
/>
)}
</div>
</div>
<div className="container mx-auto max-w-4xl px-4 py-8">
{/* Hero Section - ChatGPT Style */}
<div className="mb-8 mt-12 text-center">
<h1 className="mb-3 text-5xl font-bold tracking-tight text-gray-900 dark:text-white">
{localize('com_agents_marketplace')}
</h1>
<p className="mx-auto mb-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300">
{localize('com_agents_marketplace_subtitle')}
</p>
{/* Search bar */}
<div className="mx-auto max-w-2xl">
<SearchBar value={searchQuery} onSearch={handleSearch} />
</div>
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={activeTab}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (activeTab === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (activeTab === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === activeTab,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{name}</h2>
{description && (
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
category={activeTab}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
/>
</div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</main>
</SidePanelGroup>
</MarketplaceProvider>
</div>
);
};
export default AgentMarketplace;

View File

@@ -8,6 +8,7 @@ import {
SystemRoles,
EModelEndpoint,
TAgentsEndpoint,
PERMISSION_BITS,
TEndpointsConfig,
isAssistantsEndpoint,
} from 'librechat-data-provider';
@@ -16,8 +17,10 @@ import {
useCreateAgentMutation,
useUpdateAgentMutation,
useGetAgentByIdQuery,
useGetExpandedAgentByIdQuery,
} from '~/data-provider';
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
import { useResourcePermissions } from '~/hooks/useResourcePermissions';
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import AgentPanelSkeleton from './AgentPanelSkeleton';
@@ -50,10 +53,29 @@ export default function AgentPanel({
const { onSelect: onSelectAgent } = useSelectAgent();
const modelsQuery = useGetModelsQuery();
const agentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
// Basic agent query for initial permission check
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'agent',
basicAgentQuery.data?._id || '',
);
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
const expandedAgentQuery = useGetExpandedAgentByIdQuery(current_agent_id ?? '', {
enabled:
!!(current_agent_id ?? '') &&
current_agent_id !== Constants.EPHEMERAL_AGENT_ID &&
canEdit &&
!permissionsLoading,
});
const agentQuery = canEdit && expandedAgentQuery.data ? expandedAgentQuery : basicAgentQuery;
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
const methods = useForm<AgentForm>({
defaultValues: getDefaultAgentFormValues(),
@@ -183,6 +205,8 @@ export default function AgentPanel({
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
} = data;
const model = _model ?? '';
@@ -205,6 +229,8 @@ export default function AgentPanel({
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
},
});
return;
@@ -216,6 +242,12 @@ export default function AgentPanel({
status: 'error',
});
}
if (!name) {
return showToast({
message: localize('com_agents_missing_name'),
status: 'error',
});
}
create.mutate({
name,
@@ -230,6 +262,8 @@ export default function AgentPanel({
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
});
},
[agent_id, create, update, showToast, localize],
@@ -242,19 +276,16 @@ export default function AgentPanel({
}, [agent_id, onSelectAgent]);
const canEditAgent = useMemo(() => {
const canEdit =
(agentQuery.data?.isCollaborative ?? false)
? true
: agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN;
if (!agentQuery.data?.id) {
return true;
}
return agentQuery.data?.id != null && agentQuery.data.id ? canEdit : true;
}, [
agentQuery.data?.isCollaborative,
agentQuery.data?.author,
agentQuery.data?.id,
user?.id,
user?.role,
]);
if (user?.role === SystemRoles.ADMIN) {
return true;
}
return canEdit;
}, [agentQuery.data?.id, user?.role, canEdit]);
return (
<FormProvider {...methods}>
@@ -263,7 +294,7 @@ export default function AgentPanel({
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
aria-label="Agent configuration form"
>
<div className="mt-2 flex w-full flex-wrap gap-2">
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
<div className="w-full">
<AgentSelect
createMutation={create}

View File

@@ -1,7 +1,11 @@
import { EarthIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider';
import {
AgentCapabilities,
defaultAgentFormValues,
PERMISSION_BITS,
} from 'librechat-data-provider';
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
import type { TAgentCapabilities, AgentForm } from '~/common';
@@ -28,24 +32,25 @@ export default function AgentSelect({
const { control, reset } = useFormContext();
const { data: startupConfig } = useGetStartupConfig();
const { data: agents = null } = useListAgentsQuery(undefined, {
select: (res) =>
res.data.map((agent) =>
processAgentOption({
agent: {
...agent,
name: agent.name || agent.id,
},
instanceProjectId: startupConfig?.instanceProjectId,
}),
),
});
const { data: agents = null } = useListAgentsQuery(
{ requiredPermission: PERMISSION_BITS.EDIT },
{
select: (res) =>
res.data.map((agent) =>
processAgentOption({
agent: {
...agent,
name: agent.name || agent.id,
},
instanceProjectId: startupConfig?.instanceProjectId,
}),
),
},
);
const resetAgentForm = useCallback(
(fullAgent: Agent) => {
const { instanceProjectId } = startupConfig ?? {};
const isGlobal =
(instanceProjectId != null && fullAgent.projectIds?.includes(instanceProjectId)) ?? false;
const isGlobal = fullAgent.isPublic ?? false;
const update = {
...fullAgent,
provider: createProviderOption(fullAgent.provider),
@@ -77,6 +82,10 @@ export default function AgentSelect({
agent: update,
model: update.model,
tools: agentTools,
// Ensure the category is properly set for the form
category: fullAgent.category || 'general',
// Make sure support_contact is properly loaded
support_contact: fullAgent.support_contact,
};
Object.entries(fullAgent).forEach(([name, value]) => {

View File

@@ -0,0 +1,172 @@
import React from 'react';
import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import { SmartLoader } from './SmartLoader';
import { cn } from '~/utils';
/**
* Props for the CategoryTabs component
*/
interface CategoryTabsProps {
/** Array of agent categories to display as tabs */
categories: t.TMarketplaceCategory[];
/** Currently selected tab value */
activeTab: string;
/** Whether categories are currently loading */
isLoading: boolean;
/** Callback fired when a tab is selected */
onChange: (value: string) => void;
}
/**
* CategoryTabs - Component for displaying category tabs with counts
*
* Renders a tabbed navigation interface showing agent categories.
* Includes loading states, empty state handling, and displays counts for each category.
* Uses database-driven category labels with no hardcoded values.
*/
const CategoryTabs: React.FC<CategoryTabsProps> = ({
categories,
activeTab,
isLoading,
onChange,
}) => {
const localize = useLocalize();
// Helper function to get category display name from database data
const getCategoryDisplayName = (category: t.TCategory) => {
// Special cases for system categories
if (category.value === 'promoted') {
return localize('com_agents_top_picks');
}
if (category.value === 'all') {
return 'All';
}
// Use database label or fallback to capitalized value
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
};
// Loading skeleton component
const loadingSkeleton = (
<div className="mb-8">
<div className="flex justify-center">
<div className="flex flex-wrap items-center justify-center gap-6">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="h-6 min-w-[60px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
/>
))}
</div>
</div>
</div>
);
// Handle keyboard navigation between tabs
const handleKeyDown = (e: React.KeyboardEvent, currentCategory: string) => {
const currentIndex = categories.findIndex((cat) => cat.value === currentCategory);
let newIndex = currentIndex;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
break;
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = categories.length - 1;
break;
default:
return;
}
const newCategory = categories[newIndex];
if (newCategory) {
onChange(newCategory.value);
// Focus the new tab
setTimeout(() => {
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
newTab?.focus();
}, 0);
}
};
// Early return if no categories available
if (!isLoading && (!categories || categories.length === 0)) {
return (
<div className="mb-8 text-center text-gray-500">{localize('com_agents_no_categories')}</div>
);
}
// Main tabs content
const tabsContent = (
<div className="mb-8">
<div className="flex justify-center">
{/* Accessible tab navigation with proper ARIA attributes */}
<div
className="flex flex-wrap items-center justify-center gap-6"
role="tablist"
aria-label={localize('com_agents_category_tabs_label')}
aria-orientation="horizontal"
>
{categories.map((category, index) => (
<button
key={category.value}
id={`category-tab-${category.value}`}
onClick={() => onChange(category.value)}
onKeyDown={(e) => handleKeyDown(e, category.value)}
className={cn(
'relative px-4 py-2 text-sm font-medium transition-colors duration-200',
'focus:bg-gray-100 focus:outline-none dark:focus:bg-gray-800',
'hover:text-gray-900 dark:hover:text-white',
activeTab === category.value
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400',
)}
role="tab"
aria-selected={activeTab === category.value}
aria-controls={`tabpanel-${category.value}`}
tabIndex={activeTab === category.value ? 0 : -1}
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
>
<span className="truncate">{getCategoryDisplayName(category)}</span>
{/* Underline for active tab */}
{activeTab === category.value && (
<div
className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-gray-900 dark:bg-white"
aria-hidden="true"
/>
)}
</button>
))}
</div>
</div>
</div>
);
// Use SmartLoader to prevent category loading flashes
return (
<SmartLoader
isLoading={isLoading}
hasData={categories?.length > 0}
delay={100} // Very short delay since categories should load quickly
loadingComponent={loadingSkeleton}
>
{tabsContent}
</SmartLoader>
);
};
export default CategoryTabs;

View File

@@ -0,0 +1,252 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { Button } from '~/components/ui';
import { cn } from '~/utils';
// Comprehensive error type that handles all possible error structures
type ApiError =
| string
| Error
| {
message?: string;
status?: number;
code?: string;
response?: {
data?: {
userMessage?: string;
suggestion?: string;
message?: string;
};
status?: number;
};
data?: {
userMessage?: string;
suggestion?: string;
message?: string;
};
};
interface ErrorDisplayProps {
error: ApiError;
onRetry?: () => void;
context?: {
searchQuery?: string;
category?: string;
};
}
/**
* User-friendly error display component with actionable suggestions
*/
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, context }) => {
const localize = useLocalize();
// Type guards
const isErrorObject = (err: ApiError): err is { [key: string]: unknown } => {
return typeof err === 'object' && err !== null && !(err instanceof Error);
};
const isErrorInstance = (err: ApiError): err is Error => {
return err instanceof Error;
};
// Extract user-friendly error information
const getErrorInfo = (): { title: string; message: string; suggestion: string } => {
// Handle different error types
let errorData: unknown;
if (typeof error === 'string') {
errorData = { message: error };
} else if (isErrorInstance(error)) {
errorData = { message: error.message };
} else if (isErrorObject(error)) {
// Handle axios error response structure
errorData = (error as any)?.response?.data || (error as any)?.data || error;
} else {
errorData = error;
}
// Handle network errors first
let errorMessage = '';
if (isErrorInstance(error)) {
errorMessage = error.message;
} else if (isErrorObject(error) && (error as any)?.message) {
errorMessage = (error as any).message;
}
const errorCode = isErrorObject(error) ? (error as any)?.code : '';
// Handle timeout errors specifically
if (errorCode === 'ECONNABORTED' || errorMessage?.includes('timeout')) {
return {
title: localize('com_agents_error_timeout_title'),
message: localize('com_agents_error_timeout_message'),
suggestion: localize('com_agents_error_timeout_suggestion'),
};
}
if (errorCode === 'NETWORK_ERROR' || errorMessage?.includes('Network Error')) {
return {
title: localize('com_agents_error_network_title'),
message: localize('com_agents_error_network_message'),
suggestion: localize('com_agents_error_network_suggestion'),
};
}
// Handle specific HTTP status codes before generic userMessage
const status = isErrorObject(error) ? (error as any)?.response?.status : null;
if (status) {
if (status === 404) {
return {
title: localize('com_agents_error_not_found_title'),
message: getNotFoundMessage(),
suggestion: localize('com_agents_error_not_found_suggestion'),
};
}
if (status === 400) {
return {
title: localize('com_agents_error_invalid_request'),
message:
(errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'),
suggestion:
(errorData as any)?.suggestion || localize('com_agents_error_bad_request_suggestion'),
};
}
if (status >= 500) {
return {
title: localize('com_agents_error_server_title'),
message: localize('com_agents_error_server_message'),
suggestion: localize('com_agents_error_server_suggestion'),
};
}
}
// Use user-friendly message from backend if available (after specific status code handling)
if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) {
return {
title: getContextualTitle(),
message: (errorData as any).userMessage,
suggestion:
(errorData as any).suggestion || localize('com_agents_error_suggestion_generic'),
};
}
// Fallback to generic error with contextual title
return {
title: getContextualTitle(),
message: localize('com_agents_error_generic'),
suggestion: localize('com_agents_error_suggestion_generic'),
};
};
/**
* Get contextual title based on current operation
*/
const getContextualTitle = (): string => {
if (context?.searchQuery) {
return localize('com_agents_error_search_title');
}
if (context?.category) {
return localize('com_agents_error_category_title');
}
return localize('com_agents_error_title');
};
/**
* Get context-specific not found message
*/
const getNotFoundMessage = (): string => {
if (context?.searchQuery) {
return localize('com_agents_search_no_results', { query: context.searchQuery });
}
if (context?.category && context.category !== 'all') {
return localize('com_agents_category_empty', { category: context.category });
}
return localize('com_agents_error_not_found_message');
};
const { title, message, suggestion } = getErrorInfo();
return (
<div className="py-12 text-center" role="alert" aria-live="assertive" aria-atomic="true">
<div className="mx-auto max-w-md space-y-4">
{/* Error icon with proper accessibility */}
<div className="flex justify-center">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-full',
'bg-red-100 dark:bg-red-900/20',
)}
>
<svg
className="h-6 w-6 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
aria-hidden="true"
role="img"
aria-label="Error icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
</div>
{/* Error content with proper headings and structure */}
<div className="space-y-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title">
{title}
</h3>
<p
className="text-gray-600 dark:text-gray-400"
id="error-message"
aria-describedby="error-title"
>
{message}
</p>
<p
className="text-sm text-gray-500 dark:text-gray-500"
id="error-suggestion"
role="note"
aria-label={`Suggestion: ${suggestion}`}
>
💡 {suggestion}
</p>
</div>
{/* Retry button with enhanced accessibility */}
{onRetry && (
<div className="pt-2">
<Button
onClick={onRetry}
variant="outline"
size="sm"
className={cn(
'border-red-300 text-red-700 hover:bg-red-50 focus:ring-2 focus:ring-red-500',
'dark:border-red-600 dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:ring-red-400',
)}
aria-describedby="error-message error-suggestion"
aria-label={`Retry action. ${message}`}
>
{localize('com_agents_error_retry')}
</Button>
</div>
)}
</div>
</div>
);
};
export default ErrorDisplay;

View File

@@ -19,7 +19,7 @@ export default function ImageVision() {
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
/>
)}

View File

@@ -0,0 +1,44 @@
import React, { useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { ChatContext } from '~/Providers';
/**
* Minimal marketplace provider that provides only what SidePanel actually needs
* Replaces the bloated 44-function ChatContext implementation
*/
interface MarketplaceProviderProps {
children: React.ReactNode;
}
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => {
// Create more complete context to prevent FileRow and other component errors
// when agents with files are opened in the marketplace
const marketplaceContext = useMemo(
() => ({
conversation: {
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
},
// File-related context properties to prevent FileRow errors
files: new Map(),
setFiles: () => {},
setFilesLoading: () => {},
// Other commonly used context properties to prevent undefined errors
isSubmitting: false,
setIsSubmitting: () => {},
latestMessage: null,
setLatestMessage: () => {},
// Minimal functions to prevent errors when components try to use them
ask: () => {},
regenerate: () => {},
stopGenerating: () => {},
submitMessage: () => {},
}),
[],
);
return <ChatContext.Provider value={marketplaceContext as any}>{children}</ChatContext.Provider>;
};

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import useLocalize from '~/hooks/useLocalize';
import { useDebounce } from '~/hooks';
import { Input } from '~/components/ui';
/**
* Props for the SearchBar component
*/
interface SearchBarProps {
/** Current search query value */
value: string;
/** Callback fired when the search query changes */
onSearch: (query: string) => void;
/** Additional CSS classes */
className?: string;
}
/**
* SearchBar - Component for searching agents with debounced input
*
* Provides a search input with clear button and debounced search functionality.
* Includes proper ARIA attributes for accessibility and visual indicators.
* Uses 300ms debounce delay to prevent excessive API calls during typing.
*/
const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }) => {
const localize = useLocalize();
const [searchTerm, setSearchTerm] = useState(value);
// Debounced search value (300ms delay)
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Update internal state when props change
useEffect(() => {
setSearchTerm(value);
}, [value]);
// Trigger search when debounced value changes
useEffect(() => {
// Only trigger search if the debounced value matches current searchTerm
// This prevents stale debounced values from triggering after clear
if (debouncedSearchTerm !== value && debouncedSearchTerm === searchTerm) {
onSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, onSearch, value, searchTerm]);
/**
* Handle search input changes
*
* @param e - Input change event
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
/**
* Clear the search input and reset results
*/
const handleClear = useCallback(() => {
// Immediately call parent onSearch to clear the URL parameter
onSearch('');
// Also clear local state
setSearchTerm('');
}, [onSearch]);
return (
<div className={`relative w-full max-w-4xl ${className}`} role="search">
<label htmlFor="agent-search" className="sr-only">
{localize('com_agents_search_instructions')}
</label>
<Input
id="agent-search"
type="text"
value={searchTerm}
onChange={handleChange}
placeholder={localize('com_agents_search_placeholder')}
className="h-14 rounded-2xl border-2 border-gray-200 bg-white pl-12 pr-12 text-lg text-gray-900 shadow-lg placeholder:text-gray-500 focus:border-gray-300 focus:ring-0 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-400 dark:focus:border-gray-500"
aria-label={localize('com_agents_search_aria')}
aria-describedby="search-instructions search-results-count"
autoComplete="off"
spellCheck="false"
/>
{/* Search icon with proper accessibility */}
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
<Search className="h-6 w-6 text-gray-400" />
</div>
{/* Hidden instructions for screen readers */}
<div id="search-instructions" className="sr-only">
{localize('com_agents_search_instructions')}
</div>
{/* Show clear button only when search has value - Google style */}
{searchTerm && (
<button
type="button"
onClick={handleClear}
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 transition-colors duration-150 hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-500 dark:hover:bg-gray-400"
aria-label={localize('com_agents_clear_search')}
title={localize('com_agents_clear_search')}
>
<X className="h-3 w-3 text-white group-hover:text-white" strokeWidth={2.5} />
</button>
)}
</div>
);
};
export default SearchBar;

View File

@@ -1,272 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import { Share2Icon } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions } from 'librechat-data-provider';
import type { TStartupConfig, AgentUpdateParams } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
} from '~/components/ui';
import { useUpdateAgentMutation, useGetStartupConfig } from '~/data-provider';
import { cn, removeFocusOutlines } from '~/utils';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
type FormValues = {
[Permissions.SHARED_GLOBAL]: boolean;
[Permissions.UPDATE]: boolean;
};
export default function ShareAgent({
agent_id = '',
agentName,
projectIds = [],
isCollaborative = false,
}: {
agent_id?: string;
agentName?: string;
projectIds?: string[];
isCollaborative?: boolean;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: startupConfig = {} as TStartupConfig, isFetching } = useGetStartupConfig();
const { instanceProjectId } = startupConfig;
const agentIsGlobal = useMemo(
() => !!projectIds.includes(instanceProjectId),
[projectIds, instanceProjectId],
);
const {
watch,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
[Permissions.SHARED_GLOBAL]: agentIsGlobal,
[Permissions.UPDATE]: isCollaborative,
},
});
const sharedGlobalValue = watch(Permissions.SHARED_GLOBAL);
useEffect(() => {
if (!sharedGlobalValue) {
setValue(Permissions.UPDATE, false);
}
}, [sharedGlobalValue, setValue]);
useEffect(() => {
setValue(Permissions.SHARED_GLOBAL, agentIsGlobal);
setValue(Permissions.UPDATE, isCollaborative);
}, [agentIsGlobal, isCollaborative, setValue]);
const updateAgent = useUpdateAgentMutation({
onSuccess: (data) => {
showToast({
message: `${localize('com_assistants_update_success')} ${
data.name ?? localize('com_ui_agent')
}`,
status: 'success',
});
},
onError: (err) => {
const error = err as Error;
showToast({
message: `${localize('com_agents_update_error')}${
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
}`,
status: 'error',
});
},
});
if (!agent_id || !instanceProjectId) {
return null;
}
const onSubmit = (data: FormValues) => {
if (!agent_id || !instanceProjectId) {
return;
}
const payload = {} as AgentUpdateParams;
if (data[Permissions.UPDATE] !== isCollaborative) {
payload.isCollaborative = data[Permissions.UPDATE];
}
if (data[Permissions.SHARED_GLOBAL] !== agentIsGlobal) {
if (data[Permissions.SHARED_GLOBAL]) {
payload.projectIds = [startupConfig.instanceProjectId];
} else {
payload.removeProjectIds = [startupConfig.instanceProjectId];
payload.isCollaborative = false;
}
}
if (Object.keys(payload).length > 0) {
updateAgent.mutate({
agent_id,
data: payload,
});
} else {
showToast({
message: localize('com_ui_no_changes'),
status: 'info',
});
}
};
return (
<OGDialog>
<OGDialogTrigger asChild>
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={localize(
'com_ui_share_var',
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
)}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Share2Icon className="icon-md h-4 w-4" />
</div>
</button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 md:max-w-xl">
<OGDialogTitle>
{localize(
'com_ui_share_var',
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
)}
</OGDialogTitle>
<form
className="p-2"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleSubmit(onSubmit)(e);
}}
>
<div className="flex items-center justify-between gap-2 py-2">
<div className="flex items-center">
<button
type="button"
className="mr-2 cursor-pointer"
disabled={isFetching || updateAgent.isLoading || !instanceProjectId}
onClick={() =>
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
shouldDirty: true,
})
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
shouldDirty: true,
});
}
}}
aria-checked={getValues(Permissions.SHARED_GLOBAL)}
role="checkbox"
>
{localize('com_ui_share_to_all_users')}
</button>
<label htmlFor={Permissions.SHARED_GLOBAL} className="select-none">
{agentIsGlobal && (
<span className="ml-2 text-xs">{localize('com_ui_agent_shared_to_all')}</span>
)}
</label>
</div>
<Controller
name={Permissions.SHARED_GLOBAL}
control={control}
disabled={isFetching || updateAgent.isLoading || !instanceProjectId}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
/>
)}
/>
</div>
<div className="mb-4 flex items-center justify-between gap-2 py-2">
<div className="flex items-center">
<button
type="button"
className="mr-2 cursor-pointer"
disabled={
isFetching || updateAgent.isLoading || !instanceProjectId || !sharedGlobalValue
}
onClick={() =>
setValue(Permissions.UPDATE, !getValues(Permissions.UPDATE), {
shouldDirty: true,
})
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setValue(Permissions.UPDATE, !getValues(Permissions.UPDATE), {
shouldDirty: true,
});
}
}}
aria-checked={getValues(Permissions.UPDATE)}
role="checkbox"
>
{localize('com_agents_allow_editing')}
</button>
{/* <label htmlFor={Permissions.UPDATE} className="select-none">
{agentIsGlobal && (
<span className="ml-2 text-xs">{localize('com_ui_agent_editing_allowed')}</span>
)}
</label> */}
</div>
<Controller
name={Permissions.UPDATE}
control={control}
disabled={
isFetching || updateAgent.isLoading || !instanceProjectId || !sharedGlobalValue
}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
/>
)}
/>
</div>
<div className="flex justify-end">
<OGDialogClose asChild>
<Button
variant="submit"
size="sm"
type="submit"
disabled={isSubmitting || isFetching}
>
{localize('com_ui_save')}
</Button>
</OGDialogClose>
</div>
</form>
</OGDialogContent>
</OGDialog>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
import type { AccessRole } from 'librechat-data-provider';
import { SelectDropDownPop } from '~/components/ui';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import { useLocalize } from '~/hooks';
interface AccessRolesPickerProps {
resourceType?: string;
selectedRoleId?: string;
onRoleChange: (roleId: string) => void;
className?: string;
}
export default function AccessRolesPicker({
resourceType = 'agent',
selectedRoleId = ACCESS_ROLE_IDS.AGENT_VIEWER,
onRoleChange,
className = '',
}: AccessRolesPickerProps) {
const localize = useLocalize();
// Fetch access roles from API
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
// Helper function to get localized role name and description
const getLocalizedRoleInfo = (roleId: string) => {
switch (roleId) {
case 'agent_viewer':
return {
name: localize('com_ui_role_viewer'),
description: localize('com_ui_role_viewer_desc'),
};
case 'agent_editor':
return {
name: localize('com_ui_role_editor'),
description: localize('com_ui_role_editor_desc'),
};
case 'agent_manager':
return {
name: localize('com_ui_role_manager'),
description: localize('com_ui_role_manager_desc'),
};
case 'agent_owner':
return {
name: localize('com_ui_role_owner'),
description: localize('com_ui_role_owner_desc'),
};
default:
return {
name: localize('com_ui_unknown'),
description: localize('com_ui_unknown'),
};
}
};
// Find the currently selected role
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
if (rolesLoading || !accessRoles) {
return (
<div className={className}>
<div className="flex items-center justify-center py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
<span className="ml-2 text-sm text-gray-500">Loading roles...</span>
</div>
</div>
);
}
return (
<div className={className}>
<SelectDropDownPop
availableValues={accessRoles.map((role: AccessRole) => {
const localizedInfo = getLocalizedRoleInfo(role.accessRoleId);
return {
value: role.accessRoleId,
label: localizedInfo.name,
description: localizedInfo.description,
};
})}
showLabel={false}
value={
selectedRole
? (() => {
const localizedInfo = getLocalizedRoleInfo(selectedRole.accessRoleId);
return {
value: selectedRole.accessRoleId,
label: localizedInfo.name,
description: localizedInfo.description,
};
})()
: null
}
setValue={onRoleChange}
/>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
import type { TPrincipal } from 'librechat-data-provider';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
} from '~/components/ui';
import { cn, removeFocusOutlines } from '~/utils';
import { useToastContext } from '~/Providers';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import {
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import PeoplePicker from './PeoplePicker/PeoplePicker';
import PublicSharingToggle from './PublicSharingToggle';
import ManagePermissionsDialog from './ManagePermissionsDialog';
import AccessRolesPicker from './AccessRolesPicker';
export default function GrantAccessDialog({
agentName,
onGrantAccess,
resourceType = 'agent',
agentDbId,
agentId,
}: {
agentDbId?: string | null;
agentId?: string | null;
agentName?: string;
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
resourceType?: string;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
data: permissionsData,
// isLoading: isLoadingPermissions,
// error: permissionsError,
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
enabled: !!agentDbId,
});
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
ACCESS_ROLE_IDS.AGENT_VIEWER,
);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const agentUrl = `${window.location.origin}/c/new?agent_id=${agentId}`;
const copyAgentUrl = useCopyToClipboard({ text: agentUrl });
const currentShares: TPrincipal[] =
permissionsData?.principals?.map((principal) => ({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
accessRoleId: principal.accessRoleId,
})) || [];
const currentIsPublic = permissionsData?.public ?? false;
const currentPublicRole = permissionsData?.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
const [isPublic, setIsPublic] = useState(false);
const [publicRole, setPublicRole] = useState<string>(ACCESS_ROLE_IDS.AGENT_VIEWER);
useEffect(() => {
if (permissionsData && isModalOpen) {
setIsPublic(currentIsPublic ?? false);
setPublicRole(currentPublicRole);
}
}, [permissionsData, isModalOpen, currentIsPublic, currentPublicRole]);
if (!agentDbId) {
return null;
}
const handleGrantAccess = async () => {
try {
const sharesToAdd = newShares.map((share) => ({
...share,
accessRoleId: defaultPermissionId,
}));
const allShares = [...currentShares, ...sharesToAdd];
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: agentDbId,
data: {
updated: sharesToAdd,
removed: [],
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
},
});
if (onGrantAccess) {
onGrantAccess(allShares, isPublic, publicRole);
}
showToast({
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
status: 'success',
});
setNewShares([]);
setDefaultPermissionId(ACCESS_ROLE_IDS.AGENT_VIEWER);
setIsPublic(false);
setPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
setIsModalOpen(false);
} catch (error) {
console.error('Error granting access:', error);
showToast({
message: 'Failed to grant access. Please try again.',
status: 'error',
});
}
};
const handleCancel = () => {
setNewShares([]);
setDefaultPermissionId(ACCESS_ROLE_IDS.AGENT_VIEWER);
setIsPublic(false);
setPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
setIsModalOpen(false);
};
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
const submitButtonActive =
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
<OGDialogTrigger asChild>
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={localize('com_ui_share_var', {
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Share2Icon className="icon-md h-4 w-4" />
{totalCurrentShares > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{totalCurrentShares}
</span>
)}
</div>
</button>
</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
{localize('com_ui_share_var', {
0:
agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
<PeoplePicker
onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')}
</label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
/>
</div>
<PublicSharingToggle
isPublic={isPublic}
publicRole={publicRole}
onPublicToggle={setIsPublic}
onPublicRoleChange={setPublicRole}
resourceType={resourceType}
/>
<div className="flex justify-between border-t pt-4">
<div className="flex gap-2">
<ManagePermissionsDialog
agentDbId={agentDbId}
agentName={agentName}
resourceType={resourceType}
/>
{agentId && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (isCopying) return;
copyAgentUrl(setIsCopying);
showToast({
message: localize('com_ui_agent_url_copied'),
status: 'success',
});
}}
disabled={isCopying}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_url_to_clipboard')}
title={
isCopying
? localize('com_ui_agent_url_copied')
: localize('com_ui_copy_url_to_clipboard')
}
>
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Link className="h-4 w-4" />}
</Button>
)}
</div>
<div className="flex gap-3">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleGrantAccess}
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_granting')}
</div>
) : (
localize('com_ui_grant_access')
)}
</Button>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
);
}

View File

@@ -0,0 +1,349 @@
import React, { useState, useEffect } from 'react';
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
import { ACCESS_ROLE_IDS, TPrincipal } from 'librechat-data-provider';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
} from '~/components/ui';
import { cn, removeFocusOutlines } from '~/utils';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import {
useGetAccessRolesQuery,
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import SelectedPrincipalsList from './PeoplePicker/SelectedPrincipalsList';
import PublicSharingToggle from './PublicSharingToggle';
export default function ManagePermissionsDialog({
agentDbId,
agentName,
resourceType = 'agent',
onUpdatePermissions,
}: {
agentDbId: string;
agentName?: string;
resourceType?: string;
onUpdatePermissions?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
data: permissionsData,
isLoading: isLoadingPermissions,
error: permissionsError,
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
enabled: !!agentDbId,
});
const {
data: accessRoles,
// isLoading,
} = useGetAccessRolesQuery(resourceType);
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
const [managedIsPublic, setManagedIsPublic] = useState(false);
const [managedPublicRole, setManagedPublicRole] = useState<string>(ACCESS_ROLE_IDS.AGENT_VIEWER);
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const currentShares: TPrincipal[] = permissionsData?.principals || [];
const isPublic = permissionsData?.public || false;
const publicRole = permissionsData?.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
useEffect(() => {
if (permissionsData) {
setManagedShares(currentShares);
setManagedIsPublic(isPublic);
setManagedPublicRole(publicRole);
setHasChanges(false);
}
}, [permissionsData, isModalOpen]);
if (!agentDbId) {
return null;
}
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
}
const handleRemoveShare = (idOnTheSource: string) => {
setManagedShares(managedShares.filter((s) => s.idOnTheSource !== idOnTheSource));
setHasChanges(true);
};
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
setManagedShares(
managedShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
),
);
setHasChanges(true);
};
const handleSaveChanges = async () => {
try {
const originalSharesMap = new Map(
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const managedSharesMap = new Map(
managedShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const updated = managedShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
const original = originalSharesMap.get(key);
return !original || original.accessRoleId !== share.accessRoleId;
});
const removed = currentShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
return !managedSharesMap.has(key);
});
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: agentDbId,
data: {
updated,
removed,
public: managedIsPublic,
publicAccessRoleId: managedIsPublic ? managedPublicRole : undefined,
},
});
if (onUpdatePermissions) {
onUpdatePermissions(managedShares, managedIsPublic, managedPublicRole);
}
showToast({
message: localize('com_ui_permissions_updated_success'),
status: 'success',
});
setIsModalOpen(false);
} catch (error) {
console.error('Error updating permissions:', error);
showToast({
message: localize('com_ui_permissions_failed_update'),
status: 'error',
});
}
};
const handleCancel = () => {
setManagedShares(currentShares);
setManagedIsPublic(isPublic);
setManagedPublicRole(publicRole);
setIsModalOpen(false);
};
const handleRevokeAll = () => {
setManagedShares([]);
setManagedIsPublic(false);
setHasChanges(true);
};
const handlePublicToggle = (isPublic: boolean) => {
setManagedIsPublic(isPublic);
setHasChanges(true);
if (!isPublic) {
setManagedPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
}
};
const handlePublicRoleChange = (role: string) => {
setManagedPublicRole(role);
setHasChanges(true);
};
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
const originalTotalShares = currentShares.length + (isPublic ? 1 : 0);
/** Check if there's at least one owner (user, group, or public with owner role) */
const hasAtLeastOneOwner =
managedShares.some((share) => share.accessRoleId === ACCESS_ROLE_IDS.AGENT_OWNER) ||
(managedIsPublic && managedPublicRole === ACCESS_ROLE_IDS.AGENT_OWNER);
let peopleLabel = localize('com_ui_people');
if (managedShares.length === 1) {
peopleLabel = localize('com_ui_person');
}
let buttonAriaLabel = localize('com_ui_manage_permissions_for') + ' agent';
if (agentName != null && agentName !== '') {
buttonAriaLabel = localize('com_ui_manage_permissions_for') + ` "${agentName}"`;
}
let dialogTitle = localize('com_ui_manage_permissions_for') + ' Agent';
if (agentName != null && agentName !== '') {
dialogTitle = localize('com_ui_manage_permissions_for') + ` "${agentName}"`;
}
let publicSuffix = '';
if (managedIsPublic) {
publicSuffix = localize('com_ui_and_public');
}
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<OGDialogTrigger asChild>
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={buttonAriaLabel}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Settings className="icon-md h-4 w-4" />
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
{originalTotalShares > 0 && `(${originalTotalShares})`}
</div>
</button>
</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
{dialogTitle}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
<div className="rounded-lg bg-surface-tertiary p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-text-primary">
{localize('com_ui_current_access')}
</h3>
<p className="text-xs text-text-secondary">
{(() => {
if (totalShares === 0) {
return localize('com_ui_no_users_groups_access');
}
return localize('com_ui_shared_with_count', {
0: managedShares.length,
1: peopleLabel,
2: publicSuffix,
});
})()}
</p>
</div>
{(managedShares.length > 0 || managedIsPublic) && (
<Button
variant="outline"
size="sm"
onClick={handleRevokeAll}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="mr-2 h-4 w-4" />
{localize('com_ui_revoke_all')}
</Button>
)}
</div>
</div>
{(() => {
if (isLoadingPermissions) {
return (
<div className="flex items-center justify-center p-8">
<Loader className="h-6 w-6 animate-spin" />
<span className="ml-2 text-sm text-text-secondary">
{localize('com_ui_loading_permissions')}
</span>
</div>
);
}
if (managedShares.length > 0) {
return (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-text-primary">
<UserCheck className="h-4 w-4" />
{localize('com_ui_user_group_permissions')} ({managedShares.length})
</h3>
<SelectedPrincipalsList
principles={managedShares}
onRemoveHandler={handleRemoveShare}
availableRoles={accessRoles || []}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
);
}
return (
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
<Users className="mx-auto h-8 w-8 text-text-secondary" />
<p className="mt-2 text-sm text-text-secondary">
{localize('com_ui_no_individual_access')}
</p>
</div>
);
})()}
<div>
<h3 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h3>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
/>
</div>
<div className="flex justify-end gap-3 border-t pt-4">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleSaveChanges}
disabled={
updatePermissionsMutation.isLoading ||
!hasChanges ||
isLoadingPermissions ||
!hasAtLeastOneOwner
}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_saving')}
</div>
) : (
localize('com_ui_save_changes')
)}
</Button>
</div>
{hasChanges && (
<div className="text-xs text-orange-600 dark:text-orange-400">
* {localize('com_ui_unsaved_changes')}
</div>
)}
{!hasAtLeastOneOwner && hasChanges && (
<div className="text-xs text-red-600 dark:text-red-400">
* {localize('com_ui_at_least_one_owner_required')}
</div>
)}
</div>
</OGDialogContent>
</OGDialog>
);
}

View File

@@ -0,0 +1,101 @@
import React, { useState, useMemo } from 'react';
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
import { SearchPicker } from '~/components/ui/SearchPicker';
import { useLocalize } from '~/hooks';
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
import SelectedPrincipalsList from './SelectedPrincipalsList';
interface PeoplePickerProps {
onSelectionChange: (principals: TPrincipal[]) => void;
placeholder?: string;
className?: string;
}
export default function PeoplePicker({
onSelectionChange,
placeholder,
className = '',
}: PeoplePickerProps) {
const localize = useLocalize();
const [searchQuery, setSearchQuery] = useState('');
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
const searchParams: PrincipalSearchParams = useMemo(
() => ({
q: searchQuery,
limit: 30,
}),
[searchQuery],
);
const {
data: searchResponse,
isLoading: queryIsLoading,
error,
} = useSearchPrincipalsQuery(searchParams, {
enabled: searchQuery.length >= 2,
});
const isLoading = searchQuery.length >= 2 && queryIsLoading;
const selectableResults = useMemo(() => {
const results = searchResponse?.results || [];
return results.filter(
(result) => !selectedShares.some((share) => share.idOnTheSource === result.idOnTheSource),
);
}, [searchResponse?.results, selectedShares]);
if (error) {
console.error('Principal search error:', error);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="relative">
<SearchPicker<TPrincipal & { key: string; value: string }>
options={selectableResults.map((s) => {
const key = s.idOnTheSource || 'unknown' + 'picker_key';
const value = s.idOnTheSource || 'Unknown';
return {
...s,
id: s.id ?? undefined,
key,
value,
};
})}
renderOptions={(o) => <PeoplePickerSearchItem principal={o} />}
placeholder={placeholder || localize('com_ui_search_default_placeholder')}
query={searchQuery}
onQueryChange={(query: string) => {
setSearchQuery(query);
}}
onPick={(principal) => {
console.log('Selected Principal:', principal);
setSelectedShares((prev) => {
const newArray = [...prev, principal];
onSelectionChange([...newArray]);
return newArray;
});
setSearchQuery('');
}}
label={localize('com_ui_search_users_groups')}
isLoading={isLoading}
/>
</div>
<SelectedPrincipalsList
principles={selectedShares}
onRemoveHandler={(idOnTheSource: string) => {
setSelectedShares((prev) => {
const newArray = prev.filter((share) => share.idOnTheSource !== idOnTheSource);
onSelectionChange(newArray);
return newArray;
});
}}
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React, { forwardRef } from 'react';
import type { TPrincipal } from 'librechat-data-provider';
import { cn } from '~/utils';
import { useLocalize } from '~/hooks';
import PrincipalAvatar from '../PrincipalAvatar';
interface PeoplePickerSearchItemProps extends React.HTMLAttributes<HTMLDivElement> {
principal: TPrincipal;
}
const PeoplePickerSearchItem = forwardRef<HTMLDivElement, PeoplePickerSearchItemProps>(
function PeoplePickerSearchItem(
{ principal, className, style, onClick, ...props },
forwardedRef,
) {
const localize = useLocalize();
const { name, email, type } = principal;
// Display name with fallback
const displayName = name || localize('com_ui_unknown');
const subtitle = email || `${type} (${principal.source || 'local'})`;
return (
<div
{...props}
ref={forwardedRef}
className={cn('flex items-center gap-3 p-2', className)}
style={style}
onClick={(event) => {
onClick?.(event);
}}
>
<PrincipalAvatar principal={principal} size="md" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text-primary">{displayName}</div>
<div className="truncate text-xs text-text-secondary">{subtitle}</div>
</div>
<div className="flex-shrink-0">
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium',
type === 'user'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
)}
>
{type === 'user' ? localize('com_ui_user') : localize('com_ui_group')}
</span>
</div>
</div>
);
},
);
export default PeoplePickerSearchItem;

View File

@@ -0,0 +1,149 @@
import React, { useState, useId } from 'react';
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import type { TPrincipal, TAccessRole } from 'librechat-data-provider';
import { Button, DropdownPopup } from '~/components/ui';
import { useLocalize } from '~/hooks';
import PrincipalAvatar from '../PrincipalAvatar';
interface SelectedPrincipalsListProps {
principles: TPrincipal[];
onRemoveHandler: (idOnTheSource: string) => void;
onRoleChange?: (idOnTheSource: string, newRoleId: string) => void;
availableRoles?: Omit<TAccessRole, 'resourceType'>[];
className?: string;
}
export default function SelectedPrincipalsList({
principles,
onRemoveHandler,
className = '',
onRoleChange,
availableRoles,
}: SelectedPrincipalsListProps) {
const localize = useLocalize();
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
const displayName = principal.name || localize('com_ui_unknown');
const subtitle = principal.email || `${principal.type} (${principal.source || 'local'})`;
return { displayName, subtitle };
};
if (principles.length === 0) {
return (
<div className={`space-y-3 ${className}`}>
<div className="rounded-lg border border-dashed border-border py-8 text-center text-muted-foreground">
<Users className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="mt-1 text-xs">{localize('com_ui_search_above_to_add')}</p>
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="space-y-2">
{principles.map((share) => {
const { displayName, subtitle } = getPrincipalDisplayInfo(share);
return (
<div
key={share.idOnTheSource + '-principalList'}
className="bg-surface flex items-center justify-between rounded-lg border border-border p-3"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<PrincipalAvatar principal={share} size="md" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{subtitle}</span>
{share.source === 'entra' && (
<>
<ExternalLink className="h-3 w-3" />
<span>{localize('com_ui_azure_ad')}</span>
</>
)}
</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{!!share.accessRoleId && !!onRoleChange && (
<RoleSelector
currentRole={share.accessRoleId}
onRoleChange={(newRole) => {
onRoleChange?.(share.idOnTheSource!, newRole);
}}
availableRoles={availableRoles ?? []}
/>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveHandler(share.idOnTheSource!)}
className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
aria-label={localize('com_ui_remove_user', { 0: displayName })}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}
interface RoleSelectorProps {
currentRole: string;
onRoleChange: (newRole: string) => void;
availableRoles: Omit<TAccessRole, 'resourceType'>[];
}
function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelectorProps) {
const menuId = useId();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const localize = useLocalize();
const getLocalizedRoleName = (roleId: string) => {
switch (roleId) {
case 'agent_viewer':
return localize('com_ui_role_viewer');
case 'agent_editor':
return localize('com_ui_role_editor');
case 'agent_manager':
return localize('com_ui_role_manager');
case 'agent_owner':
return localize('com_ui_role_owner');
default:
return localize('com_ui_unknown');
}
};
return (
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton className="flex h-8 items-center gap-2 rounded-md border border-border-medium bg-surface-secondary px-2 py-1 text-sm font-medium transition-colors duration-200 hover:bg-surface-tertiary">
<span className="hidden sm:inline">{getLocalizedRoleName(currentRole)}</span>
<ChevronDown className="h-3 w-3" />
</Menu.MenuButton>
}
items={availableRoles?.map((role) => ({
id: role.accessRoleId,
label: getLocalizedRoleName(role.accessRoleId),
onClick: () => onRoleChange(role.accessRoleId),
}))}
menuId={menuId}
className="z-50 [pointer-events:auto]"
/>
);
}

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { Users, User } from 'lucide-react';
import type { TPrincipal } from 'librechat-data-provider';
import { cn } from '~/utils';
interface PrincipalAvatarProps {
principal: TPrincipal;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export default function PrincipalAvatar({
principal,
size = 'md',
className,
}: PrincipalAvatarProps) {
const { avatar, type, name } = principal;
const displayName = name || 'Unknown';
// Size variants
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
};
const iconSizeClasses = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
const avatarSizeClass = sizeClasses[size];
const iconSizeClass = iconSizeClasses[size];
// Avatar or icon logic
if (avatar) {
return (
<div className={cn('flex-shrink-0', className)}>
<img
src={avatar}
alt={`${displayName} avatar`}
className={cn(avatarSizeClass, 'rounded-full object-cover')}
onError={(e) => {
// Fallback to icon if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
{/* Hidden fallback icon that shows if image fails */}
<div className={cn('hidden', avatarSizeClass)}>
{type === 'user' ? (
<div
className={cn(
avatarSizeClass,
'flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900',
)}
>
<User className={cn(iconSizeClass, 'text-blue-600 dark:text-blue-400')} />
</div>
) : (
<div
className={cn(
avatarSizeClass,
'flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900',
)}
>
<Users className={cn(iconSizeClass, 'text-green-600 dark:text-green-400')} />
</div>
)}
</div>
</div>
);
}
// Fallback icon based on type
return (
<div className={cn('flex-shrink-0', className)}>
{type === 'user' ? (
<div
className={cn(
avatarSizeClass,
'flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900',
)}
>
<User className={cn(iconSizeClass, 'text-blue-600 dark:text-blue-400')} />
</div>
) : (
<div
className={cn(
avatarSizeClass,
'flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900',
)}
>
<Users className={cn(iconSizeClass, 'text-green-600 dark:text-green-400')} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Globe } from 'lucide-react';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import AccessRolesPicker from './AccessRolesPicker';
interface PublicSharingToggleProps {
isPublic: boolean;
publicRole: string;
onPublicToggle: (isPublic: boolean) => void;
onPublicRoleChange: (role: string) => void;
className?: string;
resourceType?: string;
}
export default function PublicSharingToggle({
isPublic,
publicRole,
onPublicToggle,
onPublicRoleChange,
className = '',
resourceType = 'agent',
}: PublicSharingToggleProps) {
const localize = useLocalize();
return (
<div className={`space-y-3 border-t pt-4 ${className}`}>
<div className="flex items-center justify-between">
<div>
<h3 className="flex items-center gap-2 text-sm font-medium">
<Globe className="h-4 w-4" />
{localize('com_ui_share_with_everyone')}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{localize('com_ui_make_agent_available_all_users')}
</p>
</div>
<Switch
checked={isPublic}
onCheckedChange={onPublicToggle}
aria-label={localize('com_ui_share_with_everyone')}
/>
</div>
{isPublic && (
<div>
<label className="mb-2 block text-sm font-medium">
{localize('com_ui_public_access_level')}
</label>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={publicRole}
onRoleChange={onPublicRoleChange}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { AgentListResponse } from 'librechat-data-provider';
import React, { useState, useEffect } from 'react';
interface SmartLoaderProps {
/** Whether the content is currently loading */
isLoading: boolean;
/** Whether there is existing data to show */
hasData: boolean;
/** Delay before showing loading state (in ms) - prevents flashing for quick loads */
delay?: number;
/** Loading skeleton/spinner component */
loadingComponent: React.ReactNode;
/** Content to show when loaded */
children: React.ReactNode;
/** Additional CSS classes */
className?: string;
}
/**
* SmartLoader - Intelligent loading wrapper that prevents flashing
*
* Only shows loading states when:
* 1. Actually loading AND no existing data
* 2. Loading has lasted longer than the delay threshold
*
* This prevents brief loading flashes for cached/fast responses
*/
export const SmartLoader: React.FC<SmartLoaderProps> = ({
isLoading,
hasData,
delay = 150,
loadingComponent,
children,
className = '',
}) => {
const [shouldShowLoading, setShouldShowLoading] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (isLoading && !hasData) {
// Only show loading after delay to prevent flashing
timeoutId = setTimeout(() => {
setShouldShowLoading(true);
}, delay);
} else {
// Immediately hide loading when done
setShouldShowLoading(false);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isLoading, hasData, delay]);
// Show loading state only if we've determined it should be shown
if (shouldShowLoading) {
return <div className={className}>{loadingComponent}</div>;
}
// Show content (including when loading but we have existing data)
return <div className={className}>{children}</div>;
};
/**
* Hook to determine if we have meaningful data to show
* Helps prevent loading states when we already have cached content
*/
export const useHasData = (data: AgentListResponse | undefined): boolean => {
if (!data) return false;
// Type guard for object data
if (typeof data === 'object' && data !== null) {
// Check for agent list data
if ('agents' in data) {
const agents = (data as any).agents;
return Array.isArray(agents) && agents.length > 0;
}
// Check for single agent data
if ('id' in data || 'name' in data) {
return true;
}
}
// Check for categories data (array)
if (Array.isArray(data)) {
return data.length > 0;
}
return false;
};
export default SmartLoader;

View File

@@ -0,0 +1,533 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import CategoryTabs from '../CategoryTabs';
import AgentGrid from '../AgentGrid';
import AgentCard from '../AgentCard';
import SearchBar from '../SearchBar';
import ErrorDisplay from '../ErrorDisplay';
import * as t from 'librechat-data-provider';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(() => ({
matches: false,
media: '',
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock Recoil
jest.mock('recoil', () => ({
useRecoilValue: jest.fn(() => 'en'),
RecoilRoot: ({ children }: any) => children,
atom: jest.fn(() => ({})),
atomFamily: jest.fn(() => ({})),
selector: jest.fn(() => ({})),
selectorFamily: jest.fn(() => ({})),
useRecoilState: jest.fn(() => ['en', jest.fn()]),
useSetRecoilState: jest.fn(() => jest.fn()),
}));
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: jest.fn() },
}),
}));
// Create the localize function once to be reused
const mockLocalize = jest.fn((key: string, options?: any) => {
const translations: Record<string, string> = {
com_agents_category_tabs_label: 'Agent Categories',
com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`,
com_agents_search_instructions: 'Type to search agents by name or description',
com_agents_search_aria: 'Search agents',
com_agents_search_placeholder: 'Search agents...',
com_agents_clear_search: 'Clear search',
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
com_agents_no_description: 'No description available',
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
com_agents_error_retry: 'Try Again',
com_agents_loading: 'Loading...',
com_agents_empty_state_heading: 'No agents found',
com_agents_search_empty_heading: 'No search results',
com_agents_created_by: 'by',
com_agents_top_picks: 'Top Picks',
// ErrorDisplay translations
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
com_agents_error_network_title: 'Network Error',
com_agents_error_network_message: 'Unable to connect to the server',
com_agents_error_network_suggestion: 'Check your internet connection and try again',
com_agents_error_not_found_title: 'Not Found',
com_agents_error_not_found_suggestion: 'The requested resource could not be found',
com_agents_error_invalid_request: 'Invalid Request',
com_agents_error_bad_request_message: 'The request was invalid',
com_agents_error_bad_request_suggestion: 'Please check your input and try again',
com_agents_error_server_title: 'Server Error',
com_agents_error_server_message: 'An internal server error occurred',
com_agents_error_server_suggestion: 'Please try again later',
com_agents_error_title: 'Error',
com_agents_error_generic: 'An unexpected error occurred',
com_agents_error_search_title: 'Search Error',
com_agents_error_category_title: 'Category Error',
com_agents_search_no_results: `No results found for "${options?.query}"`,
com_agents_category_empty: `No agents found in ${options?.category} category`,
com_agents_error_not_found_message: 'The requested resource could not be found',
};
return translations[key] || key;
});
// Mock useLocalize specifically
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: () => mockLocalize,
}));
// Mock hooks
jest.mock('~/hooks', () => ({
useLocalize: () => mockLocalize,
useDebounce: jest.fn(),
}));
jest.mock('~/data-provider/Agents', () => ({
useMarketplaceAgentsInfiniteQuery: jest.fn(),
}));
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: jest.fn(),
}));
// Mock utility functions
jest.mock('~/utils/agents', () => ({
renderAgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />),
getContactDisplayName: jest.fn((agent) => agent.authorName),
}));
// Mock SmartLoader
jest.mock('../SmartLoader', () => ({
SmartLoader: ({ children, isLoading }: any) => (isLoading ? <div>Loading...</div> : children),
useHasData: jest.fn(() => true),
}));
// Import the actual modules to get the mocked functions
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories } from '~/hooks/Agents';
import { useDebounce } from '~/hooks';
// Get typed mock functions
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
const mockUseAgentCategories = jest.mocked(useAgentCategories);
const mockUseDebounce = jest.mocked(useDebounce);
// Create wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Accessibility Improvements', () => {
beforeEach(() => {
mockUseMarketplaceAgentsInfiniteQuery.mockClear();
mockUseAgentCategories.mockClear();
mockUseDebounce.mockClear();
// Default mock implementations
mockUseDebounce.mockImplementation((value) => value);
mockUseAgentCategories.mockReturnValue({
categories: [
{ value: 'promoted', label: 'Top Picks' },
{ value: 'all', label: 'All' },
{ value: 'productivity', label: 'Productivity' },
],
emptyCategory: { value: 'all', label: 'All' },
isLoading: false,
error: null,
});
});
describe('CategoryTabs Accessibility', () => {
const categories = [
{ value: 'promoted', label: 'Top Picks', count: 5 },
{ value: 'all', label: 'All', count: 20 },
{ value: 'productivity', label: 'Productivity', count: 8 },
];
it('implements proper tablist role and ARIA attributes', () => {
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={jest.fn()}
/>,
);
// Check tablist role
const tablist = screen.getByRole('tablist');
expect(tablist).toBeInTheDocument();
expect(tablist).toHaveAttribute('aria-label', 'Agent Categories');
expect(tablist).toHaveAttribute('aria-orientation', 'horizontal');
// Check individual tabs
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
tabs.forEach((tab) => {
expect(tab).toHaveAttribute('aria-selected');
expect(tab).toHaveAttribute('aria-controls');
expect(tab).toHaveAttribute('id');
});
});
it('supports keyboard navigation', () => {
const onChange = jest.fn();
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={onChange}
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
// Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('all');
fireEvent.keyDown(promotedTab, { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('productivity');
fireEvent.keyDown(promotedTab, { key: 'Home' });
expect(onChange).toHaveBeenCalledWith('promoted');
fireEvent.keyDown(promotedTab, { key: 'End' });
expect(onChange).toHaveBeenCalledWith('productivity');
});
it('manages focus correctly', () => {
const onChange = jest.fn();
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={onChange}
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const allTab = screen.getByRole('tab', { name: /All tab/ });
// Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0');
expect(allTab).toHaveAttribute('tabIndex', '-1');
});
});
describe('SearchBar Accessibility', () => {
it('provides proper search role and labels', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Check search landmark
const searchRegion = screen.getByRole('search');
expect(searchRegion).toBeInTheDocument();
// Check input accessibility
const searchInput = screen.getByRole('textbox');
expect(searchInput).toHaveAttribute('id', 'agent-search');
expect(searchInput).toHaveAttribute('aria-label', 'Search agents');
expect(searchInput).toHaveAttribute(
'aria-describedby',
'search-instructions search-results-count',
);
// Check hidden label exists
const hiddenLabel = screen.getByLabelText('Search agents');
expect(hiddenLabel).toBeInTheDocument();
});
it('provides accessible clear button', () => {
render(<SearchBar value="test" onSearch={jest.fn()} />);
const clearButton = screen.getByRole('button', { name: 'Clear search' });
expect(clearButton).toBeInTheDocument();
expect(clearButton).toHaveAttribute('aria-label', 'Clear search');
expect(clearButton).toHaveAttribute('title', 'Clear search');
});
it('hides decorative icons from screen readers', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Search icon should be hidden
const iconContainer = document.querySelector('[aria-hidden="true"]');
expect(iconContainer).toBeInTheDocument();
});
});
describe('AgentCard Accessibility', () => {
const mockAgent = {
id: 'test-agent',
name: 'Test Agent',
description: 'A test agent for testing',
authorName: 'Test Author',
created_at: 1704067200000,
avatar: null,
instructions: 'Test instructions',
provider: 'openai' as const,
model: 'gpt-4',
model_parameters: {
temperature: 0.7,
maxContextTokens: 4096,
max_context_tokens: 4096,
max_output_tokens: 1024,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
},
};
it('provides comprehensive ARIA labels', () => {
render(<AgentCard agent={mockAgent as t.Agent} onClick={jest.fn()} />);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
expect(card).toHaveAttribute('aria-describedby', 'agent-test-agent-description');
expect(card).toHaveAttribute('role', 'button');
});
it('handles agents without descriptions', () => {
const agentWithoutDesc = { ...mockAgent, description: undefined };
render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
expect(screen.getByText('No description available')).toBeInTheDocument();
});
it('supports keyboard interaction', () => {
const onClick = jest.fn();
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(card, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(2);
});
});
describe('AgentGrid Accessibility', () => {
beforeEach(() => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
data: {
pages: [
{
data: [
{ id: '1', name: 'Agent 1', description: 'First agent' },
{ id: '2', name: 'Agent 2', description: 'Second agent' },
],
},
],
},
isLoading: false,
error: null,
} as any);
});
it('implements proper tabpanel structure', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check tabpanel role
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('id', 'category-panel-all');
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-all');
expect(tabpanel).toHaveAttribute('aria-live', 'polite');
});
it('provides grid structure with proper roles', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check grid role
const grid = screen.getByRole('grid');
expect(grid).toBeInTheDocument();
expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in All category');
// Check gridcells
const gridcells = screen.getAllByRole('gridcell');
expect(gridcells).toHaveLength(2);
});
it('announces loading states to screen readers', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
data: {
pages: [{ data: [{ id: '1', name: 'Agent 1' }] }],
},
isFetching: true,
hasNextPage: true,
isFetchingNextPage: true,
isLoading: false,
error: null,
} as any);
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check for loading announcement when fetching more data
const loadingStatus = screen.getByRole('status');
expect(loadingStatus).toBeInTheDocument();
expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
expect(loadingStatus).toHaveAttribute('aria-label', 'Loading...');
// Check for screen reader text
const srText = screen.getByText('Loading...');
expect(srText).toHaveClass('sr-only');
});
it('provides accessible empty states', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
data: {
pages: [{ data: [] }],
},
isLoading: false,
isFetching: false,
error: null,
} as any);
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check empty state accessibility
const emptyState = screen.getByRole('status');
expect(emptyState).toHaveAttribute('aria-live', 'polite');
expect(emptyState).toHaveAttribute('aria-label', 'No agents found');
});
});
describe('ErrorDisplay Accessibility', () => {
const mockError = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
},
},
};
it('implements proper alert role and ARIA attributes', () => {
render(<ErrorDisplay error={mockError} onRetry={jest.fn()} />);
// Check alert role
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'assertive');
expect(alert).toHaveAttribute('aria-atomic', 'true');
// Check heading structure
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveAttribute('id', 'error-title');
});
it('provides accessible retry button', () => {
const onRetry = jest.fn();
render(<ErrorDisplay error={mockError} onRetry={onRetry} />);
const retryButton = screen.getByRole('button', { name: /retry action/i });
expect(retryButton).toHaveAttribute('aria-describedby', 'error-message error-suggestion');
fireEvent.click(retryButton);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('structures error content with proper semantics', () => {
render(<ErrorDisplay error={mockError} />);
// Check error message structure
expect(screen.getByText(/unable to load agents/i)).toHaveAttribute('id', 'error-message');
// Check suggestion note
const suggestion = screen.getByRole('note');
expect(suggestion).toHaveAttribute('aria-label', expect.stringContaining('Suggestion:'));
});
});
describe('Focus Management', () => {
it('maintains proper focus ring styles', () => {
const { container } = render(<SearchBar value="" onSearch={jest.fn()} />);
// Check for focus styles in CSS classes
const searchInput = container.querySelector('input');
expect(searchInput?.className).toContain('focus:');
});
it('provides visible focus indicators on interactive elements', () => {
render(
<CategoryTabs
categories={[{ value: 'test', label: 'Test', count: 1 }]}
activeTab="test"
isLoading={false}
onChange={jest.fn()}
/>,
);
const tab = screen.getByRole('tab');
expect(tab.className).toContain('focus:outline-none');
expect(tab.className).toContain('focus:bg-gray-100');
});
});
describe('Screen Reader Announcements', () => {
it('includes live regions for dynamic content', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check for live region
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeInTheDocument();
});
it('provides screen reader only content', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Check for screen reader only instructions
const srOnlyElement = document.querySelector('.sr-only');
expect(srOnlyElement).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,210 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentCard from '../AgentCard';
import type t from 'librechat-data-provider';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
};
return mockTranslations[key] || key;
});
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
name: 'Test Agent',
description: 'A test agent for testing purposes',
support_contact: {
name: 'Test Support',
email: 'test@example.com',
},
avatar: { filepath: '/test-avatar.png', source: 'local' },
created_at: 1672531200000,
instructions: 'Test instructions',
provider: 'openai' as const,
model: 'gpt-4',
model_parameters: {
temperature: 0.7,
maxContextTokens: 4096,
max_context_tokens: 4096,
max_output_tokens: 1024,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
},
};
const mockOnClick = jest.fn();
beforeEach(() => {
mockOnClick.mockClear();
});
it('renders agent information correctly', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Test Support')).toBeInTheDocument();
});
it('displays avatar when provided as object', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/test-avatar.png');
});
it('displays avatar when provided as string', () => {
const agentWithStringAvatar = {
...mockAgent,
avatar: '/string-avatar.png' as any, // Legacy support for string avatars
};
render(<AgentCard agent={agentWithStringAvatar} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
});
it('displays Bot icon fallback when no avatar is provided', () => {
const agentWithoutAvatar = {
...mockAgent,
avatar: undefined,
};
render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
// Check for Bot icon presence by looking for the svg with lucide-bot class
const botIcon = document.querySelector('.lucide-bot');
expect(botIcon).toBeInTheDocument();
});
it('calls onClick when card is clicked', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.click(card);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Enter key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Space key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: ' ' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick for other keys', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Escape' });
expect(mockOnClick).not.toHaveBeenCalled();
});
it('applies additional className when provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
const card = screen.getByRole('button');
expect(card).toHaveClass('custom-class');
});
it('handles missing support contact gracefully', () => {
const agentWithoutContact = {
...mockAgent,
support_contact: undefined,
authorName: undefined,
};
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
});
it('displays authorName when support_contact is missing', () => {
const agentWithAuthorName = {
...mockAgent,
support_contact: undefined,
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('displays support_contact email when name is missing', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: { email: 'contact@example.com' },
authorName: undefined,
};
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
});
it('prioritizes support_contact name over authorName', () => {
const agentWithBoth = {
...mockAgent,
support_contact: { name: 'Support Team' },
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
it('prioritizes name over email in support_contact', () => {
const agentWithNameAndEmail = {
...mockAgent,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
authorName: undefined,
};
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
});
});

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AgentCategoryDisplay from '../AgentCategoryDisplay';
// Mock the useAgentCategories hook
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: () => ({
categories: [
{
value: 'general',
label: 'General',
icon: <span data-testid="icon-general">{''}</span>,
className: 'w-full',
},
{
value: 'hr',
label: 'HR',
icon: <span data-testid="icon-hr">{''}</span>,
className: 'w-full',
},
{
value: 'rd',
label: 'R&D',
icon: <span data-testid="icon-rd">{''}</span>,
className: 'w-full',
},
{
value: 'finance',
label: 'Finance',
icon: <span data-testid="icon-finance">{''}</span>,
className: 'w-full',
},
],
emptyCategory: {
value: '',
label: 'General',
className: 'w-full',
},
}),
}));
describe('AgentCategoryDisplay', () => {
it('should display the proper label for a category', () => {
render(<AgentCategoryDisplay category="rd" />);
expect(screen.getByText('R&D')).toBeInTheDocument();
});
it('should display the icon when showIcon is true', () => {
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('should not display the icon when showIcon is false', () => {
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
});
it('should apply custom classnames', () => {
render(<AgentCategoryDisplay category="general" className="test-class" />);
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
});
it('should not render anything for unknown categories', () => {
const { container } = render(<AgentCategoryDisplay category="unknown" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything when no category is provided', () => {
const { container } = render(<AgentCategoryDisplay />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything for empty category when showEmptyFallback is false', () => {
const { container } = render(<AgentCategoryDisplay category="" />);
expect(container).toBeEmptyDOMElement();
});
it('should render empty category placeholder when showEmptyFallback is true', () => {
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('should apply custom iconClassName to the icon', () => {
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
const iconElement = screen.getByTestId('icon-general').parentElement;
expect(iconElement).toHaveClass('custom-icon-class');
});
});

View File

@@ -0,0 +1,384 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil';
import type t from 'librechat-data-provider';
import AgentDetail from '../AgentDetail';
import { useToast } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
// Mock dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
jest.mock('~/hooks', () => ({
useToast: jest.fn(),
useMediaQuery: jest.fn(() => false), // Mock as desktop by default
}));
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('~/utils/agents', () => ({
renderAgentAvatar: jest.fn((agent, options) => (
<div data-testid="agent-avatar" data-size={options?.size} />
)),
}));
// Mock clipboard API
const mockWriteText = jest.fn();
const mockNavigate = jest.fn();
const mockShowToast = jest.fn();
const mockLocalize = jest.fn((key: string) => key);
const mockAgent: t.Agent = {
id: 'test-agent-id',
name: 'Test Agent',
description: 'This is a test agent for unit testing',
avatar: {
filepath: '/path/to/avatar.png',
source: 'local' as const,
},
model: 'gpt-4',
provider: 'openai',
instructions: 'You are a helpful test agent',
tools: [],
author: 'test-user-id',
created_at: new Date().getTime(),
version: 1,
support_contact: {
name: 'Support Team',
email: 'support@test.com',
},
model_parameters: {
model: undefined,
temperature: null,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
};
// Helper function to render with providers
const renderWithProviders = (ui: React.ReactElement, options = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
);
return render(ui, { wrapper: Wrapper, ...options });
};
describe('AgentDetail', () => {
beforeEach(() => {
jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
(useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast });
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
// Setup clipboard mock if it doesn't exist
if (!navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: mockWriteText,
},
configurable: true,
});
} else {
// If clipboard exists, spy on it
jest.spyOn(navigator.clipboard, 'writeText').mockImplementation(mockWriteText);
}
mockWriteText.mockResolvedValue(undefined);
});
const defaultProps = {
agent: mockAgent,
isOpen: true,
onClose: jest.fn(),
};
describe('Rendering', () => {
it('should render agent details correctly', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('This is a test agent for unit testing')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toHaveAttribute('data-size', 'xl');
});
it('should render contact information when available', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
expect(screen.getByText('com_agents_contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
'href',
'mailto:support@test.com',
);
});
it('should not render contact information when not available', () => {
const agentWithoutContact = { ...mockAgent };
delete (agentWithoutContact as any).support_contact;
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithoutContact} />);
expect(screen.queryByText('com_agents_contact:')).not.toBeInTheDocument();
});
it('should render loading state when agent is null', () => {
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
});
it('should render 3-dot menu button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
expect(menuButton).toBeInTheDocument();
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render Start Chat button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
expect(startChatButton).toBeInTheDocument();
expect(startChatButton).not.toBeDisabled();
});
});
describe('Interactions', () => {
it('should navigate to chat when Start Chat button is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
await user.click(startChatButton);
expect(mockNavigate).toHaveBeenCalledWith('/c/new?agent_id=test-agent-id');
});
it('should not navigate when agent is null', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
expect(startChatButton).toBeDisabled();
await user.click(startChatButton);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should open dropdown when 3-dot menu is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
});
it('should close dropdown when clicking outside', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
// Click outside (on the agent name)
const agentName = screen.getByText('Test Agent');
await user.click(agentName);
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
).not.toBeInTheDocument();
});
});
it('should copy link and show success toast when Copy Link is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton);
// Click copy link
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton);
// Wait for async clipboard operation to complete
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith(
`${window.location.origin}/c/new?agent_id=test-agent-id`,
);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_link_copied',
});
});
// Dropdown should close
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
).not.toBeInTheDocument();
});
});
it('should show error toast when clipboard write fails', async () => {
const user = userEvent.setup();
mockWriteText.mockRejectedValue(new Error('Clipboard error'));
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown and click copy link
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton);
// Wait for clipboard operation to fail and error toast to show
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalled();
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_link_copy_failed',
});
});
});
it('should call onClose when dialog is closed', () => {
const mockOnClose = jest.fn();
renderWithProviders(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />);
// Since we're testing the onOpenChange callback, we need to trigger it
// This would normally be done by the Dialog component when ESC is pressed or overlay is clicked
// We'll test this by checking that onClose is properly passed to the Dialog
expect(mockOnClose).toBeDefined();
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
expect(menuButton).toHaveAttribute('aria-label', 'com_agents_more_options');
});
it('should support keyboard navigation for dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
// Focus and open with Enter key
menuButton.focus();
await user.keyboard('{Enter}');
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
});
it('should have proper focus management', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'com_agents_more_options' });
await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
expect(copyLinkButton).toHaveClass('focus:bg-surface-hover', 'focus:outline-none');
});
});
describe('Edge Cases', () => {
it('should handle agent with only email contact', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: {
email: 'support@test.com',
},
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithEmailOnly} />);
expect(screen.getByRole('link', { name: 'support@test.com' })).toBeInTheDocument();
});
it('should handle agent with only name contact', () => {
const agentWithNameOnly = {
...mockAgent,
support_contact: {
name: 'Support Team',
},
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithNameOnly} />);
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('should handle very long description with proper text wrapping', () => {
const agentWithLongDescription = {
...mockAgent,
description:
'This is a very long description that should wrap properly and be displayed in multiple lines when the content exceeds the available width of the container.',
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithLongDescription} />);
const description = screen.getByText(agentWithLongDescription.description);
expect(description).toHaveClass('whitespace-pre-wrap');
});
it('should handle special characters in agent name', () => {
const agentWithSpecialChars = {
...mockAgent,
name: 'Test Agent™ & Co. (v2.0)',
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithSpecialChars} />);
expect(screen.getByText('Test Agent™ & Co. (v2.0)')).toBeInTheDocument();
});
});
});

View File

@@ -1,31 +1,41 @@
import React from 'react';
import { SystemRoles } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { UseMutationResult } from '@tanstack/react-query';
import '@testing-library/jest-dom/extend-expect';
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
import AgentFooter from '../AgentFooter';
import { Panel } from '~/common';
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
import { SystemRoles } from 'librechat-data-provider';
import * as reactHookForm from 'react-hook-form';
import * as hooks from '~/hooks';
import type { UseMutationResult } from '@tanstack/react-query';
const mockUseWatch = jest.fn();
const mockUseAuthContext = jest.fn();
const mockUseHasAccess = jest.fn();
const mockUseResourcePermissions = jest.fn();
jest.mock('react-hook-form', () => ({
useFormContext: () => ({
control: {},
}),
useWatch: () => {
return {
agent: {
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
},
id: 'agent-123',
};
},
useWatch: (params) => mockUseWatch(params),
}));
// Default mock implementations
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
const mockUser = {
id: 'user-123',
username: 'testuser',
@@ -39,6 +49,26 @@ const mockUser = {
updatedAt: '2023-01-01T00:00:00.000Z',
} as TUser;
// Default auth context
mockUseAuthContext.mockReturnValue({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
});
// Default access and permissions
mockUseHasAccess.mockReturnValue(true);
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => true,
isLoading: false,
permissionBits: 0,
});
jest.mock('~/hooks', () => ({
useLocalize: () => (key) => {
const translations = {
@@ -47,17 +77,9 @@ jest.mock('~/hooks', () => ({
};
return translations[key] || key;
},
useAuthContext: () => ({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
}),
useHasAccess: () => true,
useAuthContext: () => mockUseAuthContext(),
useHasAccess: () => mockUseHasAccess(),
useResourcePermissions: () => mockUseResourcePermissions(),
}));
const createBaseMutation = <T = Agent, P = any>(
@@ -126,9 +148,9 @@ jest.mock('../DeleteButton', () => ({
default: jest.fn(() => <div data-testid="delete-button" />),
}));
jest.mock('../ShareAgent', () => ({
jest.mock('../Sharing/GrantAccessDialog', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="share-agent" />),
default: jest.fn(() => <div data-testid="grant-access-dialog" />),
}));
jest.mock('../DuplicateAgent', () => ({
@@ -186,6 +208,40 @@ describe('AgentFooter', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to default mock implementations
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
// Reset auth context to default user
mockUseAuthContext.mockReturnValue({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
});
// Reset access and permissions to defaults
mockUseHasAccess.mockReturnValue(true);
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => true,
isLoading: false,
permissionBits: 0,
});
});
describe('Main Functionality', () => {
@@ -196,8 +252,8 @@ describe('AgentFooter', () => {
expect(screen.getByTestId('version-button')).toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
});
@@ -227,42 +283,125 @@ describe('AgentFooter', () => {
});
test('adjusts UI based on agent ID existence', () => {
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
agent: { name: 'Test Agent', author: 'user-123' },
id: undefined,
}));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return null; // No agent means no delete/share/duplicate buttons
}
if (name === 'id') {
return undefined; // No ID means create mode
}
return undefined;
});
// When there's no agent, permissions should also return false
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false,
isLoading: false,
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByTestId('version-button')).toBeInTheDocument();
});
test('adjusts UI based on user role', () => {
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
jest.clearAllMocks();
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.getByText('Create')).toBeInTheDocument();
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
});
test('adjusts UI based on permissions', () => {
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
test('adjusts UI based on user role', () => {
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.admin));
const { unmount } = render(<AgentFooter {...defaultProps} />);
expect(screen.getByTestId('admin-settings')).toBeInTheDocument();
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
// Clean up the first render
unmount();
jest.clearAllMocks();
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.different));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return { name: 'Test Agent', author: 'different-author', _id: 'agent-123' };
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog')).toBeInTheDocument(); // Still shows because hasAccess is true
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument(); // Should not show for different author
});
test('adjusts UI based on permissions', () => {
mockUseHasAccess.mockReturnValue(false);
// Also need to ensure the agent is not owned by the user and user is not admin
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'different-user', // Different author
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
// Mock permissions to not allow sharing
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false, // No permissions
isLoading: false,
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
});
test('hides action buttons when permissions are loading', () => {
// Ensure we have an agent that would normally show buttons
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123', // Same as current user
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => true,
isLoading: true, // This should hide the buttons
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
// Duplicate button should still show as it doesn't depend on permissions loading
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
test('handles null agent data', () => {
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
agent: null,
id: 'agent-123',
}));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return null;
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();

View File

@@ -0,0 +1,376 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid';
import type t from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the marketplace agent query hook
jest.mock('~/data-provider/Agents', () => ({
useMarketplaceAgentsInfiniteQuery: jest.fn(),
}));
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: jest.fn(() => ({
categories: [],
isLoading: false,
error: null,
})),
}));
// Mock SmartLoader
jest.mock('../SmartLoader', () => ({
useHasData: jest.fn(() => true),
}));
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All Agents',
com_agents_recommended: 'Our recommended agents',
com_agents_results_for: 'Results for "{{query}}"',
com_agents_see_more: 'See more',
com_agents_error_loading: 'Error loading agents',
com_agents_error_searching: 'Error searching agents',
com_agents_no_results: 'No agents found. Try another search term.',
com_agents_none_in_category: 'No agents found in this category',
com_agents_search_empty_heading: 'No results found',
com_agents_empty_state_heading: 'No agents available',
com_agents_loading: 'Loading...',
com_agents_grid_announcement: '{{count}} agents in {{category}}',
com_agents_load_more_label: 'Load more agents from {{category}}',
};
let translation = mockTranslations[key] || key;
if (options) {
Object.keys(options).forEach((optionKey) => {
translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), options[optionKey]);
});
}
return translation;
});
// Mock ErrorDisplay component
jest.mock('../ErrorDisplay', () => ({
__esModule: true,
default: ({ error, onRetry }: { error: any; onRetry: () => void }) => (
<div>
<div>
{`Error: `}
{typeof error === 'string' ? error : error?.message || 'Unknown error'}
</div>
<button onClick={onRetry}>{`Retry`}</button>
</div>
),
}));
// Mock AgentCard component
jest.mock('../AgentCard', () => ({
__esModule: true,
default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => (
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>
),
}));
// Import the actual modules to get the mocked functions
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
const mockOnSelectAgent = jest.fn();
const mockAgents: t.Agent[] = [
{
id: '1',
name: 'Test Agent 1',
description: 'First test agent',
avatar: { filepath: '/avatar1.png', source: 'local' },
category: 'finance',
authorName: 'Author 1',
created_at: 1672531200000,
instructions: null,
provider: 'custom',
model: 'gpt-4',
model_parameters: {
temperature: null,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
},
{
id: '2',
name: 'Test Agent 2',
description: 'Second test agent',
avatar: { filepath: '/avatar2.png', source: 'local' },
category: 'finance',
authorName: 'Author 2',
created_at: 1672531200000,
instructions: null,
provider: 'custom',
model: 'gpt-4',
model_parameters: {
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
presence_penalty: null,
},
},
];
const defaultMockQueryResult = {
data: {
pages: [
{
data: mockAgents,
},
],
},
isLoading: false,
error: null,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: true,
fetchNextPage: jest.fn(),
refetch: jest.fn(),
} as any;
beforeEach(() => {
jest.clearAllMocks();
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(defaultMockQueryResult);
});
describe('Query Integration', () => {
it('should call useGetMarketplaceAgentsQuery with correct parameters for category search', () => {
render(
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
);
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
requiredPermission: 1,
category: 'finance',
search: 'test query',
limit: 6,
});
});
it('should call useGetMarketplaceAgentsQuery with promoted=1 for promoted category', () => {
render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
requiredPermission: 1,
promoted: 1,
limit: 6,
});
});
it('should call useGetMarketplaceAgentsQuery without category filter for "all" category', () => {
render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
requiredPermission: 1,
limit: 6,
});
});
it('should not include category in search when category is "all" or "promoted"', () => {
render(<AgentGrid category="all" searchQuery="test" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
requiredPermission: 1,
search: 'test',
limit: 6,
});
});
});
// Create wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Agent Display', () => {
it('should render agent cards when data is available', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
expect(screen.getByTestId('agent-card-2')).toBeInTheDocument();
expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
});
it('should call onSelectAgent when agent card is clicked', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
fireEvent.click(screen.getByTestId('agent-card-1'));
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
});
});
describe('Loading States', () => {
it('should show loading state when isLoading is true', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
isLoading: true,
data: undefined,
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
// Should show skeleton loading state
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
});
it('should show empty state when no agents are available', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
pages: [
{
data: [],
},
],
},
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.getByText('No agents available')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should show error display when query has error', () => {
const mockError = new Error('Failed to fetch agents');
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
error: mockError,
isError: true,
data: undefined,
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.getByText('Error: Failed to fetch agents')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});
describe('Search Results', () => {
it('should show search results title when searching', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="finance"
searchQuery="automation"
onSelectAgent={mockOnSelectAgent}
/>
</Wrapper>,
);
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
});
it('should show empty search results message', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
pages: [
{
data: [],
},
],
},
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid
category="finance"
searchQuery="nonexistent"
onSelectAgent={mockOnSelectAgent}
/>
</Wrapper>,
);
expect(screen.getByText('No results found')).toBeInTheDocument();
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
});
});
describe('Load More Functionality', () => {
it('should show "See more" button when hasNextPage is true', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(
screen.getByRole('button', { name: 'Load more agents from Finance' }),
).toBeInTheDocument();
});
it('should not show "See more" button when hasNextPage is false', () => {
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
...defaultMockQueryResult,
hasNextPage: false,
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
</Wrapper>,
);
expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,229 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import CategoryTabs from '../CategoryTabs';
import type t from 'librechat-data-provider';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All',
com_agents_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General',
com_ui_agent_category_hr: 'HR',
com_ui_agent_category_rd: 'R&D',
com_ui_agent_category_finance: 'Finance',
com_ui_agent_category_it: 'IT',
com_ui_agent_category_sales: 'Sales',
com_ui_agent_category_aftersales: 'After Sales',
};
return mockTranslations[key] || key;
});
describe('CategoryTabs', () => {
const mockCategories: t.TMarketplaceCategory[] = [
{ value: 'promoted', label: 'Top Picks', description: 'Our recommended agents', count: 5 },
{ value: 'all', label: 'All', description: 'All available agents', count: 20 },
{ value: 'general', label: 'General', description: 'General purpose agents', count: 8 },
{ value: 'hr', label: 'HR', description: 'HR agents', count: 3 },
{ value: 'finance', label: 'Finance', description: 'Finance agents', count: 4 },
];
const mockOnChange = jest.fn();
const user = userEvent.setup();
beforeEach(() => {
mockOnChange.mockClear();
});
it('renders provided categories', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
// Check for provided categories
expect(screen.getByText('Top Picks')).toBeInTheDocument();
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('General')).toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('handles loading state properly', () => {
render(
<CategoryTabs
categories={[]}
activeTab="promoted"
isLoading={true}
onChange={mockOnChange}
/>,
);
// SmartLoader should handle loading behavior correctly
// The component should render without crashing during loading
expect(screen.queryByText('No categories available')).not.toBeInTheDocument();
});
it('highlights the active tab', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="general"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-900');
// Should have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');
expect(underline).toBeInTheDocument();
});
it('calls onChange when a tab is clicked', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const hrTab = screen.getByText('HR');
await user.click(hrTab);
expect(mockOnChange).toHaveBeenCalledWith('hr');
});
it('handles promoted tab click correctly', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="general"
isLoading={false}
onChange={mockOnChange}
/>,
);
const topPicksTab = screen.getByText('Top Picks');
await user.click(topPicksTab);
expect(mockOnChange).toHaveBeenCalledWith('promoted');
});
it('handles all tab click correctly', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const allTab = screen.getByText('All');
await user.click(allTab);
expect(mockOnChange).toHaveBeenCalledWith('all');
});
it('shows inactive state for non-selected tabs', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-600');
// Should not have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');
expect(underline).not.toBeInTheDocument();
});
it('renders with proper accessibility', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const tabs = screen.getAllByRole('tab');
expect(tabs.length).toBe(5);
// Verify all tabs are properly clickable buttons
tabs.forEach((tab) => {
expect(tab.tagName).toBe('BUTTON');
});
});
it('handles keyboard navigation', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button')!;
// Focus the button and click it
generalTab.focus();
expect(document.activeElement).toBe(generalTab);
await user.click(generalTab);
expect(mockOnChange).toHaveBeenCalledWith('general');
});
it('shows empty state when categories prop is empty', () => {
render(
<CategoryTabs
categories={[]}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
// Should show empty state message (localized)
expect(screen.getByText('No categories available')).toBeInTheDocument();
});
it('maintains consistent ordering of categories', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const tabs = screen.getAllByRole('tab');
const tabTexts = tabs.map((tab) => tab.textContent);
// Check that promoted is first and all is second
expect(tabTexts[0]).toBe('Top Picks');
expect(tabTexts[1]).toBe('All');
expect(tabTexts.length).toBe(5);
});
});

View File

@@ -0,0 +1,303 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorDisplay } from '../ErrorDisplay';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock the localize hook
const mockLocalize = jest.fn((key: string, options?: any) => {
const translations: Record<string, string> = {
com_agents_error_title: 'Something went wrong',
com_agents_error_generic: 'We encountered an issue while loading the content.',
com_agents_error_suggestion_generic: 'Please try refreshing the page or try again later.',
com_agents_error_network_title: 'Connection Problem',
com_agents_error_network_message: 'Unable to connect to the server.',
com_agents_error_network_suggestion: 'Check your internet connection and try again.',
com_agents_error_not_found_title: 'Not Found',
com_agents_error_not_found_message: 'The requested content could not be found.',
com_agents_error_not_found_suggestion:
'Try browsing other options or go back to the marketplace.',
com_agents_error_invalid_request: 'Invalid Request',
com_agents_error_bad_request_message: 'The request could not be processed.',
com_agents_error_bad_request_suggestion: 'Please check your input and try again.',
com_agents_error_server_title: 'Server Error',
com_agents_error_server_message: 'The server is temporarily unavailable.',
com_agents_error_server_suggestion: 'Please try again in a few moments.',
com_agents_error_search_title: 'Search Error',
com_agents_error_category_title: 'Category Error',
com_agents_error_timeout_title: 'Connection Timeout',
com_agents_error_timeout_message: 'The request took too long to complete.',
com_agents_error_timeout_suggestion: 'Please check your internet connection and try again.',
com_agents_search_no_results: `No agents found for "${options?.query}"`,
com_agents_category_empty: `No agents found in the ${options?.category} category`,
com_agents_error_retry: 'Try Again',
};
return translations[key] || key;
});
jest.mock('~/hooks/useLocalize', () => () => mockLocalize);
describe('ErrorDisplay', () => {
beforeEach(() => {
mockLocalize.mockClear();
});
describe('Backend error responses', () => {
it('displays user-friendly message from backend response', () => {
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('Unable to load agents. Please try refreshing the page.'),
).toBeInTheDocument();
expect(
screen.getByText('💡 Try refreshing the page or check your network connection'),
).toBeInTheDocument();
});
it('handles search context with backend response', () => {
const error = {
response: {
data: {
userMessage: 'Search is temporarily unavailable. Please try again.',
suggestion: 'Try a different search term or check your network connection',
},
},
};
render(<ErrorDisplay error={error} context={{ searchQuery: 'test query' }} />);
expect(screen.getByText('Search Error')).toBeInTheDocument();
expect(
screen.getByText('Search is temporarily unavailable. Please try again.'),
).toBeInTheDocument();
});
});
describe('Network errors', () => {
it('displays network error message', () => {
const error = {
code: 'NETWORK_ERROR',
message: 'Network Error',
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Connection Problem')).toBeInTheDocument();
expect(screen.getByText('Unable to connect to the server.')).toBeInTheDocument();
expect(
screen.getByText('💡 Check your internet connection and try again.'),
).toBeInTheDocument();
});
it('handles timeout errors', () => {
const error = {
code: 'ECONNABORTED',
message: 'timeout of 5000ms exceeded',
};
render(<ErrorDisplay error={error} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_title');
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_message');
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_suggestion');
});
});
describe('HTTP status codes', () => {
it('handles 404 errors with search context', () => {
const error = {
response: {
status: 404,
data: {},
},
};
render(<ErrorDisplay error={error} context={{ searchQuery: 'nonexistent agent' }} />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
expect(screen.getByText('No agents found for "nonexistent agent"')).toBeInTheDocument();
});
it('handles 404 errors with category context', () => {
const error = {
response: {
status: 404,
data: {},
},
};
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
expect(screen.getByText('No agents found in the productivity category')).toBeInTheDocument();
});
it('handles 400 bad request errors', () => {
const error = {
response: {
status: 400,
data: {
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Invalid Request')).toBeInTheDocument();
expect(screen.getByText('Please enter a search term to find agents')).toBeInTheDocument();
expect(
screen.getByText('💡 Enter a search term to find agents by name or description'),
).toBeInTheDocument();
});
it('handles 500 server errors', () => {
const error = {
response: {
status: 500,
data: {},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Server Error')).toBeInTheDocument();
expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument();
expect(screen.getByText('💡 Please try again in a few moments.')).toBeInTheDocument();
});
});
describe('Retry functionality', () => {
it('displays retry button when onRetry is provided', () => {
const mockRetry = jest.fn();
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
},
},
};
render(<ErrorDisplay error={error} onRetry={mockRetry} />);
const retryButton = screen.getByText('Try Again');
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
expect(mockRetry).toHaveBeenCalledTimes(1);
});
it('does not display retry button when onRetry is not provided', () => {
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.queryByText('Try Again')).not.toBeInTheDocument();
});
});
describe('Context-aware titles', () => {
it('shows search error title for search context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} context={{ searchQuery: 'test' }} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_search_title');
});
it('shows category error title for category context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_category_title');
});
it('shows generic error title when no context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_title');
});
});
describe('Fallback error handling', () => {
it('handles unknown errors gracefully', () => {
const error = {
message: 'Unknown error occurred',
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('We encountered an issue while loading the content.'),
).toBeInTheDocument();
expect(
screen.getByText('💡 Please try refreshing the page or try again later.'),
).toBeInTheDocument();
});
it('handles null/undefined errors', () => {
render(<ErrorDisplay error={null} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('We encountered an issue while loading the content.'),
).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('renders error icon with proper accessibility', () => {
const error = { message: 'Test error' };
render(<ErrorDisplay error={error} />);
const errorIcon = screen.getByRole('img', { hidden: true });
expect(errorIcon).toBeInTheDocument();
});
it('has proper heading structure', () => {
const error = { message: 'Test error' };
render(<ErrorDisplay error={error} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Something went wrong');
});
});
});

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { EModelEndpoint } from 'librechat-data-provider';
import { MarketplaceProvider } from '../MarketplaceContext';
import { useChatContext } from '~/Providers';
// Mock the ChatContext from Providers
jest.mock('~/Providers', () => ({
ChatContext: {
Provider: ({ children, value }: { children: React.ReactNode; value: any }) => (
<div data-testid="chat-context-provider" data-value={JSON.stringify(value)}>
{children}
</div>
),
},
useChatContext: jest.fn(),
}));
const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
// Test component that consumes the context
const TestConsumer: React.FC = () => {
const context = mockedUseChatContext();
return (
<div>
<div data-testid="endpoint">{context?.conversation?.endpoint}</div>
<div data-testid="conversation-id">{context?.conversation?.conversationId}</div>
<div data-testid="title">{context?.conversation?.title}</div>
</div>
);
};
describe('MarketplaceProvider', () => {
beforeEach(() => {
mockedUseChatContext.mockClear();
});
it('provides correct marketplace context values', () => {
const mockContext = {
conversation: {
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
},
};
mockedUseChatContext.mockReturnValue(mockContext);
render(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
expect(screen.getByTestId('endpoint')).toHaveTextContent(EModelEndpoint.agents);
expect(screen.getByTestId('conversation-id')).toHaveTextContent('marketplace');
expect(screen.getByTestId('title')).toHaveTextContent('Agent Marketplace');
});
it('creates ChatContext.Provider with correct structure', () => {
render(
<MarketplaceProvider>
<div>{/* eslint-disable-line i18next/no-literal-string */}Test Child</div>
</MarketplaceProvider>,
);
const provider = screen.getByTestId('chat-context-provider');
expect(provider).toBeInTheDocument();
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
expect(valueData.conversation).toEqual({
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
});
});
it('renders children correctly', () => {
render(
<MarketplaceProvider>
<div data-testid="test-child">
{/* eslint-disable-line i18next/no-literal-string */}Test Content
</div>
</MarketplaceProvider>,
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
expect(screen.getByTestId('test-child')).toHaveTextContent('Test Content');
});
it('provides stable context value (memoization)', () => {
const { rerender } = render(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
const firstProvider = screen.getByTestId('chat-context-provider');
const firstValue = firstProvider.getAttribute('data-value');
// Rerender should provide the same memoized value
rerender(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
const secondProvider = screen.getByTestId('chat-context-provider');
const secondValue = secondProvider.getAttribute('data-value');
expect(firstValue).toBe(secondValue);
});
it('provides minimal context without bloated functions', () => {
render(
<MarketplaceProvider>
<div>{/* eslint-disable-line i18next/no-literal-string */}Test</div>
</MarketplaceProvider>,
);
const provider = screen.getByTestId('chat-context-provider');
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
// Should only have conversation object, not 44 empty functions
expect(Object.keys(valueData)).toContain('conversation');
expect(valueData.conversation).toEqual({
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
});
});
});

View File

@@ -0,0 +1,141 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import SearchBar from '../SearchBar';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
// Mock useDebounce hook
jest.mock('~/hooks', () => ({
useDebounce: (value: string) => value, // Return value immediately for testing
}));
describe('SearchBar', () => {
const mockOnSearch = jest.fn();
const user = userEvent.setup();
beforeEach(() => {
mockOnSearch.mockClear();
});
it('renders with correct placeholder', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('placeholder', 'com_agents_search_placeholder');
});
it('displays the provided value', () => {
render(<SearchBar value="test query" onSearch={mockOnSearch} />);
const input = screen.getByDisplayValue('test query');
expect(input).toBeInTheDocument();
});
it('calls onSearch when user types', async () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
await user.type(input, 'test');
// Should call onSearch for each character due to debounce mock
expect(mockOnSearch).toHaveBeenCalled();
});
it('shows clear button when there is text', () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
expect(clearButton).toBeInTheDocument();
});
it('does not show clear button when text is empty', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const clearButton = screen.queryByRole('button', { name: 'com_agents_clear_search' });
expect(clearButton).not.toBeInTheDocument();
});
it('clears search when clear button is clicked', async () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
// Verify initial state
expect(input).toHaveValue('test');
await user.click(clearButton);
// Verify onSearch is called and input is cleared
expect(mockOnSearch).toHaveBeenCalledWith('');
expect(input).toHaveValue('');
});
it('updates internal state when value prop changes', () => {
const { rerender } = render(<SearchBar value="initial" onSearch={mockOnSearch} />);
expect(screen.getByDisplayValue('initial')).toBeInTheDocument();
rerender(<SearchBar value="updated" onSearch={mockOnSearch} />);
expect(screen.getByDisplayValue('updated')).toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-label', 'com_agents_search_aria');
});
it('applies custom className', () => {
render(<SearchBar value="" onSearch={mockOnSearch} className="custom-class" />);
const container = screen.getByRole('textbox').closest('div');
expect(container).toHaveClass('custom-class');
});
it('prevents form submission on clear button click', async () => {
const handleSubmit = jest.fn();
render(
<form onSubmit={handleSubmit}>
<SearchBar value="test" onSearch={mockOnSearch} />
</form>,
);
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
await user.click(clearButton);
expect(handleSubmit).not.toHaveBeenCalled();
});
it('handles rapid typing correctly', async () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
// Type multiple characters quickly
await user.type(input, 'quick');
// Should handle all characters
expect(input).toHaveValue('quick');
});
it('maintains focus after clear button click', async () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
input.focus();
await user.click(clearButton);
// Input should still be in the document and ready for new input
expect(input).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,370 @@
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { SmartLoader, useHasData } from '../SmartLoader';
// Mock setTimeout and clearTimeout for testing
jest.useFakeTimers();
describe('SmartLoader', () => {
const LoadingComponent = () => <div data-testid="loading">Loading...</div>;
const ContentComponent = () => (
<div data-testid="content">
{/* eslint-disable-line i18next/no-literal-string */}Content loaded
</div>
);
beforeEach(() => {
jest.clearAllTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.useFakeTimers();
});
describe('Basic functionality', () => {
it('shows content immediately when not loading', () => {
render(
<SmartLoader isLoading={false} hasData={true} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('shows content immediately when loading but has existing data', () => {
render(
<SmartLoader isLoading={true} hasData={true} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('shows content initially, then loading after delay when loading with no data', async () => {
render(
<SmartLoader
isLoading={true}
hasData={false}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Initially shows content
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// After delay, shows loading
act(() => {
jest.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
expect(screen.queryByTestId('content')).not.toBeInTheDocument();
});
});
it('prevents loading flash for quick responses', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Initially shows content
expect(screen.getByTestId('content')).toBeInTheDocument();
// Advance time but not past delay
act(() => {
jest.advanceTimersByTime(100);
});
// Loading finishes before delay
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should still show content, never showed loading
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// Advance past original delay to ensure loading doesn't appear
act(() => {
jest.advanceTimersByTime(100);
});
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
describe('Delay behavior', () => {
it('respects custom delay times', async () => {
render(
<SmartLoader
isLoading={true}
hasData={false}
delay={300}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should show content initially
expect(screen.getByTestId('content')).toBeInTheDocument();
// Should not show loading before delay
act(() => {
jest.advanceTimersByTime(250);
});
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// Should show loading after delay
act(() => {
jest.advanceTimersByTime(60);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
it('uses default delay when not specified', async () => {
render(
<SmartLoader isLoading={true} hasData={false} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
// Should show content initially
expect(screen.getByTestId('content')).toBeInTheDocument();
// Should show loading after default delay (150ms)
act(() => {
jest.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
});
describe('State transitions', () => {
it('immediately hides loading when loading completes', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Advance past delay to show loading
act(() => {
jest.advanceTimersByTime(100);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
// Loading completes
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should immediately show content
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('handles rapid loading state changes correctly', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Rapid state changes
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
rerender(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should show content throughout rapid changes
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
describe('CSS classes', () => {
it('applies custom className', () => {
const { container } = render(
<SmartLoader
isLoading={false}
hasData={true}
loadingComponent={<LoadingComponent />}
className="custom-class"
>
<ContentComponent />
</SmartLoader>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('custom-class');
});
it('applies className to both loading and content states', async () => {
const { container } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={50}
loadingComponent={<LoadingComponent />}
className="custom-class"
>
<ContentComponent />
</SmartLoader>,
);
// Content state
expect(container.firstChild).toHaveClass('custom-class');
// Loading state
act(() => {
jest.advanceTimersByTime(50);
});
await waitFor(() => {
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
});
describe('useHasData', () => {
const TestComponent: React.FC<{ data: any }> = ({ data }) => {
const hasData = useHasData(data);
return <div data-testid="result">{hasData ? 'has-data' : 'no-data'}</div>;
};
it('returns false for null data', () => {
render(<TestComponent data={null} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('returns false for undefined data', () => {
render(<TestComponent data={undefined} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects empty agents array as no data', () => {
render(<TestComponent data={{ agents: [] }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects non-empty agents array as has data', () => {
render(<TestComponent data={{ agents: [{ id: '1', name: 'Test' }] }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects invalid agents property as no data', () => {
render(<TestComponent data={{ agents: 'not-array' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects empty array as no data', () => {
render(<TestComponent data={[]} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects non-empty array as has data', () => {
render(<TestComponent data={[{ name: 'category1' }]} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects agent with id as has data', () => {
render(<TestComponent data={{ id: '123', name: 'Test Agent' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects agent with name only as has data', () => {
render(<TestComponent data={{ name: 'Test Agent' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects object without id or name as no data', () => {
render(<TestComponent data={{ description: 'Some description' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles string data as no data', () => {
render(<TestComponent data="some string" />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles number data as no data', () => {
render(<TestComponent data={42} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles boolean data as no data', () => {
render(<TestComponent data={true} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
});

View File

@@ -97,7 +97,13 @@ const Dropdown: React.FC<DropdownProps> = ({
<Select.SelectPopover
portal={portal}
store={selectProps}
className={cn('popover-ui', sizeClasses, className, 'max-h-[80vh] overflow-y-auto')}
className={cn(
'popover-ui',
sizeClasses,
className,
'max-h-[80vh] overflow-y-auto',
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
)}
>
{options.map((item, index) => {
if (isDivider(item)) {

View File

@@ -0,0 +1,192 @@
'use client';
import * as React from 'react';
import * as Ariakit from '@ariakit/react';
import { Search, X } from 'lucide-react';
import { cn } from '~/utils';
import { Spinner } from '~/components/svg';
import { Skeleton } from '~/components/ui';
import { useLocalize } from '~/hooks';
type SearchPickerProps<TOption extends { key: string }> = {
options: TOption[];
renderOptions: (option: TOption) => React.ReactElement;
query: string;
onQueryChange: (query: string) => void;
onPick: (pickedOption: TOption) => void;
placeholder?: string;
inputClassName?: string;
label: string;
resetValueOnHide?: boolean;
isSmallScreen?: boolean;
isLoading?: boolean;
minQueryLengthForNoResults?: number;
};
export function SearchPicker<TOption extends { key: string; value: string }>({
options,
renderOptions,
onPick,
onQueryChange,
query,
label,
isSmallScreen = false,
placeholder,
resetValueOnHide = false,
isLoading = false,
minQueryLengthForNoResults = 2,
}: SearchPickerProps<TOption>) {
const localize = useLocalize();
const [_open, setOpen] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const combobox = Ariakit.useComboboxStore({
resetValueOnHide,
});
const onPickHandler = (option: TOption) => {
onQueryChange('');
onPick(option);
setOpen(false);
if (inputRef.current) {
inputRef.current.focus();
}
};
const showClearIcon = query.trim().length > 0;
const clearText = () => {
onQueryChange('');
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<Ariakit.ComboboxProvider store={combobox}>
<Ariakit.ComboboxLabel className="text-token-text-primary mb-2 block font-medium">
{label}
</Ariakit.ComboboxLabel>
<div className="py-1.5">
<div
className={cn(
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
)}
>
{isLoading ? (
<Spinner className="absolute left-3 h-4 w-4 text-text-primary" />
) : (
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
)}
<Ariakit.Combobox
ref={inputRef}
onKeyDown={(e) => {
if (e.key === 'Escape' && combobox.getState().open) {
e.preventDefault();
e.stopPropagation();
onQueryChange('');
setOpen(false);
}
}}
store={combobox}
setValueOnClick={false}
setValueOnChange={false}
onChange={(e) => {
onQueryChange(e.target.value);
}}
value={query}
// autoSelect
placeholder={placeholder || localize('com_ui_select_options')}
className="m-0 mr-0 w-full rounded-md border-none bg-transparent p-0 py-2 pl-9 pr-3 text-sm leading-tight text-text-primary placeholder-text-secondary placeholder-opacity-100 focus:outline-none focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
/>
<button
type="button"
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
className={cn(
'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
showClearIcon ? 'opacity-100' : 'opacity-0',
isSmallScreen === true ? 'right-[16px]' : '',
)}
onClick={clearText}
tabIndex={showClearIcon ? 0 : -1}
disabled={!showClearIcon}
>
<X className="h-5 w-5 cursor-pointer" />
</button>
</div>
</div>
<Ariakit.ComboboxPopover
portal={false} //todo fix focus when set to true
gutter={10}
// sameWidth
open={
isLoading ||
options.length > 0 ||
(query.trim().length >= minQueryLengthForNoResults && !isLoading)
}
store={combobox}
unmountOnHide
autoFocusOnShow={false}
modal={false}
className={cn(
'animate-popover z-[9999] min-w-64 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
)}
>
{(() => {
if (isLoading) {
return (
<div className="space-y-2 p-2">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex items-center gap-3 px-3 py-2">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
);
}
if (options.length > 0) {
return options.map((o) => (
<Ariakit.ComboboxItem
key={o.key}
focusOnHover
// hideOnClick
value={o.value}
selectValueOnClick={false}
onClick={() => onPickHandler(o)}
className={cn(
'flex w-full cursor-pointer items-center px-3 text-sm',
'text-text-primary hover:bg-surface-tertiary',
'data-[active-item]:bg-surface-tertiary',
)}
render={renderOptions(o)}
></Ariakit.ComboboxItem>
));
}
if (query.trim().length >= minQueryLengthForNoResults) {
return (
<div
className={cn(
'flex items-center justify-center px-4 py-8 text-center',
'text-sm text-text-secondary',
)}
>
<div className="flex flex-col items-center gap-2">
<Search className="h-8 w-8 text-text-tertiary opacity-50" />
<div className="font-medium">{localize('com_ui_no_results_found')}</div>
<div className="text-xs text-text-tertiary">
{localize('com_ui_try_adjusting_search')}
</div>
</div>
</div>
);
}
return null;
})()}
</Ariakit.ComboboxPopover>
</Ariakit.ComboboxProvider>
);
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import type { Option } from '~/common';
@@ -32,6 +32,7 @@ function SelectDropDownPop({
footer,
}: SelectDropDownProps) {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const transitionProps = { className: 'top-full mt-3' };
if (showAbove) {
transitionProps.className = 'bottom-full mb-3';
@@ -54,8 +55,13 @@ function SelectDropDownPop({
const hasSearchRender = Boolean(searchRender);
const options = hasSearchRender ? filteredValues : availableValues;
const handleSelect = (selectedValue: string) => {
setValue(selectedValue);
setOpen(false);
};
return (
<Root>
<Root open={open} onOpenChange={setOpen}>
<div className={'flex items-center justify-center gap-2'}>
<div className={'relative w-full'}>
<Trigger asChild>
@@ -108,19 +114,32 @@ function SelectDropDownPop({
side="bottom"
align="start"
className={cn(
'mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
'z-50 mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
hasSearchRender && 'relative',
)}
>
{searchRender}
{options.map((option) => {
if (typeof option === 'string') {
return (
<MenuItem
key={option}
title={option}
value={option}
selected={!!(value && value === option)}
onClick={() => handleSelect(option)}
/>
);
}
return (
<MenuItem
key={option}
title={option}
value={option}
selected={!!(value && value === option)}
onClick={() => setValue(option)}
key={option.value}
title={option.label}
description={option.description}
value={option.value}
icon={option.icon}
selected={!!(value && value === option.value)}
onClick={() => handleSelect(option.value)}
/>
);
})}

View File

@@ -0,0 +1,12 @@
import { TranslationKeys } from '~/hooks/useLocalize';
export interface AgentCategory {
label: TranslationKeys;
value: string;
}
// The empty category placeholder - used for form defaults
export const EMPTY_AGENT_CATEGORY: AgentCategory = {
value: '',
label: 'com_ui_agent_category_general',
};

View File

@@ -1,2 +1,5 @@
export * from './queries';
export * from './mutations';
// Re-export specific marketplace queries for easier imports
export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries';

View File

@@ -1,12 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import { dataService, MutationKeys, PERMISSION_BITS, QueryKeys } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type { QueryClient, UseMutationResult } from '@tanstack/react-query';
/**
* AGENTS
*/
export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [
{ requiredPermission: PERMISSION_BITS.VIEW },
{ requiredPermission: PERMISSION_BITS.EDIT },
];
/**
* Create a new agent
*/
@@ -18,21 +21,22 @@ export const useCreateAgentMutation = (
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (newAgent, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (!listRes) {
return options?.onSuccess?.(newAgent, variables, context);
}
const currentAgents = [newAgent, ...JSON.parse(JSON.stringify(listRes.data))];
if (!listRes) {
return options?.onSuccess?.(newAgent, variables, context);
}
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data: currentAgents,
});
});
})(allAgentViewAndEditQueryKeys);
invalidateAgentMarketplaceQueries(queryClient);
const currentAgents = [newAgent, ...JSON.parse(JSON.stringify(listRes.data))];
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: currentAgents,
});
return options?.onSuccess?.(newAgent, variables, context);
},
});
@@ -63,26 +67,33 @@ export const useUpdateAgentMutation = (
return options?.onError?.(typedError, variables, context);
},
onSuccess: (updatedAgent, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (!listRes) {
return options?.onSuccess?.(updatedAgent, variables, context);
}
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
if (!listRes) {
return options?.onSuccess?.(updatedAgent, variables, context);
}
return agent;
}),
});
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
}
return agent;
}),
});
});
})(allAgentViewAndEditQueryKeys);
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
queryClient.setQueryData<t.Agent>(
[QueryKeys.agent, variables.agent_id, 'expanded'],
updatedAgent,
);
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.(updatedAgent, variables, context);
},
},
@@ -104,23 +115,28 @@ export const useDeleteAgentMutation = (
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (_data, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
const data = ((keys: t.AgentListParams[]) => {
let data: t.Agent[] = [];
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (!listRes) {
return options?.onSuccess?.(_data, variables, context);
}
if (!listRes) {
return options?.onSuccess?.(_data, variables, context);
}
const data = listRes.data.filter((agent) => agent.id !== variables.agent_id);
data = listRes.data.filter((agent) => agent.id !== variables.agent_id);
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data,
});
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data,
});
});
return data;
})(allAgentViewAndEditQueryKeys);
queryClient.removeQueries([QueryKeys.agent, variables.agent_id]);
queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']);
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.(_data, variables, data);
},
@@ -142,22 +158,23 @@ export const useDuplicateAgentMutation = (
onMutate: options?.onMutate,
onError: options?.onError,
onSuccess: ({ agent, actions }, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
if (listRes) {
const currentAgents = [agent, ...listRes.data];
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: currentAgents,
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (listRes) {
const currentAgents = [agent, ...listRes.data];
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data: currentAgents,
});
}
});
}
})(allAgentViewAndEditQueryKeys);
const existingActions = queryClient.getQueryData<t.Action[]>([QueryKeys.actions]) || [];
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], existingActions.concat(actions));
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.({ agent, actions }, variables, context);
},
@@ -202,26 +219,25 @@ export const useUpdateAgentAction = (
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updateAgentActionResponse, variables, context) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
if (!listRes) {
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
}
const updatedAgent = updateAgentActionResponse[0];
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
if (!listRes) {
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
}
return agent;
}),
});
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return updatedAgent;
}
return agent;
}),
});
});
})(allAgentViewAndEditQueryKeys);
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
if (!prev) {
@@ -241,6 +257,10 @@ export const useUpdateAgentAction = (
});
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
queryClient.setQueryData<t.Agent>(
[QueryKeys.agent, variables.agent_id, 'expanded'],
updatedAgent,
);
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
},
});
@@ -271,30 +291,29 @@ export const useDeleteAgentAction = (
return action.action_id !== variables.action_id;
});
});
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], (prev) => {
if (!prev) {
return prev;
}
queryClient.setQueryData<t.AgentListResponse>(
[QueryKeys.agents, defaultOrderQuery],
(prev) => {
if (!prev) {
return prev;
}
return {
...prev,
data: prev.data.map((agent) => {
if (agent.id === variables.agent_id) {
return {
...agent,
tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')),
};
}
return agent;
}),
};
},
);
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], (prev) => {
return {
...prev,
data: prev.data.map((agent) => {
if (agent.id === variables.agent_id) {
return {
...agent,
tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')),
};
}
return agent;
}),
};
});
});
})(allAgentViewAndEditQueryKeys);
const updaterFn = (prev) => {
if (!prev) {
return prev;
}
@@ -303,7 +322,12 @@ export const useDeleteAgentAction = (
...prev,
tools: prev.tools?.filter((tool) => !tool.includes(domain ?? '')),
};
});
};
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updaterFn);
queryClient.setQueryData<t.Agent>(
[QueryKeys.agent, variables.agent_id, 'expanded'],
updaterFn,
);
return options?.onSuccess?.(_data, variables, context);
},
});
@@ -329,25 +353,30 @@ export const useRevertAgentVersionMutation = (
onSuccess: (revertedAgent, variables, context) => {
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
const listRes = queryClient.getQueryData<t.AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
if (listRes) {
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return revertedAgent;
}
return agent;
}),
if (listRes) {
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
...listRes,
data: listRes.data.map((agent) => {
if (agent.id === variables.agent_id) {
return revertedAgent;
}
return agent;
}),
});
}
});
}
})(allAgentViewAndEditQueryKeys);
return options?.onSuccess?.(revertedAgent, variables, context);
},
},
);
};
export const invalidateAgentMarketplaceQueries = (queryClient: QueryClient) => {
queryClient.invalidateQueries([QueryKeys.marketplaceAgents]);
};

View File

@@ -1,12 +1,19 @@
import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
import { QueryKeys, dataService, EModelEndpoint, PERMISSION_BITS } from 'librechat-data-provider';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type {
QueryObserverResult,
UseQueryOptions,
UseInfiniteQueryOptions,
} from '@tanstack/react-query';
import type t from 'librechat-data-provider';
/**
* AGENTS
*/
export const defaultAgentParams: t.AgentListParams = {
limit: 10,
requiredPermission: PERMISSION_BITS.EDIT,
};
/**
* Hook for getting all available tools for A
*/
@@ -27,7 +34,7 @@ export const useAvailableAgentToolsQuery = (): QueryObserverResult<t.TPlugin[]>
* Hook for listing all Agents, with optional parameters provided for pagination and sorting
*/
export const useListAgentsQuery = <TData = t.AgentListResponse>(
params: t.AgentListParams = defaultOrderQuery,
params: t.AgentListParams = defaultAgentParams,
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
@@ -54,7 +61,7 @@ export const useListAgentsQuery = <TData = t.AgentListResponse>(
};
/**
* Hook for retrieving details about a single agent
* Hook for retrieving basic details about a single agent (VIEW permission)
*/
export const useGetAgentByIdQuery = (
agent_id: string,
@@ -75,3 +82,83 @@ export const useGetAgentByIdQuery = (
},
);
};
/**
* Hook for retrieving full agent details including sensitive configuration (EDIT permission)
*/
export const useGetExpandedAgentByIdQuery = (
agent_id: string,
config?: UseQueryOptions<t.Agent>,
): QueryObserverResult<t.Agent> => {
return useQuery<t.Agent>(
[QueryKeys.agent, agent_id, 'expanded'],
() =>
dataService.getExpandedAgentById({
agent_id,
}),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
},
);
};
/**
* MARKETPLACE
*/
/**
* Hook for getting agent categories for marketplace tabs
*/
export const useGetAgentCategoriesQuery = (
config?: UseQueryOptions<t.TMarketplaceCategory[]>,
): QueryObserverResult<t.TMarketplaceCategory[]> => {
return useQuery<t.TMarketplaceCategory[]>(
[QueryKeys.agentCategories],
() => dataService.getAgentCategories(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
...config,
},
);
};
/**
* Hook for infinite loading of marketplace agents with cursor-based pagination
*/
export const useMarketplaceAgentsInfiniteQuery = (
params: {
requiredPermission: number;
category?: string;
search?: string;
limit?: number;
promoted?: 0 | 1;
cursor?: string; // For pagination
},
config?: UseInfiniteQueryOptions<t.AgentListResponse, unknown>,
) => {
return useInfiniteQuery<t.AgentListResponse>({
queryKey: [QueryKeys.marketplaceAgents, params],
queryFn: ({ pageParam }) => {
const queryParams = { ...params };
if (pageParam) {
queryParams.cursor = pageParam.toString();
}
return dataService.getMarketplaceAgents(queryParams);
},
getNextPageParam: (lastPage) => lastPage?.after ?? undefined,
enabled: !!params.requiredPermission,
keepPreviousData: true,
staleTime: 2 * 60 * 1000, // 2 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
});
};

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import useAgentCategories from '../useAgentCategories';
import { 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;
},
}));
// Mock the data provider
jest.mock('~/data-provider/Agents', () => ({
useGetAgentCategoriesQuery: jest.fn(() => ({
data: [
{ value: 'general', label: 'com_ui_agent_category_general' },
{ value: 'hr', label: 'com_ui_agent_category_hr' },
{ value: 'rd', label: 'com_ui_agent_category_rd' },
{ value: 'finance', label: 'com_ui_agent_category_finance' },
{ value: 'it', label: 'com_ui_agent_category_it' },
{ value: 'sales', label: 'com_ui_agent_category_sales' },
{ value: 'aftersales', label: 'com_ui_agent_category_aftersales' },
{ value: 'promoted', label: 'Promoted' }, // Should be filtered out
{ value: 'all', label: 'All' }, // Should be filtered out
],
isLoading: false,
error: null,
})),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useAgentCategories', () => {
it('should return processed categories with correct structure', async () => {
const { result } = renderHook(() => useAgentCategories(), {
wrapper: createWrapper(),
});
await waitFor(() => {
// Check that we have the expected number of categories (excluding 'promoted' and 'all')
expect(result.current.categories.length).toBe(7);
});
// Check that the first category has the expected structure
const firstCategory = result.current.categories[0];
expect(firstCategory.value).toBe('general');
expect(firstCategory.label).toBe('com_ui_agent_category_general');
expect(firstCategory.className).toBe('w-full');
// Verify special categories are filtered out
const categoryValues = result.current.categories.map((cat) => cat.value);
expect(categoryValues).not.toContain('promoted');
expect(categoryValues).not.toContain('all');
// Check the empty category
expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value);
expect(result.current.emptyCategory.label).toBe('General (Translated)');
expect(result.current.emptyCategory.className).toBe('w-full');
// Check loading state
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
});

View File

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

View File

@@ -0,0 +1,58 @@
import { useMemo } from 'react';
import useLocalize from '~/hooks/useLocalize';
import { useGetAgentCategoriesQuery } from '~/data-provider/Agents';
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;
icon?: 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

@@ -1,4 +1,4 @@
import { TAgentsMap } from 'librechat-data-provider';
import { PERMISSION_BITS, TAgentsMap } from 'librechat-data-provider';
import { useMemo } from 'react';
import { useListAgentsQuery } from '~/data-provider';
import { mapAgents } from '~/utils';
@@ -8,10 +8,13 @@ export default function useAgentsMap({
}: {
isAuthenticated: boolean;
}): TAgentsMap | undefined {
const { data: agentsList = null } = useListAgentsQuery(undefined, {
select: (res) => mapAgents(res.data),
enabled: isAuthenticated,
});
const { data: agentsList = null } = useListAgentsQuery(
{ requiredPermission: PERMISSION_BITS.EDIT },
{
select: (res) => mapAgents(res.data),
enabled: isAuthenticated,
},
);
const agents = useMemo<TAgentsMap | undefined>(() => {
return agentsList !== null ? agentsList : undefined;

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

@@ -8,6 +8,7 @@ import {
isAgentsEndpoint,
getConfigDefaults,
isAssistantsEndpoint,
PERMISSION_BITS,
} from 'librechat-data-provider';
import type { TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider';
import type { MentionOption } from '~/common';
@@ -79,28 +80,31 @@ export default function useMentions({
() => startupConfig?.interface ?? defaultInterface,
[startupConfig?.interface],
);
const { data: agentsList = null } = useListAgentsQuery(undefined, {
enabled: hasAgentAccess && interfaceConfig.modelSelect === true,
select: (res) => {
const { data } = res;
return data.map(({ id, name, avatar }) => ({
value: id,
label: name ?? '',
type: EModelEndpoint.agents,
icon: EndpointIcon({
conversation: {
agent_id: id,
endpoint: EModelEndpoint.agents,
iconURL: avatar?.filepath,
},
containerClassName: 'shadow-stroke overflow-hidden rounded-full',
endpointsConfig: endpointsConfig,
context: 'menu-item',
size: 20,
}),
}));
const { data: agentsList = null } = useListAgentsQuery(
{ requiredPermission: PERMISSION_BITS.VIEW },
{
enabled: hasAgentAccess && interfaceConfig.modelSelect === true,
select: (res) => {
const { data } = res;
return data.map(({ id, name, avatar }) => ({
value: id,
label: name ?? '',
type: EModelEndpoint.agents,
icon: EndpointIcon({
conversation: {
agent_id: id,
endpoint: EModelEndpoint.agents,
iconURL: avatar?.filepath,
},
containerClassName: 'shadow-stroke overflow-hidden rounded-full',
endpointsConfig: endpointsConfig,
context: 'menu-item',
size: 20,
}),
}));
},
},
});
);
const assistantListMap = useMemo(
() => ({
[EModelEndpoint.assistants]: listMap[EModelEndpoint.assistants]

View File

@@ -35,3 +35,4 @@ export { default as useOnClickOutside } from './useOnClickOutside';
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,
};
};

View File

@@ -919,4 +919,4 @@
"com_ui_zoom": "Zoom",
"com_user_message": "Du",
"com_warning_resubmit_unsupported": "Das erneute Senden der KI-Nachricht wird für diesen Endpunkt nicht unterstützt."
}
}

View File

@@ -4,7 +4,6 @@
"com_a11y_ai_composing": "The AI is still composing.",
"com_a11y_end": "The AI has finished their reply.",
"com_a11y_start": "The AI has started their reply.",
"com_agents_allow_editing": "Allow other users to edit your agent",
"com_agents_by_librechat": "by LibreChat",
"com_agents_code_interpreter": "When enabled, allows your agent to leverage the LibreChat Code Interpreter API to run generated code, including file processing, securely. Requires a valid API key.",
"com_agents_code_interpreter_title": "Code Interpreter API",
@@ -37,7 +36,7 @@
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
"com_assistants_add_actions": "Add Actions",
"com_assistants_add_tools": "Add Tools",
"com_assistants_allow_sites_you_trust": "Only allow sites you trust.",
"com_assistants_allow_sites_you_trust": "Only allow sites you trust..",
"com_assistants_append_date": "Append Current Date & Time",
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
"com_assistants_attempt_info": "Assistant wants to send the following:",
@@ -532,10 +531,8 @@
"com_ui_agent_deleted": "Successfully deleted agent",
"com_ui_agent_duplicate_error": "There was an error duplicating the agent",
"com_ui_agent_duplicated": "Agent duplicated successfully",
"com_ui_agent_editing_allowed": "Other users can already edit this agent",
"com_ui_agent_recursion_limit": "Max Agent Steps",
"com_ui_agent_recursion_limit_info": "Limits how many steps the agent can take in a run before giving a final response. Default is 25 steps. A step is either an AI API request or a tool usage round. For example, a basic tool interaction takes 3 steps: initial request, tool usage, and follow-up request.",
"com_ui_agent_shared_to_all": "something needs to go here. was empty",
"com_ui_agent_var": "{{0}} agent",
"com_ui_agent_version": "Version",
"com_ui_agent_version_active": "Active Version",
@@ -620,6 +617,14 @@
"com_ui_cancel": "Cancel",
"com_ui_cancelled": "Cancelled",
"com_ui_category": "Category",
"com_ui_agent_category_selector_aria": "Agent's category selector",
"com_ui_agent_category_general": "General",
"com_ui_agent_category_hr": "HR",
"com_ui_agent_category_rd": "R&D",
"com_ui_agent_category_finance": "Finance",
"com_ui_agent_category_it": "IT",
"com_ui_agent_category_sales": "Sales",
"com_ui_agent_category_aftersales": "After Sales",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
"com_ui_clear": "Clear",
@@ -647,6 +652,19 @@
"com_ui_copy_code": "Copy code",
"com_ui_copy_link": "Copy link",
"com_ui_copy_to_clipboard": "Copy to clipboard",
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
"com_ui_agent_url_copied": "Agent URL copied to clipboard",
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
"com_ui_permission_level": "Permission Level",
"com_ui_grant_access": "Grant Access",
"com_ui_granting": "Granting...",
"com_ui_search_users_groups": "Search Users and Groups",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
"com_ui_user": "User",
"com_ui_group": "Group",
"com_ui_search_above_to_add": "Search above to add users or groups",
"com_ui_azure_ad": "Entra ID",
"com_ui_remove_user": "Remove {{0}}",
"com_ui_create": "Create",
"com_ui_create_link": "Create link",
"com_ui_create_memory": "Create Memory",
@@ -857,7 +875,6 @@
"com_ui_no_backup_codes": "No backup codes available. Please generate new ones",
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes to update",
"com_ui_no_data": "something needs to go here. was empty",
"com_ui_no_personalization_available": "No personalization options are currently available",
"com_ui_no_read_access": "You don't have permission to view memories",
@@ -937,6 +954,7 @@
"com_ui_scope": "Scope",
"com_ui_search": "Search",
"com_ui_seconds": "seconds",
"com_ui_search_agent_category": "Search categories...",
"com_ui_secret_key": "Secret Key",
"com_ui_select": "Select",
"com_ui_select_all": "Select All",
@@ -981,6 +999,13 @@
"com_ui_stop": "Stop",
"com_ui_storage": "Storage",
"com_ui_submit": "Submit",
"com_ui_support_contact": "Support Contact",
"com_ui_support_contact_name": "Name",
"com_ui_support_contact_name_placeholder": "Support contact name",
"com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters",
"com_ui_support_contact_email": "Email",
"com_ui_support_contact_email_placeholder": "support@example.com",
"com_ui_support_contact_email_invalid": "Please enter a valid email address",
"com_ui_teach_or_explain": "Learning",
"com_ui_temporary": "Temporary Chat",
"com_ui_terms_and_conditions": "Terms and Conditions",
@@ -1059,5 +1084,94 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
}
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
"com_ui_select_options": "Select options...",
"com_ui_no_results_found": "No results found",
"com_ui_try_adjusting_search": "Try adjusting your search terms",
"com_ui_role_viewer": "Viewer",
"com_ui_role_editor": "Editor",
"com_ui_role_manager": "Manager",
"com_ui_role_owner": "Owner",
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
"com_ui_role_editor_desc": "Can view and modify the agent",
"com_ui_role_manager_desc": "Can view, modify, and delete the agent",
"com_ui_role_owner_desc": "Has full control over the agent including sharing it",
"com_ui_permissions_failed_load": "Failed to load permissions. Please try again.",
"com_ui_permissions_updated_success": "Permissions updated successfully",
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
"com_ui_manage_permissions_for": "Manage Permissions for",
"com_ui_current_access": "Current Access",
"com_ui_no_users_groups_access": "No users or groups have access",
"com_ui_shared_with_count": "Shared with {{0}} {{1}}{{2}}",
"com_ui_person": "person",
"com_ui_people": "people",
"com_ui_and_public": " and public",
"com_ui_revoke_all": "Revoke All",
"com_ui_loading_permissions": "Loading permissions...",
"com_ui_user_group_permissions": "User & Group Permissions",
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
"com_ui_public_access": "Public Access",
"com_ui_save_changes": "Save Changes",
"com_ui_unsaved_changes": "You have unsaved changes",
"com_ui_share_with_everyone": "Share with everyone",
"com_ui_make_agent_available_all_users": "Make this agent available to all LibreChat users",
"com_ui_public_access_level": "Public access level",
"com_ui_at_least_one_owner_required": "At least one owner is required",
"com_agents_marketplace": "Agent Marketplace",
"com_agents_all": "All Agents",
"com_agents_top_picks": "Top Picks",
"com_agents_recommended": "Our recommended agents",
"com_agents_search_placeholder": "Search agents...",
"com_agents_search_aria": "Search for agents",
"com_agents_clear_search": "Clear search",
"com_agents_see_more": "See more",
"com_agents_created_by": "by",
"com_agents_contact": "Contact",
"com_agents_start_chat": "Start Chat",
"com_agents_copy_link": "Copy Link",
"com_agents_link_copied": "Link copied",
"com_agents_link_copy_failed": "Failed to copy link",
"com_agents_more_options": "More options",
"com_agents_loading": "Loading...",
"com_agents_error_loading": "Error loading agents",
"com_agents_error_searching": "Error searching agents",
"com_agents_error_title": "Something went wrong",
"com_agents_error_generic": "We encountered an issue while loading the content.",
"com_agents_error_suggestion_generic": "Please try refreshing the page or try again later.",
"com_agents_error_network_title": "Connection Problem",
"com_agents_error_network_message": "Unable to connect to the server.",
"com_agents_error_network_suggestion": "Check your internet connection and try again.",
"com_agents_error_not_found_title": "Not Found",
"com_agents_error_not_found_message": "The requested content could not be found.",
"com_agents_error_not_found_suggestion": "Try browsing other options or go back to the marketplace.",
"com_agents_error_invalid_request": "Invalid Request",
"com_agents_error_bad_request_message": "The request could not be processed.",
"com_agents_error_bad_request_suggestion": "Please check your input and try again.",
"com_agents_error_server_title": "Server Error",
"com_agents_error_server_message": "The server is temporarily unavailable.",
"com_agents_error_server_suggestion": "Please try again in a few moments.",
"com_agents_error_search_title": "Search Error",
"com_agents_error_category_title": "Category Error",
"com_agents_error_timeout_title": "Connection Timeout",
"com_agents_error_timeout_message": "The request took too long to complete.",
"com_agents_error_timeout_suggestion": "Please check your internet connection and try again.",
"com_agents_search_no_results": "No agents found for \"{{query}}\"",
"com_agents_category_empty": "No agents found in the {{category}} category",
"com_agents_error_retry": "Try Again",
"com_agents_category_tabs_label": "Agent Categories",
"com_agents_category_tab_label": "{{category}} category, {{position}} of {{total}}",
"com_agents_search_instructions": "Type to search agents by name or description",
"com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category",
"com_agents_load_more_label": "Load more agents from {{category}} category",
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
"com_agents_empty_state_heading": "No agents found",
"com_agents_search_empty_heading": "No search results",
"com_agents_no_description": "No description available",
"com_agents_none_in_category": "No agents found in this category",
"com_agents_no_results": "No agents found. Try another search term.",
"com_agents_results_for": "Results for '{{query}}'",
"com_nav_agents_marketplace": "Agent Marketplace",
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
"com_ui_agent_name_is_required": "Agent name is required",
"com_agents_missing_name": "Please type in a name before creating an agent."
}

View File

@@ -18,6 +18,7 @@ import ShareRoute from './ShareRoute';
import ChatRoute from './ChatRoute';
import Search from './Search';
import Root from './Root';
import AgentMarketplace from '~/components/SidePanel/Agents/AgentMarketplace';
const AuthLayout = () => (
<AuthContextProvider>
@@ -105,6 +106,14 @@ export const router = createBrowserRouter([
path: 'search',
element: <Search />,
},
{
path: 'agents',
element: <AgentMarketplace />,
},
{
path: 'agents/:category',
element: <AgentMarketplace />,
},
],
},
],

View File

@@ -0,0 +1,178 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
import type t from 'librechat-data-provider';
// Mock the Bot icon from lucide-react
jest.mock('lucide-react', () => ({
Bot: ({ className, strokeWidth, ...props }: any) => (
<svg data-testid="bot-icon" className={className} data-stroke-width={strokeWidth} {...props}>
<title>{/* eslint-disable-line i18next/no-literal-string */}Bot Icon</title>
</svg>
),
}));
describe('Agent Utilities', () => {
describe('getAgentAvatarUrl', () => {
it('should return null for null agent', () => {
expect(getAgentAvatarUrl(null)).toBeNull();
});
it('should return null for undefined agent', () => {
expect(getAgentAvatarUrl(undefined)).toBeNull();
});
it('should return null for agent without avatar', () => {
const agent = { id: '1', name: 'Test Agent' } as t.Agent;
expect(getAgentAvatarUrl(agent)).toBeNull();
});
it('should return string avatar directly', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/path/to/avatar.png',
} as unknown as t.Agent;
expect(getAgentAvatarUrl(agent)).toBe('/path/to/avatar.png');
});
it('should extract filepath from object avatar', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: { filepath: '/path/to/object-avatar.png' },
} as t.Agent;
expect(getAgentAvatarUrl(agent)).toBe('/path/to/object-avatar.png');
});
it('should return null for object avatar without filepath', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: { someOtherProperty: 'value' },
} as any;
expect(getAgentAvatarUrl(agent)).toBeNull();
});
});
describe('renderAgentAvatar', () => {
it('should render image when avatar URL exists', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as unknown as t.Agent;
render(<div>{renderAgentAvatar(agent)}</div>);
const img = screen.getByAltText('Test Agent avatar');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/test-avatar.png');
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
});
it('should render Bot icon fallback when no avatar', () => {
const agent = {
id: '1',
name: 'Test Agent',
} as t.Agent;
render(<div>{renderAgentAvatar(agent)}</div>);
const botIcon = screen.getByTestId('bot-icon');
expect(botIcon).toBeInTheDocument();
expect(botIcon).toHaveAttribute('data-stroke-width', '1.5');
});
it('should apply different size classes', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as unknown as t.Agent;
const { rerender } = render(<div>{renderAgentAvatar(agent, { size: 'sm' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12');
rerender(<div>{renderAgentAvatar(agent, { size: 'lg' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-20', 'w-20');
rerender(<div>{renderAgentAvatar(agent, { size: 'xl' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-24', 'w-24');
});
it('should apply custom className', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as unknown as t.Agent;
render(<div>{renderAgentAvatar(agent, { className: 'custom-class' })}</div>);
const container = screen.getByAltText('Test Agent avatar').parentElement;
expect(container).toHaveClass('custom-class');
});
it('should handle showBorder option', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as unknown as t.Agent;
const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2');
rerender(<div>{renderAgentAvatar(agent, { showBorder: false })}</div>);
expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-2');
});
});
describe('getContactDisplayName', () => {
it('should return null for null agent', () => {
expect(getContactDisplayName(null)).toBeNull();
});
it('should return null for undefined agent', () => {
expect(getContactDisplayName(undefined)).toBeNull();
});
it('should prioritize support_contact name', () => {
const agent = {
id: '1',
support_contact: { name: 'Support Team', email: 'support@example.com' },
authorName: 'John Doe',
} as any;
expect(getContactDisplayName(agent)).toBe('Support Team');
});
it('should use authorName when support_contact name is missing', () => {
const agent = {
id: '1',
support_contact: { email: 'support@example.com' },
authorName: 'John Doe',
} as any;
expect(getContactDisplayName(agent)).toBe('John Doe');
});
it('should use support_contact email when both name and authorName are missing', () => {
const agent = {
id: '1',
support_contact: { email: 'support@example.com' },
} as any;
expect(getContactDisplayName(agent)).toBe('support@example.com');
});
it('should return null when no contact info is available', () => {
const agent = {
id: '1',
name: 'Test Agent',
} as any;
expect(getContactDisplayName(agent)).toBeNull();
});
});
// Tests for hardcoded category functions removed - now using database-driven categories
});

109
client/src/utils/agents.tsx Normal file
View File

@@ -0,0 +1,109 @@
import React from 'react';
import { Bot } from 'lucide-react';
import type t from 'librechat-data-provider';
/**
* Extracts the avatar URL from an agent's avatar property
* Handles both string and object formats
*/
export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | null => {
if (!agent?.avatar) {
return null;
}
if (typeof agent.avatar === 'string') {
return agent.avatar;
}
if (agent.avatar && typeof agent.avatar === 'object' && 'filepath' in agent.avatar) {
return agent.avatar.filepath;
}
return null;
};
/**
* Renders an agent avatar with fallback to Bot icon
* Consistent across all agent displays
*/
export const renderAgentAvatar = (
agent: t.Agent | null | undefined,
options: {
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showBorder?: boolean;
} = {},
): React.ReactElement => {
const { size = 'md', className = '', showBorder = true } = options;
const avatarUrl = getAgentAvatarUrl(agent);
// Size mappings for responsive design
const sizeClasses = {
sm: 'h-12 w-12 sm:h-14 sm:w-14',
md: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
lg: 'h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28',
xl: 'h-24 w-24',
};
const iconSizeClasses = {
sm: 'h-6 w-6 sm:h-7 sm:w-7',
md: 'h-6 w-6 sm:h-8 sm:w-8 md:h-10 md:w-10',
lg: 'h-8 w-8 sm:h-10 sm:w-10 md:h-12 md:w-12',
xl: 'h-10 w-10',
};
const placeholderSizeClasses = {
sm: 'h-10 w-10 sm:h-12 sm:w-12',
md: 'h-12 w-12 sm:h-16 sm:w-16 md:h-20 md:w-20',
lg: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
xl: 'h-20 w-20',
};
const borderClasses = showBorder ? 'border-2 border-white dark:border-gray-800' : '';
if (avatarUrl) {
return (
<div className={`flex items-center justify-center ${sizeClasses[size]} ${className}`}>
<img
src={avatarUrl}
alt={`${agent?.name || 'Agent'} avatar`}
className={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
loading="lazy"
/>
</div>
);
}
// Fallback placeholder with Bot icon
return (
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
{/* Subtle minimalistic placeholder */}
<div className="absolute inset-0 rounded-full border border-gray-300 bg-gray-200 dark:border-gray-600 dark:bg-gray-700"></div>
<div
className={`relative flex items-center justify-center rounded-full bg-gray-300 dark:bg-gray-600 ${placeholderSizeClasses[size]}`}
>
<Bot
className={`text-gray-500 dark:text-gray-400 ${iconSizeClasses[size]}`}
strokeWidth={1.5}
/>
</div>
</div>
);
};
/**
* Gets the display name for a contact (prioritizes name over email)
*/
export const getContactDisplayName = (agent: t.Agent | null | undefined): string | null => {
if (!agent) return null;
const supportName = (agent as any).support_contact?.name;
const supportEmail = (agent as any).support_contact?.email;
const authorName = (agent as any).authorName;
return supportName || authorName || supportEmail || null;
};
// All hardcoded category constants removed - now using database-driven categories

View File

@@ -63,8 +63,7 @@ export const processAgentOption = ({
fileMap?: Record<string, TFile | undefined>;
instanceProjectId?: string;
}): TAgentOption => {
const isGlobal =
(instanceProjectId != null && _agent?.projectIds?.includes(instanceProjectId)) ?? false;
const isGlobal = _agent?.isPublic ?? false;
const agent: TAgentOption = {
...(_agent ?? ({} as Agent)),
label: _agent?.name ?? '',

View File

@@ -21,6 +21,21 @@ beforeEach(() => {
jest.clearAllMocks();
});
// Mock window.matchMedia for tests
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
jest.mock('react-i18next', () => {
const actual = jest.requireActual('react-i18next');
return {

View File

@@ -0,0 +1,273 @@
//TODO: needs testing and validation before running in production
console.log('needs testing and validation before running in production...');
const path = require('path');
const { logger } = require('@librechat/data-schemas');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const connect = require('./connect');
const { grantPermission } = require('~/server/services/PermissionService');
const { getProjectByName } = require('~/models/Project');
const { findRoleByIdentifier } = require('~/models');
const { Agent } = require('~/db/models');
async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100 } = {}) {
await connect();
logger.info('Starting Enhanced Agent Permissions Migration', { dryRun, batchSize });
// Verify required roles exist
const ownerRole = await findRoleByIdentifier('agent_owner');
const viewerRole = await findRoleByIdentifier('agent_viewer');
const editorRole = await findRoleByIdentifier('agent_editor');
if (!ownerRole || !viewerRole || !editorRole) {
throw new Error('Required roles not found. Run role seeding first.');
}
// Get global project agent IDs (stores agent.id, not agent._id)
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
const globalAgentIds = new Set(globalProject?.agentIds || []);
logger.info(`Found ${globalAgentIds.size} agents in global project`);
// Find agents without ACL entries using DocumentDB-compatible approach
const agentsToMigrate = await Agent.aggregate([
{
$lookup: {
from: 'aclentries',
localField: '_id',
foreignField: 'resourceId',
as: 'aclEntries',
},
},
{
$addFields: {
userAclEntries: {
$filter: {
input: '$aclEntries',
as: 'aclEntry',
cond: {
$and: [
{ $eq: ['$$aclEntry.resourceType', 'agent'] },
{ $eq: ['$$aclEntry.principalType', 'user'] },
],
},
},
},
},
},
{
$match: {
author: { $exists: true, $ne: null },
userAclEntries: { $size: 0 },
},
},
{
$project: {
_id: 1,
id: 1,
name: 1,
author: 1,
isCollaborative: 1,
},
},
]);
const categories = {
globalEditAccess: [], // Global project + collaborative -> Public EDIT
globalViewAccess: [], // Global project + not collaborative -> Public VIEW
privateAgents: [], // Not in global project -> Private (owner only)
};
agentsToMigrate.forEach((agent) => {
const isGlobal = globalAgentIds.has(agent.id);
const isCollab = agent.isCollaborative;
if (isGlobal && isCollab) {
categories.globalEditAccess.push(agent);
} else if (isGlobal && !isCollab) {
categories.globalViewAccess.push(agent);
} else {
categories.privateAgents.push(agent);
// Log warning if private agent claims to be collaborative
if (isCollab) {
logger.warn(
`Agent "${agent.name}" (${agent.id}) has isCollaborative=true but is not in global project`,
);
}
}
});
logger.info('Agent categorization:', {
globalEditAccess: categories.globalEditAccess.length,
globalViewAccess: categories.globalViewAccess.length,
privateAgents: categories.privateAgents.length,
total: agentsToMigrate.length,
});
if (dryRun) {
return {
migrated: 0,
errors: 0,
dryRun: true,
summary: {
globalEditAccess: categories.globalEditAccess.length,
globalViewAccess: categories.globalViewAccess.length,
privateAgents: categories.privateAgents.length,
total: agentsToMigrate.length,
},
details: {
globalEditAccess: categories.globalEditAccess.map((a) => ({
name: a.name,
id: a.id,
permissions: 'Owner + Public EDIT',
})),
globalViewAccess: categories.globalViewAccess.map((a) => ({
name: a.name,
id: a.id,
permissions: 'Owner + Public VIEW',
})),
privateAgents: categories.privateAgents.map((a) => ({
name: a.name,
id: a.id,
permissions: 'Owner only',
})),
},
};
}
const results = {
migrated: 0,
errors: 0,
publicViewGrants: 0,
publicEditGrants: 0,
ownerGrants: 0,
};
// Process in batches
for (let i = 0; i < agentsToMigrate.length; i += batchSize) {
const batch = agentsToMigrate.slice(i, i + batchSize);
logger.info(
`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(agentsToMigrate.length / batchSize)}`,
);
for (const agent of batch) {
try {
const isGlobal = globalAgentIds.has(agent.id);
const isCollab = agent.isCollaborative;
// Always grant owner permission to author
await grantPermission({
principalType: 'user',
principalId: agent.author,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_owner',
grantedBy: agent.author,
});
results.ownerGrants++;
// Determine public permissions for global project agents only
let publicRoleId = null;
let description = 'Private';
if (isGlobal) {
if (isCollab) {
// Global project + collaborative = Public EDIT access
publicRoleId = 'agent_editor';
description = 'Global Edit';
results.publicEditGrants++;
} else {
// Global project + not collaborative = Public VIEW access
publicRoleId = 'agent_viewer';
description = 'Global View';
results.publicViewGrants++;
}
// Grant public permission
await grantPermission({
principalType: 'public',
principalId: null,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: publicRoleId,
grantedBy: agent.author,
});
}
results.migrated++;
logger.debug(`Migrated agent "${agent.name}" [${description}]`, {
agentId: agent.id,
author: agent.author,
isGlobal,
isCollab,
publicRole: publicRoleId,
});
} catch (error) {
results.errors++;
logger.error(`Failed to migrate agent "${agent.name}"`, {
agentId: agent.id,
author: agent.author,
error: error.message,
});
}
}
// Brief pause between batches
await new Promise((resolve) => setTimeout(resolve, 100));
}
logger.info('Enhanced migration completed', results);
return results;
}
if (require.main === module) {
const dryRun = process.argv.includes('--dry-run');
const batchSize =
parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100;
migrateAgentPermissionsEnhanced({ dryRun, batchSize })
.then((result) => {
if (dryRun) {
console.log('\n=== DRY RUN RESULTS ===');
console.log(`Total agents to migrate: ${result.summary.total}`);
console.log(`- Global Edit Access: ${result.summary.globalEditAccess} agents`);
console.log(`- Global View Access: ${result.summary.globalViewAccess} agents`);
console.log(`- Private Agents: ${result.summary.privateAgents} agents`);
if (result.details.globalEditAccess.length > 0) {
console.log('\nGlobal Edit Access agents:');
result.details.globalEditAccess.forEach((agent, i) => {
console.log(` ${i + 1}. "${agent.name}" (${agent.id})`);
});
}
if (result.details.globalViewAccess.length > 0) {
console.log('\nGlobal View Access agents:');
result.details.globalViewAccess.forEach((agent, i) => {
console.log(` ${i + 1}. "${agent.name}" (${agent.id})`);
});
}
if (result.details.privateAgents.length > 0) {
console.log('\nPrivate agents:');
result.details.privateAgents.forEach((agent, i) => {
console.log(` ${i + 1}. "${agent.name}" (${agent.id})`);
});
}
} else {
console.log('\nMigration Results:', JSON.stringify(result, null, 2));
}
process.exit(0);
})
.catch((error) => {
console.error('Enhanced migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateAgentPermissionsEnhanced };

28
package-lock.json generated
View File

@@ -67,6 +67,7 @@
"@librechat/agents": "^2.4.46",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@node-saml/passport-saml": "^5.0.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
@@ -20305,6 +20306,33 @@
"json-buffer": "3.0.1"
}
},
"node_modules/@microsoft/microsoft-graph-client": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz",
"integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependenciesMeta": {
"@azure/identity": {
"optional": true
},
"@azure/msal-browser": {
"optional": true
},
"buffer": {
"optional": true
},
"stream-browserify": {
"optional": true
}
}
},
"node_modules/@mistralai/mistralai": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.5.2.tgz",

View File

@@ -71,7 +71,10 @@
"b:test:api": "cd api && bun run b:test",
"b:balance": "bun config/add-balance.js",
"b:list-balances": "bun config/list-balances.js",
"reset-terms": "node config/reset-terms.js"
"reset-terms": "node config/reset-terms.js",
"migrate:agent-permissions:dry-run": "node config/migrate-agent-permissions.js --dry-run",
"migrate:agent-permissions": "node config/migrate-agent-permissions.js",
"migrate:agent-permissions:batch": "node config/migrate-agent-permissions.js --batch-size=50"
},
"repository": {
"type": "git",

View File

@@ -0,0 +1,292 @@
import { z } from 'zod';
/**
* Granular Permission System Types for Agent Sharing
*
* This file contains TypeScript interfaces and Zod schemas for the enhanced
* agent permission system that supports sharing with specific users/groups
* and Entra ID integration.
*/
// ===== ENUMS & CONSTANTS =====
/**
* Principal types for permission system
*/
export type TPrincipalType = 'user' | 'group' | 'public';
/**
* Source of the principal (local LibreChat or external Entra ID)
*/
export type TPrincipalSource = 'local' | 'entra';
/**
* Access levels for agents
*/
export type TAccessLevel = 'none' | 'viewer' | 'editor' | 'owner';
/**
* Permission bit constants for bitwise operations
*/
export const PERMISSION_BITS = {
VIEW: 1, // 001 - Can view and use agent
EDIT: 2, // 010 - Can modify agent settings
DELETE: 4, // 100 - Can delete agent
SHARE: 8, // 1000 - Can share agent with others (future)
} as const;
/**
* Standard access role IDs
*/
export const ACCESS_ROLE_IDS = {
AGENT_VIEWER: 'agent_viewer',
AGENT_EDITOR: 'agent_editor',
AGENT_OWNER: 'agent_owner', // Future use
} as const;
// ===== ZOD SCHEMAS =====
/**
* Principal schema - represents a user, group, or public access
*/
export const principalSchema = z.object({
type: z.enum(['user', 'group', 'public']),
id: z.string().optional(), // undefined for 'public' type
name: z.string().optional(),
email: z.string().optional(), // for user and group types
source: z.enum(['local', 'entra']).optional(),
avatar: z.string().optional(), // for user and group types
description: z.string().optional(), // for group type
idOnTheSource: z.string().optional(), // Entra ID for users/groups
accessRoleId: z.string().optional(), // Access role ID for permissions
memberCount: z.number().optional(), // for group type
});
/**
* Access role schema - defines named permission sets
*/
export const accessRoleSchema = z.object({
accessRoleId: z.string(),
name: z.string(),
description: z.string().optional(),
resourceType: z.string().default('agent'),
permBits: z.number(),
});
/**
* Permission entry schema - represents a single ACL entry
*/
export const permissionEntrySchema = z.object({
id: z.string(),
principalType: z.enum(['user', 'group', 'public']),
principalId: z.string().optional(), // undefined for 'public'
principalName: z.string().optional(),
role: accessRoleSchema,
grantedBy: z.string(),
grantedAt: z.string(), // ISO date string
inheritedFrom: z.string().optional(), // for project-level inheritance
source: z.enum(['local', 'entra']).optional(),
});
/**
* Resource permissions response schema
*/
export const resourcePermissionsResponseSchema = z.object({
resourceType: z.string(),
resourceId: z.string(),
permissions: z.array(permissionEntrySchema),
});
/**
* Update resource permissions request schema
* This matches the user's requirement for the frontend DTO structure
*/
export const updateResourcePermissionsRequestSchema = z.object({
updated: principalSchema.array(),
removed: principalSchema.array(),
public: z.boolean(),
publicAccessRoleId: z.string().optional(),
});
/**
* Update resource permissions response schema
* Returns the updated permissions with accessRoleId included
*/
export const updateResourcePermissionsResponseSchema = z.object({
message: z.string(),
results: z.object({
principals: principalSchema.array(),
public: z.boolean(),
publicAccessRoleId: z.string().optional(),
}),
});
// ===== TYPESCRIPT TYPES =====
/**
* Principal - represents a user, group, or public access
*/
export type TPrincipal = z.infer<typeof principalSchema>;
/**
* Access role - defines named permission sets
*/
export type TAccessRole = z.infer<typeof accessRoleSchema>;
/**
* Permission entry - represents a single ACL entry
*/
export type TPermissionEntry = z.infer<typeof permissionEntrySchema>;
/**
* Resource permissions response
*/
export type TResourcePermissionsResponse = z.infer<typeof resourcePermissionsResponseSchema>;
/**
* Update resource permissions request
* This matches the user's requirement for the frontend DTO structure
*/
export type TUpdateResourcePermissionsRequest = z.infer<
typeof updateResourcePermissionsRequestSchema
>;
/**
* Update resource permissions response
* Returns the updated permissions with accessRoleId included
*/
export type TUpdateResourcePermissionsResponse = z.infer<
typeof updateResourcePermissionsResponseSchema
>;
/**
* Principal search request parameters
*/
export type TPrincipalSearchParams = {
q: string; // search query (required)
limit?: number; // max results (1-50, default 10)
type?: 'user' | 'group'; // filter by type (optional)
};
/**
* Principal search result item
*/
export type TPrincipalSearchResult = {
id?: string | null; // null for Entra ID principals that don't exist locally yet
type: 'user' | 'group';
name: string;
email?: string; // for users and groups
username?: string; // for users
avatar?: string; // for users and groups
provider?: string; // for users
source: 'local' | 'entra';
memberCount?: number; // for groups
description?: string; // for groups
idOnTheSource?: string; // Entra ID for users (maps to openidId) and groups (maps to idOnTheSource)
};
/**
* Principal search response
*/
export type TPrincipalSearchResponse = {
query: string;
limit: number;
type?: 'user' | 'group';
results: TPrincipalSearchResult[];
count: number;
sources: {
local: number;
entra: number;
};
};
/**
* Available roles response
*/
export type TAvailableRolesResponse = {
resourceType: string;
roles: TAccessRole[];
};
/**
* Get resource permissions response schema
* This matches the enhanced aggregation-based endpoint response format
*/
export const getResourcePermissionsResponseSchema = z.object({
resourceType: z.string(),
resourceId: z.string(),
principals: z.array(principalSchema),
public: z.boolean(),
publicAccessRoleId: z.string().optional(),
});
/**
* Get resource permissions response type
* This matches the enhanced aggregation-based endpoint response format
*/
export type TGetResourcePermissionsResponse = z.infer<typeof getResourcePermissionsResponseSchema>;
/**
* Effective permissions response schema
* Returns just the permission bitmask for a user on a resource
*/
export const effectivePermissionsResponseSchema = z.object({
permissionBits: z.number(),
});
/**
* Effective permissions response type
* Returns just the permission bitmask for a user on a resource
*/
export type TEffectivePermissionsResponse = z.infer<typeof effectivePermissionsResponseSchema>;
// ===== UTILITY TYPES =====
/**
* Permission check result
*/
export interface TPermissionCheck {
canView: boolean;
canEdit: boolean;
canDelete: boolean;
canShare: boolean;
accessLevel: TAccessLevel;
}
// ===== HELPER FUNCTIONS =====
/**
* Convert permission bits to access level
*/
export function permBitsToAccessLevel(permBits: number): TAccessLevel {
if ((permBits & PERMISSION_BITS.DELETE) > 0) return 'owner';
if ((permBits & PERMISSION_BITS.EDIT) > 0) return 'editor';
if ((permBits & PERMISSION_BITS.VIEW) > 0) return 'viewer';
return 'none';
}
/**
* Convert access role ID to permission bits
*/
export function accessRoleToPermBits(accessRoleId: string): number {
switch (accessRoleId) {
case ACCESS_ROLE_IDS.AGENT_VIEWER:
return PERMISSION_BITS.VIEW;
case ACCESS_ROLE_IDS.AGENT_EDITOR:
return PERMISSION_BITS.VIEW | PERMISSION_BITS.EDIT;
case ACCESS_ROLE_IDS.AGENT_OWNER:
return PERMISSION_BITS.VIEW | PERMISSION_BITS.EDIT | PERMISSION_BITS.DELETE;
default:
return PERMISSION_BITS.VIEW;
}
}
/**
* Check if permission bitmask contains other bitmask
* @param permissions - The permission bitmask to check
* @param requiredPermission - The required permission bit(s)
* @returns {boolean} Whether permissions contains requiredPermission
*/
export function hasPermissions(permissions: number, requiredPermission: number): boolean {
return (permissions & requiredPermission) === requiredPermission;
}

Some files were not shown because too many files have changed in this diff Show More