Compare commits
22 Commits
main
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b593d4e7b | ||
|
|
cec1ec0c79 | ||
|
|
a17826fe39 | ||
|
|
3105c17713 | ||
|
|
60db466298 | ||
|
|
e049fb8821 | ||
|
|
848fbbb492 | ||
|
|
0106b050e5 | ||
|
|
89e0d3b6a4 | ||
|
|
5fb4817e6d | ||
|
|
d971235d2f | ||
|
|
270c21e0bf | ||
|
|
71c654245a | ||
|
|
d318d8f024 | ||
|
|
0df4aef7e8 | ||
|
|
e153475872 | ||
|
|
f20209ecc5 | ||
|
|
ce3dbf8609 | ||
|
|
c6451e8cb6 | ||
|
|
45d0dd2969 | ||
|
|
24ed140e70 | ||
|
|
2f3bbc3b34 |
15
.env.example
15
.env.example
@@ -490,6 +490,21 @@ SAML_IMAGE_URL=
|
|||||||
# SAML_USE_AUTHN_RESPONSE_SIGNED=
|
# 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
|
||||||
LDAP_URL=
|
LDAP_URL=
|
||||||
LDAP_BIND_DN=
|
LDAP_BIND_DN=
|
||||||
|
|||||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -8,7 +8,8 @@
|
|||||||
"skipFiles": ["<node_internals>/**"],
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"program": "${workspaceFolder}/api/server/index.js",
|
"program": "${workspaceFolder}/api/server/index.js",
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_ENV": "production"
|
"NODE_ENV": "production",
|
||||||
|
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"envFile": "${workspaceFolder}/.env"
|
"envFile": "${workspaceFolder}/.env"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const axios = require('axios');
|
|||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Tools, EToolResources } = require('librechat-data-provider');
|
const { Tools, EToolResources } = require('librechat-data-provider');
|
||||||
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||||
const { generateShortLivedToken } = require('~/server/services/AuthService');
|
const { generateShortLivedToken } = require('~/server/services/AuthService');
|
||||||
const { getFiles } = require('~/models/File');
|
const { getFiles } = require('~/models/File');
|
||||||
|
|
||||||
@@ -22,14 +23,19 @@ const primeFiles = async (options) => {
|
|||||||
const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
|
const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
|
||||||
const agentResourceIds = new Set(file_ids);
|
const agentResourceIds = new Set(file_ids);
|
||||||
const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
|
const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
|
||||||
const dbFiles = (
|
|
||||||
(await getFiles(
|
// Get all files first
|
||||||
{ file_id: { $in: file_ids } },
|
const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
|
||||||
null,
|
|
||||||
{ text: 0 },
|
// Filter by access if user and agent are provided
|
||||||
{ userId: req?.user?.id, agentId },
|
let dbFiles;
|
||||||
)) ?? []
|
if (req?.user?.id && agentId) {
|
||||||
).concat(resourceFiles);
|
dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId);
|
||||||
|
} else {
|
||||||
|
dbFiles = allFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbFiles = dbFiles.concat(resourceFiles);
|
||||||
|
|
||||||
let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;
|
let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module.exports = {
|
|||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
roots: ['<rootDir>'],
|
roots: ['<rootDir>'],
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
|
testTimeout: 30000, // 30 seconds timeout for all tests
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
'./test/jestSetup.js',
|
'./test/jestSetup.js',
|
||||||
'./test/__mocks__/logger.js',
|
'./test/__mocks__/logger.js',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||||||
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
||||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||||
require('librechat-data-provider').Constants;
|
require('librechat-data-provider').Constants;
|
||||||
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
// Default category value for new agents
|
||||||
const {
|
const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
addAgentIdsToProject,
|
addAgentIdsToProject,
|
||||||
@@ -12,7 +12,9 @@ const {
|
|||||||
removeAgentFromAllProjects,
|
removeAgentFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
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 { getActions } = require('./Action');
|
||||||
const { Agent } = require('~/db/models');
|
const { Agent } = require('~/db/models');
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ const { Agent } = require('~/db/models');
|
|||||||
* @throws {Error} If the agent creation fails.
|
* @throws {Error} If the agent creation fails.
|
||||||
*/
|
*/
|
||||||
const createAgent = async (agentData) => {
|
const createAgent = async (agentData) => {
|
||||||
const { author, ...versionData } = agentData;
|
const { author: _author, ...versionData } = agentData;
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
const initialAgentData = {
|
const initialAgentData = {
|
||||||
...agentData,
|
...agentData,
|
||||||
@@ -34,7 +36,9 @@ const createAgent = async (agentData) => {
|
|||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
category: agentData.category || 'general',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (await Agent.create(initialAgentData)).toObject();
|
return (await Agent.create(initialAgentData)).toObject();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,29 +135,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agent.version = agent.versions ? agent.versions.length : 0;
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
return agent;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,7 +165,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
|
|||||||
'actionsHash', // Exclude actionsHash from direct comparison
|
'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) {
|
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
|
||||||
return null;
|
return null;
|
||||||
@@ -202,54 +184,116 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
|
|||||||
|
|
||||||
let isMatch = true;
|
let isMatch = true;
|
||||||
for (const field of importantFields) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
// Handle arrays
|
||||||
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
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;
|
isMatch = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for projectIds (MongoDB ObjectIds)
|
// Special handling for projectIds (MongoDB ObjectIds)
|
||||||
if (field === 'projectIds') {
|
if (field === 'projectIds') {
|
||||||
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
|
||||||
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
|
||||||
|
|
||||||
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||||
isMatch = false;
|
isMatch = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle arrays of objects like tool_kwargs
|
// Handle arrays of objects
|
||||||
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
|
else if (
|
||||||
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
|
wouldBeArr.length > 0 &&
|
||||||
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
|
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])) {
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
isMatch = false;
|
isMatch = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
const sortedWouldBe = [...wouldBeArr].sort();
|
||||||
const sortedVersion = [...lastVersion[field]].sort();
|
const sortedVersion = [...lastVersionArr].sort();
|
||||||
|
|
||||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
isMatch = false;
|
isMatch = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (field === 'model_parameters') {
|
}
|
||||||
const wouldBeParams = wouldBeVersion[field] || {};
|
// Handle objects
|
||||||
const lastVersionParams = lastVersion[field] || {};
|
else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
|
||||||
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
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;
|
isMatch = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
|
||||||
isMatch = false;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +322,14 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
|
|||||||
|
|
||||||
const currentAgent = await Agent.findOne(searchParameter);
|
const currentAgent = await Agent.findOne(searchParameter);
|
||||||
if (currentAgent) {
|
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;
|
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||||
|
|
||||||
let actionsHash = null;
|
let actionsHash = null;
|
||||||
@@ -469,8 +520,113 @@ const deleteAgent = async (searchParameter) => {
|
|||||||
return agent;
|
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.
|
* Get all agents.
|
||||||
|
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
|
||||||
* @param {Object} searchParameter - The search parameters to find matching agents.
|
* @param {Object} searchParameter - The search parameters to find matching agents.
|
||||||
* @param {string} searchParameter.author - The user ID of the agent's author.
|
* @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.
|
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
|
||||||
@@ -489,13 +645,15 @@ const getListAgents = async (searchParameter) => {
|
|||||||
const agents = (
|
const agents = (
|
||||||
await Agent.find(query, {
|
await Agent.find(query, {
|
||||||
id: 1,
|
id: 1,
|
||||||
_id: 0,
|
_id: 1,
|
||||||
name: 1,
|
name: 1,
|
||||||
avatar: 1,
|
avatar: 1,
|
||||||
author: 1,
|
author: 1,
|
||||||
projectIds: 1,
|
projectIds: 1,
|
||||||
description: 1,
|
description: 1,
|
||||||
|
// @deprecated - isCollaborative replaced by ACL permissions
|
||||||
isCollaborative: 1,
|
isCollaborative: 1,
|
||||||
|
category: 1,
|
||||||
}).lean()
|
}).lean()
|
||||||
).map((agent) => {
|
).map((agent) => {
|
||||||
if (agent.author?.toString() !== author) {
|
if (agent.author?.toString() !== author) {
|
||||||
@@ -661,6 +819,14 @@ const generateActionMetadataHash = async (actionIds, actions) => {
|
|||||||
|
|
||||||
return hashHex;
|
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
|
* Load a default agent based on the endpoint
|
||||||
@@ -678,6 +844,8 @@ module.exports = {
|
|||||||
revertAgentVersion,
|
revertAgentVersion,
|
||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
addAgentResourceFile,
|
addAgentResourceFile,
|
||||||
|
getListAgentsByAccess,
|
||||||
removeAgentResourceFiles,
|
removeAgentResourceFiles,
|
||||||
generateActionMetadataHash,
|
generateActionMetadataHash,
|
||||||
|
countPromotedAgents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1258,6 +1258,328 @@ describe('models/Agent', () => {
|
|||||||
expect(secondUpdate.versions).toHaveLength(3);
|
expect(secondUpdate.versions).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should detect changes in support_contact fields', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent with initial support_contact
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent with Support Contact',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Initial Support',
|
||||||
|
email: 'initial@support.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update support_contact name only
|
||||||
|
const firstUpdate = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'Updated Support',
|
||||||
|
email: 'initial@support.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstUpdate.versions).toHaveLength(2);
|
||||||
|
expect(firstUpdate.support_contact.name).toBe('Updated Support');
|
||||||
|
expect(firstUpdate.support_contact.email).toBe('initial@support.com');
|
||||||
|
|
||||||
|
// Update support_contact email only
|
||||||
|
const secondUpdate = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'Updated Support',
|
||||||
|
email: 'updated@support.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondUpdate.versions).toHaveLength(3);
|
||||||
|
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
|
||||||
|
|
||||||
|
// Try to update with same support_contact - should be detected as duplicate
|
||||||
|
await expect(
|
||||||
|
updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'Updated Support',
|
||||||
|
email: 'updated@support.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Duplicate version');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle support_contact from empty to populated', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent without support_contact
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent without Support',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify support_contact is undefined since it wasn't provided
|
||||||
|
expect(agent.support_contact).toBeUndefined();
|
||||||
|
|
||||||
|
// Update to add support_contact
|
||||||
|
const updated = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'New Support Team',
|
||||||
|
email: 'support@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.versions).toHaveLength(2);
|
||||||
|
expect(updated.support_contact.name).toBe('New Support Team');
|
||||||
|
expect(updated.support_contact.email).toBe('support@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle support_contact edge cases in isDuplicateVersion', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent with support_contact
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Edge Case Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Support',
|
||||||
|
email: 'support@test.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update to empty support_contact
|
||||||
|
const emptyUpdate = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(emptyUpdate.versions).toHaveLength(2);
|
||||||
|
expect(emptyUpdate.support_contact).toEqual({});
|
||||||
|
|
||||||
|
// Update back to populated support_contact
|
||||||
|
const repopulated = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'Support',
|
||||||
|
email: 'support@test.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repopulated.versions).toHaveLength(3);
|
||||||
|
|
||||||
|
// Verify all versions have correct support_contact
|
||||||
|
const finalAgent = await getAgent({ id: agentId });
|
||||||
|
expect(finalAgent.versions[0].support_contact).toEqual({
|
||||||
|
name: 'Support',
|
||||||
|
email: 'support@test.com',
|
||||||
|
});
|
||||||
|
expect(finalAgent.versions[1].support_contact).toEqual({});
|
||||||
|
expect(finalAgent.versions[2].support_contact).toEqual({
|
||||||
|
name: 'Support',
|
||||||
|
email: 'support@test.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve support_contact in version history', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Version History Test',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Initial Contact',
|
||||||
|
email: 'initial@test.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multiple updates with different support_contact values
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'Second Contact',
|
||||||
|
email: 'second@test.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'Third Contact',
|
||||||
|
email: 'third@test.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalAgent = await getAgent({ id: agentId });
|
||||||
|
|
||||||
|
// Verify version history
|
||||||
|
expect(finalAgent.versions).toHaveLength(3);
|
||||||
|
expect(finalAgent.versions[0].support_contact).toEqual({
|
||||||
|
name: 'Initial Contact',
|
||||||
|
email: 'initial@test.com',
|
||||||
|
});
|
||||||
|
expect(finalAgent.versions[1].support_contact).toEqual({
|
||||||
|
name: 'Second Contact',
|
||||||
|
email: 'second@test.com',
|
||||||
|
});
|
||||||
|
expect(finalAgent.versions[2].support_contact).toEqual({
|
||||||
|
name: 'Third Contact',
|
||||||
|
email: 'third@test.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current state should match last version
|
||||||
|
expect(finalAgent.support_contact).toEqual({
|
||||||
|
name: 'Third Contact',
|
||||||
|
email: 'third@test.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle partial support_contact updates', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent with full support_contact
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Partial Update Test',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Original Name',
|
||||||
|
email: 'original@email.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// MongoDB's findOneAndUpdate will replace the entire support_contact object
|
||||||
|
// So we need to verify that partial updates still work correctly
|
||||||
|
const updated = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'New Name',
|
||||||
|
email: '', // Empty email
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.versions).toHaveLength(2);
|
||||||
|
expect(updated.support_contact.name).toBe('New Name');
|
||||||
|
expect(updated.support_contact.email).toBe('');
|
||||||
|
|
||||||
|
// Verify isDuplicateVersion works with partial changes
|
||||||
|
await expect(
|
||||||
|
updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
support_contact: {
|
||||||
|
name: 'New Name',
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Duplicate version');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge Cases
|
||||||
|
describe.each([
|
||||||
|
{
|
||||||
|
operation: 'add',
|
||||||
|
name: 'empty file_id',
|
||||||
|
needsAgent: true,
|
||||||
|
params: { tool_resource: 'file_search', file_id: '' },
|
||||||
|
shouldResolve: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: 'add',
|
||||||
|
name: 'non-existent agent',
|
||||||
|
needsAgent: false,
|
||||||
|
params: { tool_resource: 'file_search', file_id: 'file123' },
|
||||||
|
shouldResolve: false,
|
||||||
|
error: 'Agent not found for adding resource file',
|
||||||
|
},
|
||||||
|
])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
|
||||||
|
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
|
||||||
|
const agent = needsAgent ? await createBasicAgent() : null;
|
||||||
|
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
if (shouldResolve) {
|
||||||
|
await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
|
||||||
|
} else {
|
||||||
|
await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{
|
||||||
|
name: 'empty files array',
|
||||||
|
files: [],
|
||||||
|
needsAgent: true,
|
||||||
|
shouldResolve: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-existent tool_resource',
|
||||||
|
files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
|
||||||
|
needsAgent: true,
|
||||||
|
shouldResolve: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-existent agent',
|
||||||
|
files: [{ tool_resource: 'file_search', file_id: 'file123' }],
|
||||||
|
needsAgent: false,
|
||||||
|
shouldResolve: false,
|
||||||
|
error: 'Agent not found for removing resource files',
|
||||||
|
},
|
||||||
|
])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
|
||||||
|
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
|
||||||
|
const agent = needsAgent ? await createBasicAgent() : null;
|
||||||
|
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
if (shouldResolve) {
|
||||||
|
const result = await removeAgentResourceFiles({ agent_id, files });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
if (agent) {
|
||||||
|
expect(result.id).toBe(agent.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
test('should handle extremely large version history', async () => {
|
test('should handle extremely large version history', async () => {
|
||||||
const agentId = `agent_${uuidv4()}`;
|
const agentId = `agent_${uuidv4()}`;
|
||||||
@@ -1633,7 +1955,7 @@ describe('models/Agent', () => {
|
|||||||
expect(result.version).toBe(1);
|
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 authorId = new mongoose.Types.ObjectId();
|
||||||
const userId = new mongoose.Types.ObjectId();
|
const userId = new mongoose.Types.ObjectId();
|
||||||
const agentId = `agent_${uuidv4()}`;
|
const agentId = `agent_${uuidv4()}`;
|
||||||
@@ -1654,7 +1976,11 @@ describe('models/Agent', () => {
|
|||||||
model_parameters: { model: 'gpt-4' },
|
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 () => {
|
test('should handle ephemeral agent with no MCP servers', async () => {
|
||||||
@@ -1762,7 +2088,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 authorId = new mongoose.Types.ObjectId();
|
||||||
const userId = new mongoose.Types.ObjectId();
|
const userId = new mongoose.Types.ObjectId();
|
||||||
const agentId = `agent_${uuidv4()}`;
|
const agentId = `agent_${uuidv4()}`;
|
||||||
@@ -1785,7 +2111,11 @@ describe('models/Agent', () => {
|
|||||||
model_parameters: { model: 'gpt-4' },
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2570,6 +2900,93 @@ describe('models/Agent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Support Contact Field', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create subdocument with ObjectId for support_contact', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const agentData = {
|
||||||
|
id: 'agent_test_support',
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: userId,
|
||||||
|
support_contact: {
|
||||||
|
name: 'Support Team',
|
||||||
|
email: 'support@example.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
const agent = await createAgent(agentData);
|
||||||
|
|
||||||
|
// Verify support_contact is stored correctly
|
||||||
|
expect(agent.support_contact).toBeDefined();
|
||||||
|
expect(agent.support_contact.name).toBe('Support Team');
|
||||||
|
expect(agent.support_contact.email).toBe('support@example.com');
|
||||||
|
|
||||||
|
// Verify no _id field is created in support_contact
|
||||||
|
expect(agent.support_contact._id).toBeUndefined();
|
||||||
|
|
||||||
|
// Fetch from database to double-check
|
||||||
|
const dbAgent = await Agent.findOne({ id: agentData.id });
|
||||||
|
expect(dbAgent.support_contact).toBeDefined();
|
||||||
|
expect(dbAgent.support_contact.name).toBe('Support Team');
|
||||||
|
expect(dbAgent.support_contact.email).toBe('support@example.com');
|
||||||
|
expect(dbAgent.support_contact._id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty support_contact correctly', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const agentData = {
|
||||||
|
id: 'agent_test_empty_support',
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: userId,
|
||||||
|
support_contact: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = await createAgent(agentData);
|
||||||
|
|
||||||
|
// Verify empty support_contact is stored as empty object
|
||||||
|
expect(agent.support_contact).toEqual({});
|
||||||
|
expect(agent.support_contact._id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing support_contact correctly', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const agentData = {
|
||||||
|
id: 'agent_test_no_support',
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = await createAgent(agentData);
|
||||||
|
|
||||||
|
// Verify support_contact is undefined when not provided
|
||||||
|
expect(agent.support_contact).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createBasicAgent(overrides = {}) {
|
function createBasicAgent(overrides = {}) {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
id: `agent_${uuidv4()}`,
|
id: `agent_${uuidv4()}`,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { EToolResources, FileContext, Constants } = require('librechat-data-provider');
|
const { EToolResources, FileContext } = require('librechat-data-provider');
|
||||||
const { getProjectByName } = require('./Project');
|
|
||||||
const { getAgent } = require('./Agent');
|
|
||||||
const { File } = require('~/db/models');
|
const { File } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,124 +12,17 @@ const findFileById = async (file_id, options = {}) => {
|
|||||||
return await File.findOne({ file_id, ...options }).lean();
|
return await File.findOne({ file_id, ...options }).lean();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a user has access to multiple files through a shared agent (batch operation)
|
|
||||||
* @param {string} userId - The user ID to check access for
|
|
||||||
* @param {string[]} fileIds - Array of file IDs to check
|
|
||||||
* @param {string} agentId - The agent ID that might grant access
|
|
||||||
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
|
|
||||||
*/
|
|
||||||
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId, checkCollaborative = true) => {
|
|
||||||
const accessMap = new Map();
|
|
||||||
|
|
||||||
// Initialize all files as no access
|
|
||||||
fileIds.forEach((fileId) => accessMap.set(fileId, false));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await getAgent({ id: agentId });
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
return accessMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is the author - if so, grant access to all files
|
|
||||||
if (agent.author.toString() === userId) {
|
|
||||||
fileIds.forEach((fileId) => accessMap.set(fileId, true));
|
|
||||||
return accessMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if agent is shared with the user via projects
|
|
||||||
if (!agent.projectIds || agent.projectIds.length === 0) {
|
|
||||||
return accessMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if agent is in global project
|
|
||||||
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
|
|
||||||
if (
|
|
||||||
!globalProject ||
|
|
||||||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString())
|
|
||||||
) {
|
|
||||||
return accessMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent is globally shared - check if it's collaborative
|
|
||||||
if (checkCollaborative && !agent.isCollaborative) {
|
|
||||||
return accessMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check which files are actually attached
|
|
||||||
const attachedFileIds = new Set();
|
|
||||||
if (agent.tool_resources) {
|
|
||||||
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
|
|
||||||
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
|
||||||
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant access only to files that are attached to this agent
|
|
||||||
fileIds.forEach((fileId) => {
|
|
||||||
if (attachedFileIds.has(fileId)) {
|
|
||||||
accessMap.set(fileId, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return accessMap;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
|
|
||||||
return accessMap;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves files matching a given filter, sorted by the most recently updated.
|
* Retrieves files matching a given filter, sorted by the most recently updated.
|
||||||
* @param {Object} filter - The filter criteria to apply.
|
* @param {Object} filter - The filter criteria to apply.
|
||||||
* @param {Object} [_sortOptions] - Optional sort parameters.
|
* @param {Object} [_sortOptions] - Optional sort parameters.
|
||||||
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
|
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
|
||||||
* Default excludes the 'text' field.
|
* Default excludes the 'text' field.
|
||||||
* @param {Object} [options] - Additional options
|
|
||||||
* @param {string} [options.userId] - User ID for access control
|
|
||||||
* @param {string} [options.agentId] - Agent ID that might grant access to files
|
|
||||||
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
|
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
|
||||||
*/
|
*/
|
||||||
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => {
|
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
|
||||||
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
||||||
const files = await File.find(filter).select(selectFields).sort(sortOptions).lean();
|
return await File.find(filter).select(selectFields).sort(sortOptions).lean();
|
||||||
|
|
||||||
// If userId and agentId are provided, filter files based on access
|
|
||||||
if (options.userId && options.agentId) {
|
|
||||||
// Collect file IDs that need access check
|
|
||||||
const filesToCheck = [];
|
|
||||||
const ownedFiles = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.user && file.user.toString() === options.userId) {
|
|
||||||
ownedFiles.push(file);
|
|
||||||
} else {
|
|
||||||
filesToCheck.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filesToCheck.length === 0) {
|
|
||||||
return ownedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch check access for all non-owned files
|
|
||||||
const fileIds = filesToCheck.map((f) => f.file_id);
|
|
||||||
const accessMap = await hasAccessToFilesViaAgent(
|
|
||||||
options.userId,
|
|
||||||
fileIds,
|
|
||||||
options.agentId,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter files based on access
|
|
||||||
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
|
|
||||||
|
|
||||||
return [...ownedFiles, ...accessibleFiles];
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,5 +176,4 @@ module.exports = {
|
|||||||
deleteFiles,
|
deleteFiles,
|
||||||
deleteFileByFilter,
|
deleteFileByFilter,
|
||||||
batchUpdateFiles,
|
batchUpdateFiles,
|
||||||
hasAccessToFilesViaAgent,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { fileSchema } = require('@librechat/data-schemas');
|
|
||||||
const { agentSchema } = require('@librechat/data-schemas');
|
|
||||||
const { projectSchema } = require('@librechat/data-schemas');
|
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
const { createModels } = require('@librechat/data-schemas');
|
||||||
const { getFiles, createFile } = require('./File');
|
const { getFiles, createFile } = require('./File');
|
||||||
const { getProjectByName } = require('./Project');
|
|
||||||
const { createAgent } = require('./Agent');
|
const { createAgent } = require('./Agent');
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
const { seedDefaultRoles } = require('~/models');
|
||||||
|
|
||||||
let File;
|
let File;
|
||||||
let Agent;
|
let Agent;
|
||||||
let Project;
|
let AclEntry;
|
||||||
|
let User;
|
||||||
|
let modelsToCleanup = [];
|
||||||
|
|
||||||
describe('File Access Control', () => {
|
describe('File Access Control', () => {
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
@@ -19,13 +19,41 @@ describe('File Access Control', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
const mongoUri = mongoServer.getUri();
|
const mongoUri = mongoServer.getUri();
|
||||||
File = mongoose.models.File || mongoose.model('File', fileSchema);
|
|
||||||
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
|
||||||
Project = mongoose.models.Project || mongoose.model('Project', projectSchema);
|
|
||||||
await mongoose.connect(mongoUri);
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
|
// Initialize all models
|
||||||
|
const models = createModels(mongoose);
|
||||||
|
|
||||||
|
// Track which models we're adding
|
||||||
|
modelsToCleanup = Object.keys(models);
|
||||||
|
|
||||||
|
// Register models on mongoose.models so methods can access them
|
||||||
|
const dbModels = require('~/db/models');
|
||||||
|
Object.assign(mongoose.models, dbModels);
|
||||||
|
|
||||||
|
File = dbModels.File;
|
||||||
|
Agent = dbModels.Agent;
|
||||||
|
AclEntry = dbModels.AclEntry;
|
||||||
|
User = dbModels.User;
|
||||||
|
|
||||||
|
// Seed default roles
|
||||||
|
await seedDefaultRoles();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
// Clean up all collections before disconnecting
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear only the models we added
|
||||||
|
for (const modelName of modelsToCleanup) {
|
||||||
|
if (mongoose.models[modelName]) {
|
||||||
|
delete mongoose.models[modelName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await mongoose.disconnect();
|
await mongoose.disconnect();
|
||||||
await mongoServer.stop();
|
await mongoServer.stop();
|
||||||
});
|
});
|
||||||
@@ -33,16 +61,33 @@ describe('File Access Control', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await File.deleteMany({});
|
await File.deleteMany({});
|
||||||
await Agent.deleteMany({});
|
await Agent.deleteMany({});
|
||||||
await Project.deleteMany({});
|
await AclEntry.deleteMany({});
|
||||||
|
await User.deleteMany({});
|
||||||
|
// Don't delete AccessRole as they are seeded defaults needed for tests
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasAccessToFilesViaAgent', () => {
|
describe('hasAccessToFilesViaAgent', () => {
|
||||||
it('should efficiently check access for multiple files at once', async () => {
|
it('should efficiently check access for multiple files at once', async () => {
|
||||||
const userId = new mongoose.Types.ObjectId().toString();
|
const userId = new mongoose.Types.ObjectId();
|
||||||
const authorId = new mongoose.Types.ObjectId().toString();
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
const agentId = uuidv4();
|
const agentId = uuidv4();
|
||||||
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
|
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create users
|
||||||
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
email: 'user@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: authorId,
|
||||||
|
email: 'author@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
// Create files
|
// Create files
|
||||||
for (const fileId of fileIds) {
|
for (const fileId of fileIds) {
|
||||||
await createFile({
|
await createFile({
|
||||||
@@ -54,13 +99,12 @@ describe('File Access Control', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create agent with only first two files attached
|
// Create agent with only first two files attached
|
||||||
await createAgent({
|
const agent = await createAgent({
|
||||||
id: agentId,
|
id: agentId,
|
||||||
name: 'Test Agent',
|
name: 'Test Agent',
|
||||||
author: authorId,
|
author: authorId,
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
isCollaborative: true,
|
|
||||||
tool_resources: {
|
tool_resources: {
|
||||||
file_search: {
|
file_search: {
|
||||||
file_ids: [fileIds[0], fileIds[1]],
|
file_ids: [fileIds[0], fileIds[1]],
|
||||||
@@ -68,15 +112,19 @@ describe('File Access Control', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get or create global project
|
// Grant EDIT permission to user on the agent
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
// Share agent globally
|
principalId: userId,
|
||||||
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_editor',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
// Check access for all files
|
// Check access for all files
|
||||||
const { hasAccessToFilesViaAgent } = require('./File');
|
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||||
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
|
||||||
|
|
||||||
// Should have access only to the first two files
|
// Should have access only to the first two files
|
||||||
expect(accessMap.get(fileIds[0])).toBe(true);
|
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||||
@@ -86,10 +134,18 @@ describe('File Access Control', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should grant access to all files when user is the agent author', async () => {
|
it('should grant access to all files when user is the agent author', async () => {
|
||||||
const authorId = new mongoose.Types.ObjectId().toString();
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
const agentId = uuidv4();
|
const agentId = uuidv4();
|
||||||
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
|
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create author user
|
||||||
|
await User.create({
|
||||||
|
_id: authorId,
|
||||||
|
email: 'author@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
// Create agent
|
// Create agent
|
||||||
await createAgent({
|
await createAgent({
|
||||||
id: agentId,
|
id: agentId,
|
||||||
@@ -105,8 +161,8 @@ describe('File Access Control', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check access as the author
|
// Check access as the author
|
||||||
const { hasAccessToFilesViaAgent } = require('./File');
|
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||||
const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId);
|
const accessMap = await hasAccessToFilesViaAgent(authorId.toString(), fileIds, agentId);
|
||||||
|
|
||||||
// Author should have access to all files
|
// Author should have access to all files
|
||||||
expect(accessMap.get(fileIds[0])).toBe(true);
|
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||||
@@ -115,31 +171,57 @@ describe('File Access Control', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle non-existent agent gracefully', async () => {
|
it('should handle non-existent agent gracefully', async () => {
|
||||||
const userId = new mongoose.Types.ObjectId().toString();
|
const userId = new mongoose.Types.ObjectId();
|
||||||
const fileIds = [uuidv4(), uuidv4()];
|
const fileIds = [uuidv4(), uuidv4()];
|
||||||
|
|
||||||
const { hasAccessToFilesViaAgent } = require('./File');
|
// Create user
|
||||||
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent');
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
email: 'user@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(
|
||||||
|
userId.toString(),
|
||||||
|
fileIds,
|
||||||
|
'non-existent-agent',
|
||||||
|
);
|
||||||
|
|
||||||
// Should have no access to any files
|
// Should have no access to any files
|
||||||
expect(accessMap.get(fileIds[0])).toBe(false);
|
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||||
expect(accessMap.get(fileIds[1])).toBe(false);
|
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deny access when agent is not collaborative', async () => {
|
it('should deny access when user only has VIEW permission', async () => {
|
||||||
const userId = new mongoose.Types.ObjectId().toString();
|
const userId = new mongoose.Types.ObjectId();
|
||||||
const authorId = new mongoose.Types.ObjectId().toString();
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
const agentId = uuidv4();
|
const agentId = uuidv4();
|
||||||
const fileIds = [uuidv4(), uuidv4()];
|
const fileIds = [uuidv4(), uuidv4()];
|
||||||
|
|
||||||
// Create agent with files but isCollaborative: false
|
// Create users
|
||||||
await createAgent({
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
email: 'user@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: authorId,
|
||||||
|
email: 'author@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create agent with files
|
||||||
|
const agent = await createAgent({
|
||||||
id: agentId,
|
id: agentId,
|
||||||
name: 'Non-Collaborative Agent',
|
name: 'View-Only Agent',
|
||||||
author: authorId,
|
author: authorId,
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
isCollaborative: false,
|
|
||||||
tool_resources: {
|
tool_resources: {
|
||||||
file_search: {
|
file_search: {
|
||||||
file_ids: fileIds,
|
file_ids: fileIds,
|
||||||
@@ -147,17 +229,21 @@ describe('File Access Control', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get or create global project
|
// Grant only VIEW permission to user on the agent
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
// Share agent globally
|
principalId: userId,
|
||||||
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_viewer',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
// Check access for files
|
// Check access for files
|
||||||
const { hasAccessToFilesViaAgent } = require('./File');
|
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||||
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
|
||||||
|
|
||||||
// Should have no access to any files when isCollaborative is false
|
// Should have no access to any files when only VIEW permission
|
||||||
expect(accessMap.get(fileIds[0])).toBe(false);
|
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||||
expect(accessMap.get(fileIds[1])).toBe(false);
|
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -172,18 +258,28 @@ describe('File Access Control', () => {
|
|||||||
const sharedFileId = `file_${uuidv4()}`;
|
const sharedFileId = `file_${uuidv4()}`;
|
||||||
const inaccessibleFileId = `file_${uuidv4()}`;
|
const inaccessibleFileId = `file_${uuidv4()}`;
|
||||||
|
|
||||||
// Create/get global project using getProjectByName which will upsert
|
// Create users
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME);
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
email: 'user@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: authorId,
|
||||||
|
email: 'author@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
provider: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
// Create agent with shared file
|
// Create agent with shared file
|
||||||
await createAgent({
|
const agent = await createAgent({
|
||||||
id: agentId,
|
id: agentId,
|
||||||
name: 'Shared Agent',
|
name: 'Shared Agent',
|
||||||
provider: 'test',
|
provider: 'test',
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
author: authorId,
|
author: authorId,
|
||||||
projectIds: [globalProject._id],
|
|
||||||
isCollaborative: true,
|
|
||||||
tool_resources: {
|
tool_resources: {
|
||||||
file_search: {
|
file_search: {
|
||||||
file_ids: [sharedFileId],
|
file_ids: [sharedFileId],
|
||||||
@@ -191,6 +287,16 @@ describe('File Access Control', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Grant EDIT permission to user on the agent
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: userId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_editor',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
// Create files
|
// Create files
|
||||||
await createFile({
|
await createFile({
|
||||||
file_id: ownedFileId,
|
file_id: ownedFileId,
|
||||||
@@ -220,14 +326,17 @@ describe('File Access Control', () => {
|
|||||||
bytes: 300,
|
bytes: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get files with access control
|
// Get all files first
|
||||||
const files = await getFiles(
|
const allFiles = await getFiles(
|
||||||
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
|
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
|
||||||
null,
|
null,
|
||||||
{ text: 0 },
|
{ text: 0 },
|
||||||
{ userId: userId.toString(), agentId },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Then filter by access control
|
||||||
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||||
|
const files = await filterFilesByAgentAccess(allFiles, userId.toString(), agentId);
|
||||||
|
|
||||||
expect(files).toHaveLength(2);
|
expect(files).toHaveLength(2);
|
||||||
expect(files.map((f) => f.file_id)).toContain(ownedFileId);
|
expect(files.map((f) => f.file_id)).toContain(ownedFileId);
|
||||||
expect(files.map((f) => f.file_id)).toContain(sharedFileId);
|
expect(files.map((f) => f.file_id)).toContain(sharedFileId);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const {
|
|||||||
CacheKeys,
|
CacheKeys,
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
roleDefaults,
|
roleDefaults,
|
||||||
PermissionTypes,
|
|
||||||
permissionsSchema,
|
permissionsSchema,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@node-saml/passport-saml": "^5.0.0",
|
"@node-saml/passport-saml": "^5.0.0",
|
||||||
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
437
api/server/controllers/PermissionsController.js
Normal file
437
api/server/controllers/PermissionsController.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||||
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
|
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
Constants,
|
|
||||||
FileSources,
|
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
|
FileSources,
|
||||||
EToolResources,
|
EToolResources,
|
||||||
actionDelimiter,
|
actionDelimiter,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
@@ -17,18 +16,24 @@ const {
|
|||||||
createAgent,
|
createAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getListAgents,
|
getListAgentsByAccess,
|
||||||
|
countPromotedAgents,
|
||||||
|
revertAgentVersion,
|
||||||
} = require('~/models/Agent');
|
} = require('~/models/Agent');
|
||||||
|
const {
|
||||||
|
grantPermission,
|
||||||
|
findAccessibleResources,
|
||||||
|
findPubliclyAccessibleResources,
|
||||||
|
hasPublicPermission,
|
||||||
|
} = require('~/server/services/PermissionService');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||||
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
||||||
const { filterFile } = require('~/server/services/Files/process');
|
const { filterFile } = require('~/server/services/Files/process');
|
||||||
const { updateAction, getActions } = require('~/models/Action');
|
const { updateAction, getActions } = require('~/models/Action');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
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 { deleteFileByFilter } = require('~/models/File');
|
||||||
|
const { getCategoriesWithCounts } = require('~/models');
|
||||||
|
|
||||||
const systemTools = {
|
const systemTools = {
|
||||||
[Tools.execute_code]: true,
|
[Tools.execute_code]: true,
|
||||||
@@ -67,6 +72,27 @@ const createAgentHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent = await createAgent(agentData);
|
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);
|
res.status(201).json(agent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -89,21 +115,14 @@ const createAgentHandler = async (req, res) => {
|
|||||||
* @returns {Promise<Agent>} 200 - success response - application/json
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
||||||
* @returns {Error} 404 - Agent not found
|
* @returns {Error} 404 - Agent not found
|
||||||
*/
|
*/
|
||||||
const getAgentHandler = async (req, res) => {
|
const getAgentHandler = async (req, res, expandProperties = false) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const author = req.user.id;
|
const author = req.user.id;
|
||||||
|
|
||||||
let query = { id, author };
|
// Permissions are validated by middleware before calling this function
|
||||||
|
// Simply load the agent by ID
|
||||||
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
|
const agent = await getAgent({ id });
|
||||||
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
|
|
||||||
query = {
|
|
||||||
$or: [{ id, $in: globalProject.agentIds }, query],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = await getAgent(query);
|
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return res.status(404).json({ error: 'Agent not found' });
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
@@ -120,23 +139,45 @@ const getAgentHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agent.author = agent.author.toString();
|
agent.author = agent.author.toString();
|
||||||
|
|
||||||
|
// @deprecated - isCollaborative replaced by ACL permissions
|
||||||
agent.isCollaborative = !!agent.isCollaborative;
|
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) {
|
if (agent.author !== author) {
|
||||||
delete agent.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({
|
return res.status(200).json({
|
||||||
|
_id: agent._id,
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
|
description: agent.description,
|
||||||
avatar: agent.avatar,
|
avatar: agent.avatar,
|
||||||
author: agent.author,
|
author: agent.author,
|
||||||
|
provider: agent.provider,
|
||||||
|
model: agent.model,
|
||||||
projectIds: agent.projectIds,
|
projectIds: agent.projectIds,
|
||||||
|
// @deprecated - isCollaborative replaced by ACL permissions
|
||||||
isCollaborative: agent.isCollaborative,
|
isCollaborative: agent.isCollaborative,
|
||||||
|
isPublic: agent.isPublic,
|
||||||
version: agent.version,
|
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);
|
return res.status(200).json(agent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/Agents/:id] Error retrieving agent', error);
|
logger.error('[/Agents/:id] Error retrieving agent', error);
|
||||||
@@ -157,43 +198,20 @@ const updateAgentHandler = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const validatedData = agentUpdateSchema.parse(req.body);
|
const validatedData = agentUpdateSchema.parse(req.body);
|
||||||
const { projectIds, removeProjectIds, ...updateData } = removeNullishValues(validatedData);
|
const { _id, ...updateData } = removeNullishValues(validatedData);
|
||||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
|
||||||
const existingAgent = await getAgent({ id });
|
const existingAgent = await getAgent({ id });
|
||||||
|
|
||||||
if (!existingAgent) {
|
if (!existingAgent) {
|
||||||
return res.status(404).json({ error: 'Agent not found' });
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
|
||||||
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 =
|
let updatedAgent =
|
||||||
Object.keys(updateData).length > 0
|
Object.keys(updateData).length > 0
|
||||||
? await updateAgent({ id }, updateData, {
|
? await updateAgent({ id }, updateData, {
|
||||||
updatingUserId: req.user.id,
|
updatingUserId: req.user.id,
|
||||||
skipVersioning: isProjectUpdate,
|
|
||||||
})
|
})
|
||||||
: existingAgent;
|
: existingAgent;
|
||||||
|
|
||||||
if (isProjectUpdate) {
|
|
||||||
updatedAgent = await updateAgentProjects({
|
|
||||||
user: req.user,
|
|
||||||
agentId: id,
|
|
||||||
projectIds,
|
|
||||||
removeProjectIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatedAgent.author) {
|
if (updatedAgent.author) {
|
||||||
updatedAgent.author = updatedAgent.author.toString();
|
updatedAgent.author = updatedAgent.author.toString();
|
||||||
}
|
}
|
||||||
@@ -318,6 +336,26 @@ const duplicateAgentHandler = async (req, res) => {
|
|||||||
newAgentData.actions = agentActions;
|
newAgentData.actions = agentActions;
|
||||||
const newAgent = await createAgent(newAgentData);
|
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({
|
return res.status(201).json({
|
||||||
agent: newAgent,
|
agent: newAgent,
|
||||||
actions: newActionsList,
|
actions: newActionsList,
|
||||||
@@ -344,7 +382,7 @@ const deleteAgentHandler = async (req, res) => {
|
|||||||
if (!agent) {
|
if (!agent) {
|
||||||
return res.status(404).json({ error: 'Agent not found' });
|
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' });
|
return res.json({ message: 'Agent deleted' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/Agents/:id] Error deleting Agent', error);
|
logger.error('[/Agents/:id] Error deleting Agent', error);
|
||||||
@@ -353,7 +391,7 @@ const deleteAgentHandler = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Lists agents using ACL-aware permissions (ownership + explicit shares).
|
||||||
* @route GET /Agents
|
* @route GET /Agents
|
||||||
* @param {object} req - Express Request
|
* @param {object} req - Express Request
|
||||||
* @param {object} req.query - Request query
|
* @param {object} req.query - Request query
|
||||||
@@ -362,9 +400,64 @@ const deleteAgentHandler = async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
const getListAgentsHandler = async (req, res) => {
|
const getListAgentsHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await getListAgents({
|
const userId = req.user.id;
|
||||||
author: 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);
|
return res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/Agents] Error listing Agents', error);
|
logger.error('[/Agents] Error listing Agents', error);
|
||||||
@@ -531,7 +624,48 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||||||
res.status(500).json({ error: error.message });
|
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 = {
|
module.exports = {
|
||||||
createAgent: createAgentHandler,
|
createAgent: createAgentHandler,
|
||||||
getAgent: getAgentHandler,
|
getAgent: getAgentHandler,
|
||||||
@@ -541,4 +675,5 @@ module.exports = {
|
|||||||
getListAgents: getListAgentsHandler,
|
getListAgents: getListAgentsHandler,
|
||||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||||
revertAgentVersion: revertAgentVersionHandler,
|
revertAgentVersion: revertAgentVersionHandler,
|
||||||
|
getAgentCategories,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -372,52 +372,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||||||
expect(agentInDb.id).toBe(existingAgentId);
|
expect(agentInDb.id).toBe(existingAgentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject update from non-author when not collaborative', async () => {
|
|
||||||
const differentUserId = new mongoose.Types.ObjectId().toString();
|
|
||||||
mockReq.user.id = differentUserId; // Different user
|
|
||||||
mockReq.params.id = existingAgentId;
|
|
||||||
mockReq.body = {
|
|
||||||
name: 'Unauthorized Update',
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateAgentHandler(mockReq, mockRes);
|
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({
|
|
||||||
error: 'You do not have permission to modify this non-collaborative agent',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify agent was not modified in database
|
|
||||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
||||||
expect(agentInDb.name).toBe('Original Agent');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow update from non-author when collaborative', async () => {
|
|
||||||
// First make the agent collaborative
|
|
||||||
await Agent.updateOne({ id: existingAgentId }, { isCollaborative: true });
|
|
||||||
|
|
||||||
const differentUserId = new mongoose.Types.ObjectId().toString();
|
|
||||||
mockReq.user.id = differentUserId; // Different user
|
|
||||||
mockReq.params.id = existingAgentId;
|
|
||||||
mockReq.body = {
|
|
||||||
name: 'Collaborative Update',
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateAgentHandler(mockReq, mockRes);
|
|
||||||
|
|
||||||
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
|
||||||
expect(mockRes.json).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
|
||||||
expect(updatedAgent.name).toBe('Collaborative Update');
|
|
||||||
// Author field should be removed for non-author
|
|
||||||
expect(updatedAgent.author).toBeUndefined();
|
|
||||||
|
|
||||||
// Verify in database
|
|
||||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
|
||||||
expect(agentInDb.name).toBe('Collaborative Update');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow admin to update any agent', async () => {
|
test('should allow admin to update any agent', async () => {
|
||||||
const adminUserId = new mongoose.Types.ObjectId().toString();
|
const adminUserId = new mongoose.Types.ObjectId().toString();
|
||||||
mockReq.user.id = adminUserId;
|
mockReq.user.id = adminUserId;
|
||||||
@@ -555,45 +509,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||||||
expect(agentInDb.__v).not.toBe(99);
|
expect(agentInDb.__v).not.toBe(99);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should prevent privilege escalation through isCollaborative', async () => {
|
|
||||||
// Create a non-collaborative agent
|
|
||||||
const authorId = new mongoose.Types.ObjectId();
|
|
||||||
const agent = await Agent.create({
|
|
||||||
id: `agent_${uuidv4()}`,
|
|
||||||
name: 'Private Agent',
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-4',
|
|
||||||
author: authorId,
|
|
||||||
isCollaborative: false,
|
|
||||||
versions: [
|
|
||||||
{
|
|
||||||
name: 'Private Agent',
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-4',
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to make it collaborative as a different user
|
|
||||||
const attackerId = new mongoose.Types.ObjectId().toString();
|
|
||||||
mockReq.user.id = attackerId;
|
|
||||||
mockReq.params.id = agent.id;
|
|
||||||
mockReq.body = {
|
|
||||||
isCollaborative: true, // Trying to escalate privileges
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateAgentHandler(mockReq, mockRes);
|
|
||||||
|
|
||||||
// Should be rejected
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
|
||||||
|
|
||||||
// Verify in database that it's still not collaborative
|
|
||||||
const agentInDb = await Agent.findOne({ id: agent.id });
|
|
||||||
expect(agentInDb.isCollaborative).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should prevent author hijacking', async () => {
|
test('should prevent author hijacking', async () => {
|
||||||
const originalAuthorId = new mongoose.Types.ObjectId();
|
const originalAuthorId = new mongoose.Types.ObjectId();
|
||||||
const attackerId = new mongoose.Types.ObjectId();
|
const attackerId = new mongoose.Types.ObjectId();
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ const startServer = async () => {
|
|||||||
app.use('/api/agents', routes.agents);
|
app.use('/api/agents', routes.agents);
|
||||||
app.use('/api/banner', routes.banner);
|
app.use('/api/banner', routes.banner);
|
||||||
app.use('/api/memories', routes.memories);
|
app.use('/api/memories', routes.memories);
|
||||||
|
app.use('/api/permissions', routes.accessPermissions);
|
||||||
|
|
||||||
app.use('/api/tags', routes.tags);
|
app.use('/api/tags', routes.tags);
|
||||||
app.use('/api/mcp', routes.mcp);
|
app.use('/api/mcp', routes.mcp);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
157
api/server/middleware/accessResources/canAccessResource.js
Normal file
157
api/server/middleware/accessResources/canAccessResource.js
Normal 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,
|
||||||
|
};
|
||||||
9
api/server/middleware/accessResources/index.js
Normal file
9
api/server/middleware/accessResources/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const { canAccessResource } = require('./canAccessResource');
|
||||||
|
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
||||||
|
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canAccessResource,
|
||||||
|
canAccessAgentResource,
|
||||||
|
canAccessAgentFromBody,
|
||||||
|
};
|
||||||
72
api/server/middleware/checkPeoplePickerAccess.js
Normal file
72
api/server/middleware/checkPeoplePickerAccess.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||||
|
const { getRoleByName } = require('~/models/Role');
|
||||||
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to check if user has permission to access people picker functionality
|
||||||
|
* Checks specific permission based on the 'type' query parameter:
|
||||||
|
* - type=user: requires VIEW_USERS permission
|
||||||
|
* - type=group: requires VIEW_GROUPS permission
|
||||||
|
* - no type (mixed search): requires either VIEW_USERS OR VIEW_GROUPS
|
||||||
|
*/
|
||||||
|
const checkPeoplePickerAccess = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user || !user.role) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await getRoleByName(user.role);
|
||||||
|
if (!role || !role.permissions) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'No permissions configured for user role',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type } = req.query;
|
||||||
|
const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {};
|
||||||
|
const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true;
|
||||||
|
const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true;
|
||||||
|
|
||||||
|
if (type === 'user') {
|
||||||
|
if (!canViewUsers) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Insufficient permissions to search for users',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type === 'group') {
|
||||||
|
if (!canViewGroups) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Insufficient permissions to search for groups',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!canViewUsers || !canViewGroups) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Insufficient permissions to search for both users and groups',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[checkPeoplePickerAccess][${req.user?.id}] checkPeoplePickerAccess error for req.query.type = ${req.query.type}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to check permissions',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkPeoplePickerAccess,
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ const concurrentLimiter = require('./concurrentLimiter');
|
|||||||
const validateEndpoint = require('./validateEndpoint');
|
const validateEndpoint = require('./validateEndpoint');
|
||||||
const requireLocalAuth = require('./requireLocalAuth');
|
const requireLocalAuth = require('./requireLocalAuth');
|
||||||
const canDeleteAccount = require('./canDeleteAccount');
|
const canDeleteAccount = require('./canDeleteAccount');
|
||||||
|
const accessResources = require('./accessResources');
|
||||||
const setBalanceConfig = require('./setBalanceConfig');
|
const setBalanceConfig = require('./setBalanceConfig');
|
||||||
const requireLdapAuth = require('./requireLdapAuth');
|
const requireLdapAuth = require('./requireLdapAuth');
|
||||||
const abortMiddleware = require('./abortMiddleware');
|
const abortMiddleware = require('./abortMiddleware');
|
||||||
@@ -29,6 +30,7 @@ module.exports = {
|
|||||||
...validate,
|
...validate,
|
||||||
...limiters,
|
...limiters,
|
||||||
...roles,
|
...roles,
|
||||||
|
...accessResources,
|
||||||
noIndex,
|
noIndex,
|
||||||
checkBan,
|
checkBan,
|
||||||
uaParser,
|
uaParser,
|
||||||
|
|||||||
370
api/server/middleware/roles/access.spec.js
Normal file
370
api/server/middleware/roles/access.spec.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
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 false if user has only some of multiple permissions', async () => {
|
||||||
|
// User has USE but not CREATE, so should fail when checking for both
|
||||||
|
const result = await checkAccess({
|
||||||
|
req: {},
|
||||||
|
user: { id: 'user123', role: 'user' },
|
||||||
|
permissionType: PermissionTypes.AGENTS,
|
||||||
|
permissions: [Permissions.CREATE, Permissions.USE],
|
||||||
|
getRoleByName,
|
||||||
|
});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true if user has all of multiple permissions', async () => {
|
||||||
|
// Admin has both USE and CREATE
|
||||||
|
const result = await checkAccess({
|
||||||
|
req: {},
|
||||||
|
user: { id: 'admin123', role: 'admin' },
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
63
api/server/routes/accessPermissions.js
Normal file
63
api/server/routes/accessPermissions.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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 { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
|
||||||
|
|
||||||
|
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', checkPeoplePickerAccess, 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;
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { generateCheckAccess } = require('@librechat/api');
|
const { generateCheckAccess } = require('@librechat/api');
|
||||||
|
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
SystemRoles,
|
|
||||||
Permissions,
|
Permissions,
|
||||||
PermissionTypes,
|
PermissionTypes,
|
||||||
actionDelimiter,
|
actionDelimiter,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
|
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
|
||||||
|
const { findAccessibleResources } = require('~/server/services/PermissionService');
|
||||||
const { updateAction, getActions, deleteAction } = require('~/models/Action');
|
const { updateAction, getActions, deleteAction } = require('~/models/Action');
|
||||||
const { isActionDomainAllowed } = require('~/server/services/domains');
|
const { isActionDomainAllowed } = require('~/server/services/domains');
|
||||||
|
const { canAccessAgentResource } = require('~/server/middleware');
|
||||||
const { getAgent, updateAgent } = require('~/models/Agent');
|
const { getAgent, updateAgent } = require('~/models/Agent');
|
||||||
const { getRoleByName } = require('~/models/Role');
|
const { getRoleByName } = require('~/models/Role');
|
||||||
|
const { getListAgentsByAccess } = require('~/models/Agent');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -23,12 +25,6 @@ const checkAgentCreate = generateCheckAccess({
|
|||||||
getRoleByName,
|
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
|
* Retrieves all user's actions
|
||||||
* @route GET /actions/
|
* @route GET /actions/
|
||||||
@@ -37,10 +33,22 @@ const isAdmin = (req) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const admin = isAdmin(req);
|
const userId = req.user.id;
|
||||||
// If admin, get all actions, otherwise only user's actions
|
const editableAgentObjectIds = await findAccessibleResources({
|
||||||
const searchParams = admin ? {} : { user: req.user.id };
|
userId,
|
||||||
res.json(await getActions(searchParams));
|
resourceType: 'agent',
|
||||||
|
requiredPermissions: PermissionBits.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentsResponse = await getListAgentsByAccess({
|
||||||
|
accessibleIds: editableAgentObjectIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editableAgentIds = agentsResponse.data.map((agent) => agent.id);
|
||||||
|
const actions =
|
||||||
|
editableAgentIds.length > 0 ? await getActions({ agent_id: { $in: editableAgentIds } }) : [];
|
||||||
|
|
||||||
|
res.json(actions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -55,106 +63,111 @@ router.get('/', async (req, res) => {
|
|||||||
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
|
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
|
||||||
* @returns {Object} 200 - success response - application/json
|
* @returns {Object} 200 - success response - application/json
|
||||||
*/
|
*/
|
||||||
router.post('/:agent_id', checkAgentCreate, async (req, res) => {
|
router.post(
|
||||||
try {
|
'/:agent_id',
|
||||||
const { agent_id } = req.params;
|
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 }} */
|
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
|
||||||
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
|
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
|
||||||
if (!functions.length) {
|
if (!functions.length) {
|
||||||
return res.status(400).json({ message: 'No functions provided' });
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(action);
|
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||||
}
|
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
|
||||||
|
if (!isDomainAllowed) {
|
||||||
actions.push(`${domain}${actionDelimiter}${action_id}`);
|
return res.status(400).json({ message: 'Domain not allowed' });
|
||||||
|
|
||||||
/** @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];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
res.json([updatedAgent, updatedAction]);
|
let { domain } = metadata;
|
||||||
} catch (error) {
|
domain = await domainParser(domain, true);
|
||||||
const message = 'Trouble updating the Agent Action';
|
|
||||||
logger.error(message, error);
|
if (!domain) {
|
||||||
res.status(500).json({ message });
|
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.
|
* Deletes an action for a specific agent.
|
||||||
@@ -163,52 +176,56 @@ router.post('/:agent_id', checkAgentCreate, async (req, res) => {
|
|||||||
* @param {string} req.params.action_id - The ID of the action to delete.
|
* @param {string} req.params.action_id - The ID of the action to delete.
|
||||||
* @returns {Object} 200 - success response - application/json
|
* @returns {Object} 200 - success response - application/json
|
||||||
*/
|
*/
|
||||||
router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => {
|
router.delete(
|
||||||
try {
|
'/:agent_id/:action_id',
|
||||||
const { agent_id, action_id } = req.params;
|
canAccessAgentResource({
|
||||||
const admin = isAdmin(req);
|
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
|
// Permissions already validated by middleware - load agent directly
|
||||||
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
|
const agent = await getAgent({ id: agent_id });
|
||||||
const agent = await getAgent(agentQuery);
|
if (!agent) {
|
||||||
if (!agent) {
|
return res.status(404).json({ message: 'Agent not found for deleting action' });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
domain = await domainParser(domain, true);
|
const { tools = [], actions = [] } = agent;
|
||||||
|
|
||||||
if (!domain) {
|
let domain = '';
|
||||||
return res.status(400).json({ message: 'No domain provided' });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { PermissionBits } = require('@librechat/data-schemas');
|
||||||
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
|
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
|
||||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
@@ -7,6 +8,7 @@ const {
|
|||||||
// validateModel,
|
// validateModel,
|
||||||
validateConvoAccess,
|
validateConvoAccess,
|
||||||
buildEndpointOption,
|
buildEndpointOption,
|
||||||
|
canAccessAgentFromBody,
|
||||||
} = require('~/server/middleware');
|
} = require('~/server/middleware');
|
||||||
const { initializeClient } = require('~/server/services/Endpoints/agents');
|
const { initializeClient } = require('~/server/services/Endpoints/agents');
|
||||||
const AgentController = require('~/server/controllers/agents/request');
|
const AgentController = require('~/server/controllers/agents/request');
|
||||||
@@ -23,8 +25,12 @@ const checkAgentAccess = generateCheckAccess({
|
|||||||
skipCheck: skipAgentCheck,
|
skipCheck: skipAgentCheck,
|
||||||
getRoleByName,
|
getRoleByName,
|
||||||
});
|
});
|
||||||
|
const checkAgentResourceAccess = canAccessAgentFromBody({
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
router.use(checkAgentAccess);
|
router.use(checkAgentAccess);
|
||||||
|
router.use(checkAgentResourceAccess);
|
||||||
router.use(validateConvoAccess);
|
router.use(validateConvoAccess);
|
||||||
router.use(buildEndpointOption);
|
router.use(buildEndpointOption);
|
||||||
router.use(setHeaders);
|
router.use(setHeaders);
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
|
|||||||
chatRouter.use('/', chat);
|
chatRouter.use('/', chat);
|
||||||
router.use('/chat', chatRouter);
|
router.use('/chat', chatRouter);
|
||||||
|
|
||||||
|
// Add marketplace routes
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { generateCheckAccess } = require('@librechat/api');
|
const { generateCheckAccess } = require('@librechat/api');
|
||||||
|
const { PermissionBits } = require('@librechat/data-schemas');
|
||||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
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 v1 = require('~/server/controllers/agents/v1');
|
||||||
const { getRoleByName } = require('~/models/Role');
|
const { getRoleByName } = require('~/models/Role');
|
||||||
const actions = require('./actions');
|
const actions = require('./actions');
|
||||||
@@ -44,6 +45,11 @@ router.use('/actions', actions);
|
|||||||
*/
|
*/
|
||||||
router.use('/tools', tools);
|
router.use('/tools', tools);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all agent categories with counts
|
||||||
|
* @route GET /agents/marketplace/categories
|
||||||
|
*/
|
||||||
|
router.get('/categories', v1.getAgentCategories);
|
||||||
/**
|
/**
|
||||||
* Creates an agent.
|
* Creates an agent.
|
||||||
* @route POST /agents
|
* @route POST /agents
|
||||||
@@ -53,13 +59,38 @@ router.use('/tools', tools);
|
|||||||
router.post('/', checkAgentCreate, v1.createAgent);
|
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
|
* @route GET /agents/:id
|
||||||
* @param {string} req.params.id - Agent identifier.
|
* @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.
|
* Updates an agent.
|
||||||
* @route PATCH /agents/:id
|
* @route PATCH /agents/:id
|
||||||
@@ -67,7 +98,15 @@ router.get('/:id', checkAgentAccess, v1.getAgent);
|
|||||||
* @param {AgentUpdateParams} req.body - The agent update parameters.
|
* @param {AgentUpdateParams} req.body - The agent update parameters.
|
||||||
* @returns {Agent} 200 - Success response - application/json
|
* @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.
|
* Duplicates an agent.
|
||||||
@@ -75,7 +114,15 @@ router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
|
|||||||
* @param {string} req.params.id - Agent identifier.
|
* @param {string} req.params.id - Agent identifier.
|
||||||
* @returns {Agent} 201 - Success response - application/json
|
* @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.
|
* Deletes an agent.
|
||||||
@@ -83,7 +130,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
|||||||
* @param {string} req.params.id - Agent identifier.
|
* @param {string} req.params.id - Agent identifier.
|
||||||
* @returns {Agent} 200 - success response - application/json
|
* @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.
|
* 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.
|
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
|
||||||
* @returns {Object} 200 - success response - application/json
|
* @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 };
|
module.exports = { v1: router, avatar };
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ const request = require('supertest');
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
const { createMethods } = require('@librechat/data-schemas');
|
||||||
|
const { createAgent } = require('~/models/Agent');
|
||||||
|
const { createFile } = require('~/models/File');
|
||||||
|
|
||||||
// Mock dependencies
|
// Only mock the external dependencies that we don't want to test
|
||||||
jest.mock('~/server/services/Files/process', () => ({
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||||
filterFile: jest.fn(),
|
filterFile: jest.fn(),
|
||||||
@@ -25,31 +27,8 @@ jest.mock('~/server/services/Tools/credentials', () => ({
|
|||||||
loadAuthValues: jest.fn(),
|
loadAuthValues: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/server/services/Files/S3/crud', () => ({
|
// Import the router
|
||||||
refreshS3FileUrls: jest.fn(),
|
const router = require('~/server/routes/files/files');
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/cache', () => ({
|
|
||||||
getLogStores: jest.fn(() => ({
|
|
||||||
get: jest.fn(),
|
|
||||||
set: jest.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/config', () => ({
|
|
||||||
logger: {
|
|
||||||
error: jest.fn(),
|
|
||||||
warn: jest.fn(),
|
|
||||||
debug: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createFile } = require('~/models/File');
|
|
||||||
const { createAgent } = require('~/models/Agent');
|
|
||||||
const { getProjectByName } = require('~/models/Project');
|
|
||||||
|
|
||||||
// Import the router after mocks
|
|
||||||
const router = require('./files');
|
|
||||||
|
|
||||||
describe('File Routes - Agent Files Endpoint', () => {
|
describe('File Routes - Agent Files Endpoint', () => {
|
||||||
let app;
|
let app;
|
||||||
@@ -60,13 +39,42 @@ describe('File Routes - Agent Files Endpoint', () => {
|
|||||||
let fileId1;
|
let fileId1;
|
||||||
let fileId2;
|
let fileId2;
|
||||||
let fileId3;
|
let fileId3;
|
||||||
|
let File;
|
||||||
|
let User;
|
||||||
|
let Agent;
|
||||||
|
let methods;
|
||||||
|
let AclEntry;
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
let AccessRole;
|
||||||
|
let modelsToCleanup = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
await mongoose.connect(mongoServer.getUri());
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
// Initialize models
|
// Initialize all models using createModels
|
||||||
require('~/db/models');
|
const { createModels } = require('@librechat/data-schemas');
|
||||||
|
const models = createModels(mongoose);
|
||||||
|
|
||||||
|
// Track which models we're adding
|
||||||
|
modelsToCleanup = Object.keys(models);
|
||||||
|
|
||||||
|
// Register models on mongoose.models so methods can access them
|
||||||
|
Object.assign(mongoose.models, models);
|
||||||
|
|
||||||
|
// Create methods with our test mongoose instance
|
||||||
|
methods = createMethods(mongoose);
|
||||||
|
|
||||||
|
// Now we can access models from the db/models
|
||||||
|
File = models.File;
|
||||||
|
Agent = models.Agent;
|
||||||
|
AclEntry = models.AclEntry;
|
||||||
|
User = models.User;
|
||||||
|
AccessRole = models.AccessRole;
|
||||||
|
|
||||||
|
// Seed default roles using our methods
|
||||||
|
await methods.seedDefaultRoles();
|
||||||
|
|
||||||
app = express();
|
app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -82,88 +90,121 @@ describe('File Routes - Agent Files Endpoint', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await mongoose.disconnect();
|
// Clean up all collections before disconnecting
|
||||||
await mongoServer.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
// Clear database
|
|
||||||
const collections = mongoose.connection.collections;
|
const collections = mongoose.connection.collections;
|
||||||
for (const key in collections) {
|
for (const key in collections) {
|
||||||
await collections[key].deleteMany({});
|
await collections[key].deleteMany({});
|
||||||
}
|
}
|
||||||
|
|
||||||
authorId = new mongoose.Types.ObjectId().toString();
|
// Clear only the models we added
|
||||||
otherUserId = new mongoose.Types.ObjectId().toString();
|
for (const modelName of modelsToCleanup) {
|
||||||
|
if (mongoose.models[modelName]) {
|
||||||
|
delete mongoose.models[modelName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up all test data
|
||||||
|
await File.deleteMany({});
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
await User.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
// Don't delete AccessRole as they are seeded defaults needed for tests
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
authorId = new mongoose.Types.ObjectId();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId();
|
||||||
agentId = uuidv4();
|
agentId = uuidv4();
|
||||||
fileId1 = uuidv4();
|
fileId1 = uuidv4();
|
||||||
fileId2 = uuidv4();
|
fileId2 = uuidv4();
|
||||||
fileId3 = uuidv4();
|
fileId3 = uuidv4();
|
||||||
|
|
||||||
|
// Create users in database
|
||||||
|
await User.create({
|
||||||
|
_id: authorId,
|
||||||
|
username: 'author',
|
||||||
|
email: 'author@test.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: otherUserId,
|
||||||
|
username: 'other',
|
||||||
|
email: 'other@test.com',
|
||||||
|
});
|
||||||
|
|
||||||
// Create files
|
// Create files
|
||||||
await createFile({
|
await createFile({
|
||||||
user: authorId,
|
user: authorId,
|
||||||
file_id: fileId1,
|
file_id: fileId1,
|
||||||
filename: 'agent-file1.txt',
|
filename: 'file1.txt',
|
||||||
filepath: `/uploads/${authorId}/${fileId1}`,
|
filepath: '/uploads/file1.txt',
|
||||||
bytes: 1024,
|
bytes: 100,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
await createFile({
|
await createFile({
|
||||||
user: authorId,
|
user: authorId,
|
||||||
file_id: fileId2,
|
file_id: fileId2,
|
||||||
filename: 'agent-file2.txt',
|
filename: 'file2.txt',
|
||||||
filepath: `/uploads/${authorId}/${fileId2}`,
|
filepath: '/uploads/file2.txt',
|
||||||
bytes: 2048,
|
bytes: 200,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
await createFile({
|
await createFile({
|
||||||
user: otherUserId,
|
user: otherUserId,
|
||||||
file_id: fileId3,
|
file_id: fileId3,
|
||||||
filename: 'user-file.txt',
|
filename: 'file3.txt',
|
||||||
filepath: `/uploads/${otherUserId}/${fileId3}`,
|
filepath: '/uploads/file3.txt',
|
||||||
bytes: 512,
|
bytes: 300,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an agent with files attached
|
|
||||||
await createAgent({
|
|
||||||
id: agentId,
|
|
||||||
name: 'Test Agent',
|
|
||||||
author: authorId,
|
|
||||||
model: 'gpt-4',
|
|
||||||
provider: 'openai',
|
|
||||||
isCollaborative: true,
|
|
||||||
tool_resources: {
|
|
||||||
file_search: {
|
|
||||||
file_ids: [fileId1, fileId2],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Share the agent globally
|
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
|
||||||
if (globalProject) {
|
|
||||||
const { updateAgent } = require('~/models/Agent');
|
|
||||||
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /files/agent/:agent_id', () => {
|
describe('GET /files/agent/:agent_id', () => {
|
||||||
it('should return files accessible through the agent for non-author', async () => {
|
it('should return files accessible through the agent for non-author with EDIT permission', async () => {
|
||||||
|
// Create an agent with files attached
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1, fileId2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant EDIT permission to user on the agent using PermissionService
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_editor',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock req.user for this request
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: otherUserId.toString() };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await request(app).get(`/files/agent/${agentId}`);
|
const response = await request(app).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toHaveLength(2); // Only agent files, not user-owned files
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body).toHaveLength(2);
|
||||||
const fileIds = response.body.map((f) => f.file_id);
|
expect(response.body.map((f) => f.file_id)).toContain(fileId1);
|
||||||
expect(fileIds).toContain(fileId1);
|
expect(response.body.map((f) => f.file_id)).toContain(fileId2);
|
||||||
expect(fileIds).toContain(fileId2);
|
|
||||||
expect(fileIds).not.toContain(fileId3); // User's own file not included
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 when agent_id is not provided', async () => {
|
it('should return 400 when agent_id is not provided', async () => {
|
||||||
@@ -176,45 +217,63 @@ describe('File Routes - Agent Files Endpoint', () => {
|
|||||||
const response = await request(app).get('/files/agent/non-existent-agent');
|
const response = await request(app).get('/files/agent/non-existent-agent');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual([]); // Empty array for non-existent agent
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array when agent is not collaborative', async () => {
|
it('should return empty array when user only has VIEW permission', async () => {
|
||||||
// Create a non-collaborative agent
|
// Create an agent with files attached
|
||||||
const nonCollabAgentId = uuidv4();
|
const agent = await createAgent({
|
||||||
await createAgent({
|
id: agentId,
|
||||||
id: nonCollabAgentId,
|
name: 'Test Agent',
|
||||||
name: 'Non-Collaborative Agent',
|
|
||||||
author: authorId,
|
|
||||||
model: 'gpt-4',
|
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
isCollaborative: false,
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
tool_resources: {
|
tool_resources: {
|
||||||
file_search: {
|
file_search: {
|
||||||
file_ids: [fileId1],
|
file_ids: [fileId1, fileId2],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Share it globally
|
// Grant only VIEW permission to user on the agent
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
if (globalProject) {
|
await grantPermission({
|
||||||
const { updateAgent } = require('~/models/Agent');
|
principalType: 'user',
|
||||||
await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] });
|
principalId: otherUserId,
|
||||||
}
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_viewer',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await request(app).get(`/files/agent/${nonCollabAgentId}`);
|
const response = await request(app).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual([]); // Empty array when not collaborative
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return agent files for agent author', async () => {
|
it('should return agent files for agent author', async () => {
|
||||||
|
// Create an agent with files attached
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1, fileId2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Create a new app instance with author authentication
|
// Create a new app instance with author authentication
|
||||||
const authorApp = express();
|
const authorApp = express();
|
||||||
authorApp.use(express.json());
|
authorApp.use(express.json());
|
||||||
authorApp.use((req, res, next) => {
|
authorApp.use((req, res, next) => {
|
||||||
req.user = { id: authorId };
|
req.user = { id: authorId.toString() };
|
||||||
req.app = { locals: {} };
|
req.app = { locals: {} };
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -223,46 +282,48 @@ describe('File Routes - Agent Files Endpoint', () => {
|
|||||||
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toHaveLength(2); // Agent files for author
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body).toHaveLength(2);
|
||||||
const fileIds = response.body.map((f) => f.file_id);
|
|
||||||
expect(fileIds).toContain(fileId1);
|
|
||||||
expect(fileIds).toContain(fileId2);
|
|
||||||
expect(fileIds).not.toContain(fileId3); // User's own file not included
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return files uploaded by other users to shared agent for author', async () => {
|
it('should return files uploaded by other users to shared agent for author', async () => {
|
||||||
// Create a file uploaded by another user
|
const anotherUserId = new mongoose.Types.ObjectId();
|
||||||
const otherUserFileId = uuidv4();
|
const otherUserFileId = uuidv4();
|
||||||
const anotherUserId = new mongoose.Types.ObjectId().toString();
|
|
||||||
|
await User.create({
|
||||||
|
_id: anotherUserId,
|
||||||
|
username: 'another',
|
||||||
|
email: 'another@test.com',
|
||||||
|
});
|
||||||
|
|
||||||
await createFile({
|
await createFile({
|
||||||
user: anotherUserId,
|
user: anotherUserId,
|
||||||
file_id: otherUserFileId,
|
file_id: otherUserFileId,
|
||||||
filename: 'other-user-file.txt',
|
filename: 'other-user-file.txt',
|
||||||
filepath: `/uploads/${anotherUserId}/${otherUserFileId}`,
|
filepath: '/uploads/other-user-file.txt',
|
||||||
bytes: 4096,
|
bytes: 400,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update agent to include the file uploaded by another user
|
// Create agent to include the file uploaded by another user
|
||||||
const { updateAgent } = require('~/models/Agent');
|
await createAgent({
|
||||||
await updateAgent(
|
id: agentId,
|
||||||
{ id: agentId },
|
name: 'Test Agent',
|
||||||
{
|
provider: 'openai',
|
||||||
tool_resources: {
|
model: 'gpt-4',
|
||||||
file_search: {
|
author: authorId,
|
||||||
file_ids: [fileId1, fileId2, otherUserFileId],
|
tool_resources: {
|
||||||
},
|
file_search: {
|
||||||
|
file_ids: [fileId1, otherUserFileId],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
// Create app instance with author authentication
|
// Create a new app instance with author authentication
|
||||||
const authorApp = express();
|
const authorApp = express();
|
||||||
authorApp.use(express.json());
|
authorApp.use(express.json());
|
||||||
authorApp.use((req, res, next) => {
|
authorApp.use((req, res, next) => {
|
||||||
req.user = { id: authorId };
|
req.user = { id: authorId.toString() };
|
||||||
req.app = { locals: {} };
|
req.app = { locals: {} };
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -271,12 +332,10 @@ describe('File Routes - Agent Files Endpoint', () => {
|
|||||||
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toHaveLength(3); // Including file from another user
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body).toHaveLength(2);
|
||||||
const fileIds = response.body.map((f) => f.file_id);
|
expect(response.body.map((f) => f.file_id)).toContain(fileId1);
|
||||||
expect(fileIds).toContain(fileId1);
|
expect(response.body.map((f) => f.file_id)).toContain(otherUserFileId);
|
||||||
expect(fileIds).toContain(fileId2);
|
|
||||||
expect(fileIds).toContain(otherUserFileId); // File uploaded by another user
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ const {
|
|||||||
Time,
|
Time,
|
||||||
isUUID,
|
isUUID,
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
Constants,
|
|
||||||
FileSources,
|
FileSources,
|
||||||
|
PERMISSION_BITS,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
checkOpenAIStorage,
|
checkOpenAIStorage,
|
||||||
@@ -17,12 +17,13 @@ const {
|
|||||||
processDeleteRequest,
|
processDeleteRequest,
|
||||||
processAgentFileUpload,
|
processAgentFileUpload,
|
||||||
} = require('~/server/services/Files/process');
|
} = require('~/server/services/Files/process');
|
||||||
const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File');
|
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||||
|
const { checkPermission } = require('~/server/services/PermissionService');
|
||||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||||
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
|
||||||
|
const { getFiles, batchUpdateFiles } = require('~/models/File');
|
||||||
const { getAssistant } = require('~/models/Assistant');
|
const { getAssistant } = require('~/models/Assistant');
|
||||||
const { getAgent } = require('~/models/Agent');
|
const { getAgent } = require('~/models/Agent');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
@@ -77,14 +78,15 @@ router.get('/agent/:agent_id', async (req, res) => {
|
|||||||
|
|
||||||
// Check if user has access to the agent
|
// Check if user has access to the agent
|
||||||
if (agent.author.toString() !== userId) {
|
if (agent.author.toString() !== userId) {
|
||||||
// Non-authors need the agent to be globally shared and collaborative
|
// Non-authors need at least EDIT permission to view agent files
|
||||||
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
|
const hasEditPermission = await checkPermission({
|
||||||
|
userId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
requiredPermission: PERMISSION_BITS.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (!hasEditPermission) {
|
||||||
!globalProject ||
|
|
||||||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) ||
|
|
||||||
!agent.isCollaborative
|
|
||||||
) {
|
|
||||||
return res.status(200).json([]);
|
return res.status(200).json([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ const express = require('express');
|
|||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { createMethods } = require('@librechat/data-schemas');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
const { createAgent } = require('~/models/Agent');
|
||||||
|
const { createFile } = require('~/models/File');
|
||||||
|
|
||||||
// Mock dependencies
|
// Only mock the external dependencies that we don't want to test
|
||||||
jest.mock('~/server/services/Files/process', () => ({
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||||
filterFile: jest.fn(),
|
filterFile: jest.fn(),
|
||||||
@@ -44,9 +46,6 @@ jest.mock('~/config', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { createFile } = require('~/models/File');
|
|
||||||
const { createAgent } = require('~/models/Agent');
|
|
||||||
const { getProjectByName } = require('~/models/Project');
|
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
|
|
||||||
// Import the router after mocks
|
// Import the router after mocks
|
||||||
@@ -57,22 +56,51 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
let mongoServer;
|
let mongoServer;
|
||||||
let authorId;
|
let authorId;
|
||||||
let otherUserId;
|
let otherUserId;
|
||||||
let agentId;
|
|
||||||
let fileId;
|
let fileId;
|
||||||
|
let File;
|
||||||
|
let Agent;
|
||||||
|
let AclEntry;
|
||||||
|
let User;
|
||||||
|
let AccessRole;
|
||||||
|
let methods;
|
||||||
|
let modelsToCleanup = [];
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
let agentId;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
await mongoose.connect(mongoServer.getUri());
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
// Initialize models
|
// Initialize all models using createModels
|
||||||
require('~/db/models');
|
const { createModels } = require('@librechat/data-schemas');
|
||||||
|
const models = createModels(mongoose);
|
||||||
|
|
||||||
|
// Track which models we're adding
|
||||||
|
modelsToCleanup = Object.keys(models);
|
||||||
|
|
||||||
|
// Register models on mongoose.models so methods can access them
|
||||||
|
Object.assign(mongoose.models, models);
|
||||||
|
|
||||||
|
// Create methods with our test mongoose instance
|
||||||
|
methods = createMethods(mongoose);
|
||||||
|
|
||||||
|
// Now we can access models from the db/models
|
||||||
|
File = models.File;
|
||||||
|
Agent = models.Agent;
|
||||||
|
AclEntry = models.AclEntry;
|
||||||
|
User = models.User;
|
||||||
|
AccessRole = models.AccessRole;
|
||||||
|
|
||||||
|
// Seed default roles using our methods
|
||||||
|
await methods.seedDefaultRoles();
|
||||||
|
|
||||||
app = express();
|
app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Mock authentication middleware
|
// Mock authentication middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
req.user = { id: otherUserId || 'default-user' };
|
req.user = { id: otherUserId ? otherUserId.toString() : 'default-user' };
|
||||||
req.app = { locals: {} };
|
req.app = { locals: {} };
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -81,6 +109,19 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
// Clean up all collections before disconnecting
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear only the models we added
|
||||||
|
for (const modelName of modelsToCleanup) {
|
||||||
|
if (mongoose.models[modelName]) {
|
||||||
|
delete mongoose.models[modelName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await mongoose.disconnect();
|
await mongoose.disconnect();
|
||||||
await mongoServer.stop();
|
await mongoServer.stop();
|
||||||
});
|
});
|
||||||
@@ -88,48 +129,41 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Clear database
|
// Clear database - clean up all test data
|
||||||
const collections = mongoose.connection.collections;
|
await File.deleteMany({});
|
||||||
for (const key in collections) {
|
await Agent.deleteMany({});
|
||||||
await collections[key].deleteMany({});
|
await User.deleteMany({});
|
||||||
}
|
await AclEntry.deleteMany({});
|
||||||
|
// Don't delete AccessRole as they are seeded defaults needed for tests
|
||||||
|
|
||||||
authorId = new mongoose.Types.ObjectId().toString();
|
// Create test data
|
||||||
otherUserId = new mongoose.Types.ObjectId().toString();
|
authorId = new mongoose.Types.ObjectId();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId();
|
||||||
|
agentId = uuidv4();
|
||||||
fileId = uuidv4();
|
fileId = uuidv4();
|
||||||
|
|
||||||
|
// Create users in database
|
||||||
|
await User.create({
|
||||||
|
_id: authorId,
|
||||||
|
username: 'author',
|
||||||
|
email: 'author@test.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: otherUserId,
|
||||||
|
username: 'other',
|
||||||
|
email: 'other@test.com',
|
||||||
|
});
|
||||||
|
|
||||||
// Create a file owned by the author
|
// Create a file owned by the author
|
||||||
await createFile({
|
await createFile({
|
||||||
user: authorId,
|
user: authorId,
|
||||||
file_id: fileId,
|
file_id: fileId,
|
||||||
filename: 'test.txt',
|
filename: 'test.txt',
|
||||||
filepath: `/uploads/${authorId}/${fileId}`,
|
filepath: '/uploads/test.txt',
|
||||||
bytes: 1024,
|
bytes: 100,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an agent with the file attached
|
|
||||||
const agent = await createAgent({
|
|
||||||
id: uuidv4(),
|
|
||||||
name: 'Test Agent',
|
|
||||||
author: authorId,
|
|
||||||
model: 'gpt-4',
|
|
||||||
provider: 'openai',
|
|
||||||
isCollaborative: true,
|
|
||||||
tool_resources: {
|
|
||||||
file_search: {
|
|
||||||
file_ids: [fileId],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
agentId = agent.id;
|
|
||||||
|
|
||||||
// Share the agent globally
|
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
|
||||||
if (globalProject) {
|
|
||||||
const { updateAgent } = require('~/models/Agent');
|
|
||||||
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DELETE /files', () => {
|
describe('DELETE /files', () => {
|
||||||
@@ -140,8 +174,8 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
user: otherUserId,
|
user: otherUserId,
|
||||||
file_id: userFileId,
|
file_id: userFileId,
|
||||||
filename: 'user-file.txt',
|
filename: 'user-file.txt',
|
||||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
filepath: '/uploads/user-file.txt',
|
||||||
bytes: 1024,
|
bytes: 200,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +185,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file_id: userFileId,
|
file_id: userFileId,
|
||||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
filepath: '/uploads/user-file.txt',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -168,7 +202,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file_id: fileId,
|
file_id: fileId,
|
||||||
filepath: `/uploads/${authorId}/${fileId}`,
|
filepath: '/uploads/test.txt',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -180,14 +214,39 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow deleting files accessible through shared agent', async () => {
|
it('should allow deleting files accessible through shared agent', async () => {
|
||||||
|
// Create an agent with the file attached
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant EDIT permission to user on the agent
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_editor',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.delete('/files')
|
.delete('/files')
|
||||||
.send({
|
.send({
|
||||||
agent_id: agentId,
|
agent_id: agent.id,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file_id: fileId,
|
file_id: fileId,
|
||||||
filepath: `/uploads/${authorId}/${fileId}`,
|
filepath: '/uploads/test.txt',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -204,19 +263,44 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
user: authorId,
|
user: authorId,
|
||||||
file_id: unattachedFileId,
|
file_id: unattachedFileId,
|
||||||
filename: 'unattached.txt',
|
filename: 'unattached.txt',
|
||||||
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
filepath: '/uploads/unattached.txt',
|
||||||
bytes: 1024,
|
bytes: 300,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create an agent without the unattached file
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId], // Only fileId, not unattachedFileId
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant EDIT permission to user on the agent
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_editor',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.delete('/files')
|
.delete('/files')
|
||||||
.send({
|
.send({
|
||||||
agent_id: agentId,
|
agent_id: agent.id,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file_id: unattachedFileId,
|
file_id: unattachedFileId,
|
||||||
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
filepath: '/uploads/unattached.txt',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -224,6 +308,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(response.body.message).toBe('You can only delete files you have access to');
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
|
expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
|
||||||
|
expect(processDeleteRequest).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed authorized and unauthorized files', async () => {
|
it('should handle mixed authorized and unauthorized files', async () => {
|
||||||
@@ -233,8 +318,8 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
user: otherUserId,
|
user: otherUserId,
|
||||||
file_id: userFileId,
|
file_id: userFileId,
|
||||||
filename: 'user-file.txt',
|
filename: 'user-file.txt',
|
||||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
filepath: '/uploads/user-file.txt',
|
||||||
bytes: 1024,
|
bytes: 200,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,51 +329,87 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||||||
user: authorId,
|
user: authorId,
|
||||||
file_id: unauthorizedFileId,
|
file_id: unauthorizedFileId,
|
||||||
filename: 'unauthorized.txt',
|
filename: 'unauthorized.txt',
|
||||||
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
|
filepath: '/uploads/unauthorized.txt',
|
||||||
bytes: 1024,
|
bytes: 400,
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create an agent with only fileId attached
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant EDIT permission to user on the agent
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_editor',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.delete('/files')
|
.delete('/files')
|
||||||
.send({
|
.send({
|
||||||
agent_id: agentId,
|
agent_id: agent.id,
|
||||||
files: [
|
files: [
|
||||||
{
|
{ file_id: userFileId, filepath: '/uploads/user-file.txt' },
|
||||||
file_id: fileId, // Authorized through agent
|
{ file_id: fileId, filepath: '/uploads/test.txt' },
|
||||||
filepath: `/uploads/${authorId}/${fileId}`,
|
{ file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' },
|
||||||
},
|
|
||||||
{
|
|
||||||
file_id: userFileId, // Owned by user
|
|
||||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file_id: unauthorizedFileId, // Not authorized
|
|
||||||
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(response.body.message).toBe('You can only delete files you have access to');
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
|
expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
|
||||||
expect(response.body.unauthorizedFiles).not.toContain(fileId);
|
expect(processDeleteRequest).not.toHaveBeenCalled();
|
||||||
expect(response.body.unauthorizedFiles).not.toContain(userFileId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prevent deleting files when agent is not collaborative', async () => {
|
it('should prevent deleting files when user lacks EDIT permission on agent', async () => {
|
||||||
// Update the agent to be non-collaborative
|
// Create an agent with the file attached
|
||||||
const { updateAgent } = require('~/models/Agent');
|
const agent = await createAgent({
|
||||||
await updateAgent({ id: agentId }, { isCollaborative: false });
|
id: uuidv4(),
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant only VIEW permission to user on the agent
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: 'user',
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: 'agent_viewer',
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.delete('/files')
|
.delete('/files')
|
||||||
.send({
|
.send({
|
||||||
agent_id: agentId,
|
agent_id: agent.id,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file_id: fileId,
|
file_id: fileId,
|
||||||
filepath: `/uploads/${authorId}/${fileId}`,
|
filepath: '/uploads/test.txt',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const accessPermissions = require('./accessPermissions');
|
||||||
const assistants = require('./assistants');
|
const assistants = require('./assistants');
|
||||||
const categories = require('./categories');
|
const categories = require('./categories');
|
||||||
const tokenizer = require('./tokenizer');
|
const tokenizer = require('./tokenizer');
|
||||||
@@ -28,6 +29,7 @@ const user = require('./user');
|
|||||||
const mcp = require('./mcp');
|
const mcp = require('./mcp');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
mcp,
|
||||||
edit,
|
edit,
|
||||||
auth,
|
auth,
|
||||||
keys,
|
keys,
|
||||||
@@ -55,5 +57,5 @@ module.exports = {
|
|||||||
assistants,
|
assistants,
|
||||||
categories,
|
categories,
|
||||||
staticRoute,
|
staticRoute,
|
||||||
mcp,
|
accessPermissions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
setBalanceConfig,
|
setBalanceConfig,
|
||||||
checkDomainAllowed,
|
checkDomainAllowed,
|
||||||
} = require('~/server/middleware');
|
} = require('~/server/middleware');
|
||||||
|
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
@@ -35,6 +36,7 @@ const oauthHandler = async (req, res) => {
|
|||||||
req.user.provider == 'openid' &&
|
req.user.provider == 'openid' &&
|
||||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||||
) {
|
) {
|
||||||
|
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||||
setOpenIDAuthTokens(req.user.tokenset, res);
|
setOpenIDAuthTokens(req.user.tokenset, res);
|
||||||
} else {
|
} else {
|
||||||
await setAuthTokens(req.user._id, res);
|
await setAuthTokens(req.user._id, res);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
jest.mock('~/models', () => ({
|
jest.mock('~/models', () => ({
|
||||||
initializeRoles: jest.fn(),
|
initializeRoles: jest.fn(),
|
||||||
|
seedDefaultRoles: jest.fn(),
|
||||||
|
ensureDefaultCategories: jest.fn(),
|
||||||
}));
|
}));
|
||||||
jest.mock('~/models/Role', () => ({
|
jest.mock('~/models/Role', () => ({
|
||||||
updateAccessPermissions: jest.fn(),
|
updateAccessPermissions: jest.fn(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
|
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
|
||||||
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
|
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
|
||||||
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||||
|
const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
|
||||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||||
const handleRateLimits = require('./Config/handleRateLimits');
|
const handleRateLimits = require('./Config/handleRateLimits');
|
||||||
const { loadDefaultInterface } = require('./start/interface');
|
const { loadDefaultInterface } = require('./start/interface');
|
||||||
@@ -25,7 +26,6 @@ const { processModelSpecs } = require('./start/modelSpecs');
|
|||||||
const { initializeS3 } = require('./Files/S3/initialize');
|
const { initializeS3 } = require('./Files/S3/initialize');
|
||||||
const { loadAndFormatTools } = require('./ToolService');
|
const { loadAndFormatTools } = require('./ToolService');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { initializeRoles } = require('~/models');
|
|
||||||
const { setCachedTools } = require('./Config');
|
const { setCachedTools } = require('./Config');
|
||||||
const paths = require('~/config/paths');
|
const paths = require('~/config/paths');
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@ const paths = require('~/config/paths');
|
|||||||
*/
|
*/
|
||||||
const AppService = async (app) => {
|
const AppService = async (app) => {
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
await seedDefaultRoles();
|
||||||
|
await ensureDefaultCategories();
|
||||||
/** @type {TCustomConfig} */
|
/** @type {TCustomConfig} */
|
||||||
const config = (await loadCustomConfig()) ?? {};
|
const config = (await loadCustomConfig()) ?? {};
|
||||||
const configDefaults = getConfigDefaults();
|
const configDefaults = getConfigDefaults();
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ jest.mock('./Files/Firebase/initialize', () => ({
|
|||||||
}));
|
}));
|
||||||
jest.mock('~/models', () => ({
|
jest.mock('~/models', () => ({
|
||||||
initializeRoles: jest.fn(),
|
initializeRoles: jest.fn(),
|
||||||
|
seedDefaultRoles: jest.fn(),
|
||||||
|
ensureDefaultCategories: jest.fn(),
|
||||||
}));
|
}));
|
||||||
jest.mock('~/models/Role', () => ({
|
jest.mock('~/models/Role', () => ({
|
||||||
updateAccessPermissions: jest.fn(),
|
updateAccessPermissions: jest.fn(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
imageExtRegex,
|
imageExtRegex,
|
||||||
EToolResources,
|
EToolResources,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { convertImage } = require('~/server/services/Files/images/convert');
|
const { convertImage } = require('~/server/services/Files/images/convert');
|
||||||
const { createFile, getFiles, updateFile } = require('~/models/File');
|
const { createFile, getFiles, updateFile } = require('~/models/File');
|
||||||
@@ -164,14 +165,19 @@ const primeFiles = async (options, apiKey) => {
|
|||||||
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
||||||
const agentResourceIds = new Set(file_ids);
|
const agentResourceIds = new Set(file_ids);
|
||||||
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
||||||
const dbFiles = (
|
|
||||||
(await getFiles(
|
// Get all files first
|
||||||
{ file_id: { $in: file_ids } },
|
const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
|
||||||
null,
|
|
||||||
{ text: 0 },
|
// Filter by access if user and agent are provided
|
||||||
{ userId: req?.user?.id, agentId },
|
let dbFiles;
|
||||||
)) ?? []
|
if (req?.user?.id && agentId) {
|
||||||
).concat(resourceFiles);
|
dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId);
|
||||||
|
} else {
|
||||||
|
dbFiles = allFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbFiles = dbFiles.concat(resourceFiles);
|
||||||
|
|
||||||
const files = [];
|
const files = [];
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|||||||
12
api/server/services/Files/index.js
Normal file
12
api/server/services/Files/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const { processCodeFile } = require('./Code/process');
|
||||||
|
const { processFileUpload } = require('./process');
|
||||||
|
const { uploadImageBuffer } = require('./images');
|
||||||
|
const { hasAccessToFilesViaAgent, filterFilesByAgentAccess } = require('./permissions');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
processCodeFile,
|
||||||
|
processFileUpload,
|
||||||
|
uploadImageBuffer,
|
||||||
|
hasAccessToFilesViaAgent,
|
||||||
|
filterFilesByAgentAccess,
|
||||||
|
};
|
||||||
123
api/server/services/Files/permissions.js
Normal file
123
api/server/services/Files/permissions.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { PERMISSION_BITS } = require('librechat-data-provider');
|
||||||
|
const { checkPermission } = require('~/server/services/PermissionService');
|
||||||
|
const { getAgent } = require('~/models/Agent');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user has access to multiple files through a shared agent (batch operation)
|
||||||
|
* @param {string} userId - The user ID to check access for
|
||||||
|
* @param {string[]} fileIds - Array of file IDs to check
|
||||||
|
* @param {string} agentId - The agent ID that might grant access
|
||||||
|
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
|
||||||
|
*/
|
||||||
|
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => {
|
||||||
|
const accessMap = new Map();
|
||||||
|
|
||||||
|
// Initialize all files as no access
|
||||||
|
fileIds.forEach((fileId) => accessMap.set(fileId, false));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = await getAgent({ id: agentId });
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is the author - if so, grant access to all files
|
||||||
|
if (agent.author.toString() === userId) {
|
||||||
|
fileIds.forEach((fileId) => accessMap.set(fileId, true));
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has at least VIEW permission on the agent
|
||||||
|
const hasViewPermission = await checkPermission({
|
||||||
|
userId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
requiredPermission: PERMISSION_BITS.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasViewPermission) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has EDIT permission (which would indicate collaborative access)
|
||||||
|
const hasEditPermission = await checkPermission({
|
||||||
|
userId,
|
||||||
|
resourceType: 'agent',
|
||||||
|
resourceId: agent._id,
|
||||||
|
requiredPermission: PERMISSION_BITS.EDIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user only has VIEW permission, they can't access files
|
||||||
|
// Only users with EDIT permission or higher can access agent files
|
||||||
|
if (!hasEditPermission) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has edit permissions - check which files are actually attached
|
||||||
|
const attachedFileIds = new Set();
|
||||||
|
if (agent.tool_resources) {
|
||||||
|
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
|
||||||
|
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
||||||
|
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant access only to files that are attached to this agent
|
||||||
|
fileIds.forEach((fileId) => {
|
||||||
|
if (attachedFileIds.has(fileId)) {
|
||||||
|
accessMap.set(fileId, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return accessMap;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter files based on user access through agents
|
||||||
|
* @param {Array<MongoFile>} files - Array of file documents
|
||||||
|
* @param {string} userId - User ID for access control
|
||||||
|
* @param {string} agentId - Agent ID that might grant access to files
|
||||||
|
* @returns {Promise<Array<MongoFile>>} Filtered array of accessible files
|
||||||
|
*/
|
||||||
|
const filterFilesByAgentAccess = async (files, userId, agentId) => {
|
||||||
|
if (!userId || !agentId || !files || files.length === 0) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate owned files from files that need access check
|
||||||
|
const filesToCheck = [];
|
||||||
|
const ownedFiles = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.user && file.user.toString() === userId) {
|
||||||
|
ownedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
filesToCheck.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToCheck.length === 0) {
|
||||||
|
return ownedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch check access for all non-owned files
|
||||||
|
const fileIds = filesToCheck.map((f) => f.file_id);
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Filter files based on access
|
||||||
|
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
|
||||||
|
|
||||||
|
return [...ownedFiles, ...accessibleFiles];
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hasAccessToFilesViaAgent,
|
||||||
|
filterFilesByAgentAccess,
|
||||||
|
};
|
||||||
525
api/server/services/GraphApiService.js
Normal file
525
api/server/services/GraphApiService.js
Normal 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,
|
||||||
|
};
|
||||||
720
api/server/services/GraphApiService.spec.js
Normal file
720
api/server/services/GraphApiService.spec.js
Normal 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',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
721
api/server/services/PermissionService.js
Normal file
721
api/server/services/PermissionService.js
Normal 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,
|
||||||
|
};
|
||||||
1058
api/server/services/PermissionService.spec.js
Normal file
1058
api/server/services/PermissionService.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,24 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||||||
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
||||||
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
|
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
|
||||||
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
||||||
|
peoplePicker: {
|
||||||
|
admin: {
|
||||||
|
users: interfaceConfig?.peoplePicker?.admin?.users ?? defaults.peoplePicker?.admin.users,
|
||||||
|
groups: interfaceConfig?.peoplePicker?.admin?.groups ?? defaults.peoplePicker?.admin.groups,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
users: interfaceConfig?.peoplePicker?.user?.users ?? defaults.peoplePicker?.user.users,
|
||||||
|
groups: interfaceConfig?.peoplePicker?.user?.groups ?? defaults.peoplePicker?.user.groups,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
marketplace: {
|
||||||
|
admin: {
|
||||||
|
use: interfaceConfig?.marketplace?.admin?.use ?? defaults.marketplace?.admin.use,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
use: interfaceConfig?.marketplace?.user?.use ?? defaults.marketplace?.user.use,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateAccessPermissions(roleName, {
|
await updateAccessPermissions(roleName, {
|
||||||
@@ -66,6 +84,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.user?.users,
|
||||||
|
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.user?.groups,
|
||||||
|
},
|
||||||
|
[PermissionTypes.MARKETPLACE]: {
|
||||||
|
[Permissions.USE]: loadedInterface.marketplace.user?.use,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||||
});
|
});
|
||||||
await updateAccessPermissions(SystemRoles.ADMIN, {
|
await updateAccessPermissions(SystemRoles.ADMIN, {
|
||||||
@@ -80,6 +105,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.admin?.users,
|
||||||
|
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.admin?.groups,
|
||||||
|
},
|
||||||
|
[PermissionTypes.MARKETPLACE]: {
|
||||||
|
[Permissions.USE]: loadedInterface.marketplace.admin?.use,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,20 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: true,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -65,6 +73,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -87,6 +100,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -121,6 +139,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -152,6 +175,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -183,6 +211,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -205,6 +238,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -227,6 +265,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -249,6 +292,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -279,6 +327,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -310,6 +363,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -339,6 +397,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -356,12 +419,20 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -379,12 +450,20 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -416,6 +495,11 @@ describe('loadDefaultInterface', () => {
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.PEOPLE_PICKER]: {
|
||||||
|
[Permissions.VIEW_GROUPS]: undefined,
|
||||||
|
[Permissions.VIEW_USERS]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ async function setupOpenId() {
|
|||||||
email: userinfo.email || '',
|
email: userinfo.email || '',
|
||||||
emailVerified: userinfo.email_verified || false,
|
emailVerified: userinfo.email_verified || false,
|
||||||
name: fullName,
|
name: fullName,
|
||||||
|
idOnTheSource: userinfo.oid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const balanceConfig = await getBalanceConfig();
|
const balanceConfig = await getBalanceConfig();
|
||||||
@@ -375,6 +376,7 @@ async function setupOpenId() {
|
|||||||
user.openidId = userinfo.sub;
|
user.openidId = userinfo.sub;
|
||||||
user.username = username;
|
user.username = username;
|
||||||
user.name = fullName;
|
user.name = fullName;
|
||||||
|
user.idOnTheSource = userinfo.oid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||||
|
|||||||
@@ -10,4 +10,9 @@ process.env.JWT_SECRET = 'test';
|
|||||||
process.env.JWT_REFRESH_SECRET = 'test';
|
process.env.JWT_REFRESH_SECRET = 'test';
|
||||||
process.env.CREDS_KEY = 'test';
|
process.env.CREDS_KEY = 'test';
|
||||||
process.env.CREDS_IV = 'test';
|
process.env.CREDS_IV = 'test';
|
||||||
|
process.env.ALLOW_EMAIL_LOGIN = 'true';
|
||||||
|
|
||||||
|
// Set global test timeout to 30 seconds
|
||||||
|
// This can be overridden in individual tests if needed
|
||||||
|
jest.setTimeout(30000);
|
||||||
process.env.OPENAI_API_KEY = 'test';
|
process.env.OPENAI_API_KEY = 'test';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"data-provider": "cd .. && npm run build:data-provider",
|
"data-provider": "cd .. && npm run build:data-provider",
|
||||||
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
|
"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",
|
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
|
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';
|
import type { OptionWithIcon, ExtendedFile } from './types';
|
||||||
|
|
||||||
export type TAgentOption = OptionWithIcon &
|
export type TAgentOption = OptionWithIcon &
|
||||||
@@ -7,6 +12,7 @@ export type TAgentOption = OptionWithIcon &
|
|||||||
knowledge_files?: Array<[string, ExtendedFile]>;
|
knowledge_files?: Array<[string, ExtendedFile]>;
|
||||||
context_files?: Array<[string, ExtendedFile]>;
|
context_files?: Array<[string, ExtendedFile]>;
|
||||||
code_files?: Array<[string, ExtendedFile]>;
|
code_files?: Array<[string, ExtendedFile]>;
|
||||||
|
_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAgentCapabilities = {
|
export type TAgentCapabilities = {
|
||||||
@@ -30,4 +36,6 @@ export type AgentForm = {
|
|||||||
agent_ids?: string[];
|
agent_ids?: string[];
|
||||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||||
recursion_limit?: number;
|
recursion_limit?: number;
|
||||||
|
support_contact?: SupportContact;
|
||||||
|
category: string;
|
||||||
} & TAgentCapabilities;
|
} & TAgentCapabilities;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const defaultInterface = getConfigDefaults().interface;
|
|||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||||
|
|
||||||
const interfaceConfig = useMemo(
|
const interfaceConfig = useMemo(
|
||||||
() => startupConfig?.interface ?? defaultInterface,
|
() => startupConfig?.interface ?? defaultInterface,
|
||||||
[startupConfig],
|
[startupConfig],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function OpenSidebar({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
data-testid="open-sidebar-button"
|
data-testid="open-sidebar-button"
|
||||||
aria-label={localize('com_nav_open_sidebar')}
|
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={() =>
|
onClick={() =>
|
||||||
setNavVisible((prev) => {
|
setNavVisible((prev) => {
|
||||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useContext } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
|
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
|
||||||
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
|
|
||||||
import { TooltipAnchor, Button } from '~/components/ui';
|
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';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function NewChat({
|
export default function NewChat({
|
||||||
@@ -29,6 +29,15 @@ export default function NewChat({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { conversation } = store.useCreateConversationAtom(index);
|
const { conversation } = store.useCreateConversationAtom(index);
|
||||||
|
const authContext = useContext(AuthContext);
|
||||||
|
const hasAccessToAgents = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.AGENTS,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
const hasAccessToMarketplace = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.MARKETPLACE,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -50,6 +59,21 @@ export default function NewChat({
|
|||||||
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
|
[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 marketplace permission is enabled, auth is ready, and user has access to agents
|
||||||
|
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between py-[2px] md:py-2">
|
<div className="flex items-center justify-between py-[2px] md:py-2">
|
||||||
@@ -88,6 +112,29 @@ export default function NewChat({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{subHeaders != null ? subHeaders : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
PlaneTakeoffIcon,
|
PlaneTakeoffIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
TerminalSquareIcon,
|
TerminalSquareIcon,
|
||||||
|
// NEW: Add these for agent categories
|
||||||
|
Users as UsersIcon,
|
||||||
|
Beaker as BeakerIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
@@ -22,6 +26,13 @@ const categoryIconMap: Record<string, React.ElementType> = {
|
|||||||
code: TerminalSquareIcon,
|
code: TerminalSquareIcon,
|
||||||
travel: PlaneTakeoffIcon,
|
travel: PlaneTakeoffIcon,
|
||||||
teach_or_explain: GraduationCapIcon,
|
teach_or_explain: GraduationCapIcon,
|
||||||
|
// NEW: Agent categories
|
||||||
|
general: BoxIcon,
|
||||||
|
hr: UsersIcon,
|
||||||
|
rd: BeakerIcon,
|
||||||
|
it: TerminalSquareIcon,
|
||||||
|
sales: LineChartIcon,
|
||||||
|
aftersales: SettingsIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryColorMap: Record<string, string> = {
|
const categoryColorMap: Record<string, string> = {
|
||||||
@@ -34,6 +45,13 @@ const categoryColorMap: Record<string, string> = {
|
|||||||
finance: 'text-orange-400',
|
finance: 'text-orange-400',
|
||||||
roleplay: 'text-orange-400',
|
roleplay: 'text-orange-400',
|
||||||
teach_or_explain: 'text-blue-300',
|
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({
|
export default function CategoryIcon({
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import * as Popover from '@radix-ui/react-popover';
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
fileConfig as defaultFileConfig,
|
|
||||||
QueryKeys,
|
QueryKeys,
|
||||||
defaultOrderQuery,
|
|
||||||
mergeFileConfig,
|
mergeFileConfig,
|
||||||
|
fileConfig as defaultFileConfig,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
@@ -14,7 +13,12 @@ import type {
|
|||||||
AgentCreateParams,
|
AgentCreateParams,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
} from 'librechat-data-provider';
|
} 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 { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
|
||||||
import { useToastContext } from '~/Providers';
|
import { useToastContext } from '~/Providers';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
@@ -46,41 +50,41 @@ function Avatar({
|
|||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
setProgress(0.4);
|
setProgress(0.4);
|
||||||
},
|
},
|
||||||
onSuccess: (data, vars) => {
|
onSuccess: (data) => {
|
||||||
if (vars.postCreation === false) {
|
if (lastSeenCreatedId.current !== createMutation.data?.id) {
|
||||||
showToast({ message: localize('com_ui_upload_success') });
|
|
||||||
} else if (lastSeenCreatedId.current !== createMutation.data?.id) {
|
|
||||||
lastSeenCreatedId.current = createMutation.data?.id ?? '';
|
lastSeenCreatedId.current = createMutation.data?.id ?? '';
|
||||||
}
|
}
|
||||||
|
showToast({ message: localize('com_ui_upload_agent_avatar') });
|
||||||
|
|
||||||
setInput(null);
|
setInput(null);
|
||||||
const newUrl = data.avatar?.filepath ?? '';
|
const newUrl = data.avatar?.filepath ?? '';
|
||||||
setPreviewUrl(newUrl);
|
setPreviewUrl(newUrl);
|
||||||
|
|
||||||
const res = queryClient.getQueryData<AgentListResponse>([
|
((keys) => {
|
||||||
QueryKeys.agents,
|
keys.forEach((key) => {
|
||||||
defaultOrderQuery,
|
const res = queryClient.getQueryData<AgentListResponse>([QueryKeys.agents, key]);
|
||||||
]);
|
|
||||||
|
|
||||||
if (!res?.data) {
|
if (!res?.data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const agents = res.data.map((agent) => {
|
const agents = res.data.map((agent) => {
|
||||||
if (agent.id === agent_id) {
|
if (agent.id === agent_id) {
|
||||||
return {
|
return {
|
||||||
...agent,
|
...agent,
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
|
|
||||||
...res,
|
|
||||||
data: agents,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, key], {
|
||||||
|
...res,
|
||||||
|
data: agents,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(allAgentViewAndEditQueryKeys);
|
||||||
|
invalidateAgentMarketplaceQueries(queryClient);
|
||||||
setProgress(1);
|
setProgress(1);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -137,7 +141,6 @@ function Avatar({
|
|||||||
|
|
||||||
uploadAvatar({
|
uploadAvatar({
|
||||||
agent_id: createMutation.data.id,
|
agent_id: createMutation.data.id,
|
||||||
postCreation: true,
|
|
||||||
formData,
|
formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
93
client/src/components/SidePanel/Agents/AgentCard.tsx
Normal file
93
client/src/components/SidePanel/Agents/AgentCard.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -25,6 +25,7 @@ import FileSearch from './FileSearch';
|
|||||||
import Artifacts from './Artifacts';
|
import Artifacts from './Artifacts';
|
||||||
import AgentTool from './AgentTool';
|
import AgentTool from './AgentTool';
|
||||||
import CodeForm from './Code/Form';
|
import CodeForm from './Code/Form';
|
||||||
|
import AgentCategorySelector from './AgentCategorySelector';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
|
|
||||||
const labelClass = 'mb-2 text-token-text-primary block font-medium';
|
const labelClass = 'mb-2 text-token-text-primary block font-medium';
|
||||||
@@ -49,7 +50,10 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||||||
groupedTools: allTools,
|
groupedTools: allTools,
|
||||||
} = useAgentPanelContext();
|
} = useAgentPanelContext();
|
||||||
|
|
||||||
const { control } = methods;
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = methods;
|
||||||
const provider = useWatch({ control, name: 'provider' });
|
const provider = useWatch({ control, name: 'provider' });
|
||||||
const model = useWatch({ control, name: 'model' });
|
const model = useWatch({ control, name: 'model' });
|
||||||
const agent = useWatch({ control, name: 'agent' });
|
const agent = useWatch({ control, name: 'agent' });
|
||||||
@@ -193,21 +197,33 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||||||
/>
|
/>
|
||||||
<label className={labelClass} htmlFor="name">
|
<label className={labelClass} htmlFor="name">
|
||||||
{localize('com_ui_name')}
|
{localize('com_ui_name')}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Controller
|
<Controller
|
||||||
name="name"
|
name="name"
|
||||||
|
rules={{ required: localize('com_ui_agent_name_is_required') }}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<input
|
<>
|
||||||
{...field}
|
<input
|
||||||
value={field.value ?? ''}
|
{...field}
|
||||||
maxLength={256}
|
value={field.value ?? ''}
|
||||||
className={inputClass}
|
maxLength={256}
|
||||||
id="name"
|
className={inputClass}
|
||||||
type="text"
|
id="name"
|
||||||
placeholder={localize('com_agents_name_placeholder')}
|
type="text"
|
||||||
aria-label="Agent name"
|
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
|
<Controller
|
||||||
@@ -242,6 +258,13 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
||||||
<Instructions />
|
<Instructions />
|
||||||
{/* Model and Provider */}
|
{/* Model and Provider */}
|
||||||
@@ -361,6 +384,93 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||||||
</div>
|
</div>
|
||||||
{/* MCP Section */}
|
{/* MCP Section */}
|
||||||
{/* <MCPSection /> */}
|
{/* <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>
|
</div>
|
||||||
<ToolSelectDialog
|
<ToolSelectDialog
|
||||||
isOpen={showToolDialog}
|
isOpen={showToolDialog}
|
||||||
|
|||||||
218
client/src/components/SidePanel/Agents/AgentDetail.tsx
Normal file
218
client/src/components/SidePanel/Agents/AgentDetail.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
import {
|
||||||
|
AgentListResponse,
|
||||||
|
PERMISSION_BITS,
|
||||||
|
QueryKeys,
|
||||||
|
Constants,
|
||||||
|
EModelEndpoint,
|
||||||
|
LocalStorageKeys,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
|
import { useChatContext } from '~/Providers';
|
||||||
|
import { Dialog, DialogContent, Button } from '~/components/ui';
|
||||||
|
import { renderAgentAvatar } from '~/utils/agents';
|
||||||
|
import { DotsIcon } from '~/components/svg';
|
||||||
|
import { useToast, useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface SupportContact {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentWithSupport extends t.Agent {
|
||||||
|
support_contact?: SupportContact;
|
||||||
|
}
|
||||||
|
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 { conversation, newConversation } = useChatContext();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
|
||||||
|
|
||||||
|
queryClient.setQueryData<t.TMessage[]>(
|
||||||
|
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||||
|
|
||||||
|
newConversation({
|
||||||
|
template: {
|
||||||
|
conversationId: Constants.NEW_CONVO as string,
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
agent_id: agent.id,
|
||||||
|
title: `Chat with ${agent.name || 'Agent'}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useWatch, useFormContext } from 'react-hook-form';
|
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 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 { useUpdateAgentMutation } from '~/data-provider';
|
||||||
import AdvancedButton from './Advanced/AdvancedButton';
|
import AdvancedButton from './Advanced/AdvancedButton';
|
||||||
|
import VersionButton from './Version/VersionButton';
|
||||||
import DuplicateAgent from './DuplicateAgent';
|
import DuplicateAgent from './DuplicateAgent';
|
||||||
import AdminSettings from './AdminSettings';
|
import AdminSettings from './AdminSettings';
|
||||||
import DeleteButton from './DeleteButton';
|
import DeleteButton from './DeleteButton';
|
||||||
import { Spinner } from '~/components';
|
import { Spinner } from '~/components';
|
||||||
import ShareAgent from './ShareAgent';
|
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
import VersionButton from './Version/VersionButton';
|
|
||||||
|
|
||||||
export default function AgentFooter({
|
export default function AgentFooter({
|
||||||
activePanel,
|
activePanel,
|
||||||
@@ -32,12 +37,17 @@ export default function AgentFooter({
|
|||||||
const { control } = methods;
|
const { control } = methods;
|
||||||
const agent = useWatch({ control, name: 'agent' });
|
const agent = useWatch({ control, name: 'agent' });
|
||||||
const agent_id = useWatch({ control, name: 'id' });
|
const agent_id = useWatch({ control, name: 'id' });
|
||||||
|
|
||||||
const hasAccessToShareAgents = useHasAccess({
|
const hasAccessToShareAgents = useHasAccess({
|
||||||
permissionType: PermissionTypes.AGENTS,
|
permissionType: PermissionTypes.AGENTS,
|
||||||
permission: Permissions.SHARED_GLOBAL,
|
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 = () => {
|
const renderSaveButton = () => {
|
||||||
if (createMutation.isLoading || updateMutation.isLoading) {
|
if (createMutation.isLoading || updateMutation.isLoading) {
|
||||||
return <Spinner className="icon-md" aria-hidden="true" />;
|
return <Spinner className="icon-md" aria-hidden="true" />;
|
||||||
@@ -59,18 +69,21 @@ export default function AgentFooter({
|
|||||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||||
{/* Context Button */}
|
{/* Context Button */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<DeleteButton
|
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canDeleteThisAgent) &&
|
||||||
agent_id={agent_id}
|
!permissionsLoading && (
|
||||||
setCurrentAgentId={setCurrentAgentId}
|
<DeleteButton
|
||||||
createMutation={createMutation}
|
|
||||||
/>
|
|
||||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
|
|
||||||
hasAccessToShareAgents && (
|
|
||||||
<ShareAgent
|
|
||||||
agent_id={agent_id}
|
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 ?? ''}
|
agentName={agent?.name ?? ''}
|
||||||
projectIds={agent?.projectIds ?? []}
|
|
||||||
isCollaborative={agent?.isCollaborative}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||||
|
|||||||
288
client/src/components/SidePanel/Agents/AgentGrid.tsx
Normal file
288
client/src/components/SidePanel/Agents/AgentGrid.tsx
Normal 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;
|
||||||
333
client/src/components/SidePanel/Agents/AgentMarketplace.tsx
Normal file
333
client/src/components/SidePanel/Agents/AgentMarketplace.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
|
||||||
|
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
import type { ContextType } from '~/common';
|
||||||
|
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||||
|
import { MarketplaceProvider } from './MarketplaceContext';
|
||||||
|
import { useDocumentTitle, useHasAccess } from '~/hooks';
|
||||||
|
import { TooltipAnchor, Button } from '~/components/ui';
|
||||||
|
import { SidePanelGroup } from '~/components/SidePanel';
|
||||||
|
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||||
|
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||||
|
import { NewChatIcon } from '~/components/svg';
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
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 queryClient = useQueryClient();
|
||||||
|
const { conversation, newConversation } = useChatContext();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<t.TMessage[]>(
|
||||||
|
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||||
|
newConversation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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', []);
|
||||||
|
|
||||||
|
const hasAccessToMarketplace = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.MARKETPLACE,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
if (!hasAccessToMarketplace) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
navigate('/c/new');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [hasAccessToMarketplace, navigate]);
|
||||||
|
|
||||||
|
if (!hasAccessToMarketplace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
||||||
|
<MarketplaceProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<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>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</MarketplaceProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentMarketplace;
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Constants,
|
Constants,
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
|
PERMISSION_BITS,
|
||||||
isAssistantsEndpoint,
|
isAssistantsEndpoint,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { AgentForm, StringOption } from '~/common';
|
import type { AgentForm, StringOption } from '~/common';
|
||||||
@@ -14,8 +15,10 @@ import {
|
|||||||
useCreateAgentMutation,
|
useCreateAgentMutation,
|
||||||
useUpdateAgentMutation,
|
useUpdateAgentMutation,
|
||||||
useGetAgentByIdQuery,
|
useGetAgentByIdQuery,
|
||||||
|
useGetExpandedAgentByIdQuery,
|
||||||
} from '~/data-provider';
|
} from '~/data-provider';
|
||||||
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
|
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
|
||||||
|
import { useResourcePermissions } from '~/hooks/useResourcePermissions';
|
||||||
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
||||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||||
import AgentPanelSkeleton from './AgentPanelSkeleton';
|
import AgentPanelSkeleton from './AgentPanelSkeleton';
|
||||||
@@ -44,10 +47,29 @@ export default function AgentPanel() {
|
|||||||
const { onSelect: onSelectAgent } = useSelectAgent();
|
const { onSelect: onSelectAgent } = useSelectAgent();
|
||||||
|
|
||||||
const modelsQuery = useGetModelsQuery();
|
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,
|
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 models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
|
||||||
const methods = useForm<AgentForm>({
|
const methods = useForm<AgentForm>({
|
||||||
defaultValues: getDefaultAgentFormValues(),
|
defaultValues: getDefaultAgentFormValues(),
|
||||||
@@ -177,6 +199,8 @@ export default function AgentPanel() {
|
|||||||
end_after_tools,
|
end_after_tools,
|
||||||
hide_sequential_outputs,
|
hide_sequential_outputs,
|
||||||
recursion_limit,
|
recursion_limit,
|
||||||
|
category,
|
||||||
|
support_contact,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const model = _model ?? '';
|
const model = _model ?? '';
|
||||||
@@ -199,6 +223,8 @@ export default function AgentPanel() {
|
|||||||
end_after_tools,
|
end_after_tools,
|
||||||
hide_sequential_outputs,
|
hide_sequential_outputs,
|
||||||
recursion_limit,
|
recursion_limit,
|
||||||
|
category,
|
||||||
|
support_contact,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -210,6 +236,12 @@ export default function AgentPanel() {
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!name) {
|
||||||
|
return showToast({
|
||||||
|
message: localize('com_agents_missing_name'),
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
create.mutate({
|
create.mutate({
|
||||||
name,
|
name,
|
||||||
@@ -224,6 +256,8 @@ export default function AgentPanel() {
|
|||||||
end_after_tools,
|
end_after_tools,
|
||||||
hide_sequential_outputs,
|
hide_sequential_outputs,
|
||||||
recursion_limit,
|
recursion_limit,
|
||||||
|
category,
|
||||||
|
support_contact,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agent_id, create, update, showToast, localize],
|
[agent_id, create, update, showToast, localize],
|
||||||
@@ -236,19 +270,16 @@ export default function AgentPanel() {
|
|||||||
}, [agent_id, onSelectAgent]);
|
}, [agent_id, onSelectAgent]);
|
||||||
|
|
||||||
const canEditAgent = useMemo(() => {
|
const canEditAgent = useMemo(() => {
|
||||||
const canEdit =
|
if (!agentQuery.data?.id) {
|
||||||
(agentQuery.data?.isCollaborative ?? false)
|
return true;
|
||||||
? true
|
}
|
||||||
: agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN;
|
|
||||||
|
|
||||||
return agentQuery.data?.id != null && agentQuery.data.id ? canEdit : true;
|
if (user?.role === SystemRoles.ADMIN) {
|
||||||
}, [
|
return true;
|
||||||
agentQuery.data?.isCollaborative,
|
}
|
||||||
agentQuery.data?.author,
|
|
||||||
agentQuery.data?.id,
|
return canEdit;
|
||||||
user?.id,
|
}, [agentQuery.data?.id, user?.role, canEdit]);
|
||||||
user?.role,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
@@ -257,7 +288,7 @@ export default function AgentPanel() {
|
|||||||
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
||||||
aria-label="Agent configuration form"
|
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">
|
<div className="w-full">
|
||||||
<AgentSelect
|
<AgentSelect
|
||||||
createMutation={create}
|
createMutation={create}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-que
|
|||||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||||
import type { TAgentCapabilities, AgentForm } from '~/common';
|
import type { TAgentCapabilities, AgentForm } from '~/common';
|
||||||
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
|
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
|
||||||
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider';
|
import {
|
||||||
|
useListAgentsQuery,
|
||||||
|
useGetStartupConfig,
|
||||||
|
useAgentListingDefaultPermissionLevel,
|
||||||
|
} from '~/data-provider';
|
||||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
@@ -28,24 +32,27 @@ export default function AgentSelect({
|
|||||||
const { control, reset } = useFormContext();
|
const { control, reset } = useFormContext();
|
||||||
|
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { data: agents = null } = useListAgentsQuery(undefined, {
|
const permissionLevel = useAgentListingDefaultPermissionLevel();
|
||||||
select: (res) =>
|
|
||||||
res.data.map((agent) =>
|
const { data: agents = null } = useListAgentsQuery(
|
||||||
processAgentOption({
|
{ requiredPermission: permissionLevel },
|
||||||
agent: {
|
{
|
||||||
...agent,
|
select: (res) =>
|
||||||
name: agent.name || agent.id,
|
res.data.map((agent) =>
|
||||||
},
|
processAgentOption({
|
||||||
instanceProjectId: startupConfig?.instanceProjectId,
|
agent: {
|
||||||
}),
|
...agent,
|
||||||
),
|
name: agent.name || agent.id,
|
||||||
});
|
},
|
||||||
|
instanceProjectId: startupConfig?.instanceProjectId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const resetAgentForm = useCallback(
|
const resetAgentForm = useCallback(
|
||||||
(fullAgent: Agent) => {
|
(fullAgent: Agent) => {
|
||||||
const { instanceProjectId } = startupConfig ?? {};
|
const isGlobal = fullAgent.isPublic ?? false;
|
||||||
const isGlobal =
|
|
||||||
(instanceProjectId != null && fullAgent.projectIds?.includes(instanceProjectId)) ?? false;
|
|
||||||
const update = {
|
const update = {
|
||||||
...fullAgent,
|
...fullAgent,
|
||||||
provider: createProviderOption(fullAgent.provider),
|
provider: createProviderOption(fullAgent.provider),
|
||||||
@@ -77,6 +84,10 @@ export default function AgentSelect({
|
|||||||
agent: update,
|
agent: update,
|
||||||
model: update.model,
|
model: update.model,
|
||||||
tools: agentTools,
|
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]) => {
|
Object.entries(fullAgent).forEach(([name, value]) => {
|
||||||
|
|||||||
172
client/src/components/SidePanel/Agents/CategoryTabs.tsx
Normal file
172
client/src/components/SidePanel/Agents/CategoryTabs.tsx
Normal 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;
|
||||||
252
client/src/components/SidePanel/Agents/ErrorDisplay.tsx
Normal file
252
client/src/components/SidePanel/Agents/ErrorDisplay.tsx
Normal 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;
|
||||||
@@ -19,7 +19,7 @@ export default function ImageVision() {
|
|||||||
{...field}
|
{...field}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
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()}
|
value={field.value?.toString()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChatContext } from '~/Providers';
|
||||||
|
import { useChatHelpers } from '~/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) => {
|
||||||
|
const chatHelpers = useChatHelpers(0, 'new');
|
||||||
|
|
||||||
|
return <ChatContext.Provider value={chatHelpers as any}>{children}</ChatContext.Provider>;
|
||||||
|
};
|
||||||
111
client/src/components/SidePanel/Agents/SearchBar.tsx
Normal file
111
client/src/components/SidePanel/Agents/SearchBar.tsx
Normal 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;
|
||||||
@@ -1,270 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||||
|
import { ACCESS_ROLE_IDS, PermissionTypes, Permissions } 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, useHasAccess } 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();
|
||||||
|
|
||||||
|
// Check if user has permission to access people picker
|
||||||
|
const canViewUsers = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.PEOPLE_PICKER,
|
||||||
|
permission: Permissions.VIEW_USERS,
|
||||||
|
});
|
||||||
|
const canViewGroups = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.PEOPLE_PICKER,
|
||||||
|
permission: Permissions.VIEW_GROUPS,
|
||||||
|
});
|
||||||
|
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
|
||||||
|
|
||||||
|
// Determine type filter based on permissions
|
||||||
|
const peoplePickerTypeFilter = useMemo(() => {
|
||||||
|
if (canViewUsers && canViewGroups) {
|
||||||
|
return null; // Both types allowed
|
||||||
|
} else if (canViewUsers) {
|
||||||
|
return 'user' as const;
|
||||||
|
} else if (canViewGroups) {
|
||||||
|
return 'group' as const;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [canViewUsers, canViewGroups]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{hasPeoplePickerAccess && (
|
||||||
|
<>
|
||||||
|
<PeoplePicker
|
||||||
|
onSelectionChange={setNewShares}
|
||||||
|
placeholder={localize('com_ui_search_people_placeholder')}
|
||||||
|
typeFilter={peoplePickerTypeFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{hasPeoplePickerAccess && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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) {
|
||||||
|
const shares = permissionsData.principals || [];
|
||||||
|
const isPublicValue = permissionsData.public || false;
|
||||||
|
const publicRoleValue = permissionsData.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
|
||||||
|
|
||||||
|
setManagedShares(shares);
|
||||||
|
setManagedIsPublic(isPublicValue);
|
||||||
|
setManagedPublicRole(publicRoleValue);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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;
|
||||||
|
typeFilter?: 'user' | 'group' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeoplePicker({
|
||||||
|
onSelectionChange,
|
||||||
|
placeholder,
|
||||||
|
className = '',
|
||||||
|
typeFilter = null,
|
||||||
|
}: PeoplePickerProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||||
|
|
||||||
|
const searchParams: PrincipalSearchParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
q: searchQuery,
|
||||||
|
limit: 30,
|
||||||
|
...(typeFilter && { type: typeFilter }),
|
||||||
|
}),
|
||||||
|
[searchQuery, typeFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
client/src/components/SidePanel/Agents/SmartLoader.tsx
Normal file
96
client/src/components/SidePanel/Agents/SmartLoader.tsx
Normal 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;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
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 { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
import AgentDetail from '../AgentDetail';
|
||||||
|
import { useToast } from '~/hooks';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
useLocalize: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils/agents', () => ({
|
||||||
|
renderAgentAvatar: jest.fn((agent, options) => (
|
||||||
|
<div data-testid="agent-avatar" data-size={options?.size} />
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/Providers', () => ({
|
||||||
|
useChatContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@tanstack/react-query', () => ({
|
||||||
|
...jest.requireActual('@tanstack/react-query'),
|
||||||
|
useQueryClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
const { useLocalize } = require('~/hooks');
|
||||||
|
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
|
||||||
|
|
||||||
|
// Mock useChatContext
|
||||||
|
const { useChatContext } = require('~/Providers');
|
||||||
|
(useChatContext as jest.Mock).mockReturnValue({
|
||||||
|
conversation: { conversationId: 'test-convo-id' },
|
||||||
|
newConversation: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock useQueryClient
|
||||||
|
const { useQueryClient } = require('@tanstack/react-query');
|
||||||
|
(useQueryClient as jest.Mock).mockReturnValue({
|
||||||
|
getQueryData: jest.fn(),
|
||||||
|
setQueryData: jest.fn(),
|
||||||
|
invalidateQueries: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
const mockNewConversation = jest.fn();
|
||||||
|
const mockQueryClient = {
|
||||||
|
getQueryData: jest.fn().mockReturnValue(null),
|
||||||
|
setQueryData: jest.fn(),
|
||||||
|
invalidateQueries: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update mocks for this test
|
||||||
|
const { useChatContext } = require('~/Providers');
|
||||||
|
(useChatContext as jest.Mock).mockReturnValue({
|
||||||
|
conversation: { conversationId: 'test-convo-id' },
|
||||||
|
newConversation: mockNewConversation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useQueryClient } = require('@tanstack/react-query');
|
||||||
|
(useQueryClient as jest.Mock).mockReturnValue(mockQueryClient);
|
||||||
|
|
||||||
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
|
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||||
|
await user.click(startChatButton);
|
||||||
|
|
||||||
|
expect(mockNewConversation).toHaveBeenCalledWith({
|
||||||
|
template: {
|
||||||
|
conversationId: Constants.NEW_CONVO,
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
agent_id: 'test-agent-id',
|
||||||
|
title: 'Chat with Test Agent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +1,41 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { SystemRoles } from 'librechat-data-provider';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
import '@testing-library/jest-dom/extend-expect';
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||||
import AgentFooter from '../AgentFooter';
|
import AgentFooter from '../AgentFooter';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
|
||||||
import { SystemRoles } from 'librechat-data-provider';
|
const mockUseWatch = jest.fn();
|
||||||
import * as reactHookForm from 'react-hook-form';
|
const mockUseAuthContext = jest.fn();
|
||||||
import * as hooks from '~/hooks';
|
const mockUseHasAccess = jest.fn();
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
const mockUseResourcePermissions = jest.fn();
|
||||||
|
|
||||||
jest.mock('react-hook-form', () => ({
|
jest.mock('react-hook-form', () => ({
|
||||||
useFormContext: () => ({
|
useFormContext: () => ({
|
||||||
control: {},
|
control: {},
|
||||||
}),
|
}),
|
||||||
useWatch: () => {
|
useWatch: (params) => mockUseWatch(params),
|
||||||
return {
|
|
||||||
agent: {
|
|
||||||
name: 'Test Agent',
|
|
||||||
author: 'user-123',
|
|
||||||
projectIds: ['project-1'],
|
|
||||||
isCollaborative: false,
|
|
||||||
},
|
|
||||||
id: 'agent-123',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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 = {
|
const mockUser = {
|
||||||
id: 'user-123',
|
id: 'user-123',
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
@@ -39,6 +49,26 @@ const mockUser = {
|
|||||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
} as TUser;
|
} 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', () => ({
|
jest.mock('~/hooks', () => ({
|
||||||
useLocalize: () => (key) => {
|
useLocalize: () => (key) => {
|
||||||
const translations = {
|
const translations = {
|
||||||
@@ -47,17 +77,9 @@ jest.mock('~/hooks', () => ({
|
|||||||
};
|
};
|
||||||
return translations[key] || key;
|
return translations[key] || key;
|
||||||
},
|
},
|
||||||
useAuthContext: () => ({
|
useAuthContext: () => mockUseAuthContext(),
|
||||||
user: mockUser,
|
useHasAccess: () => mockUseHasAccess(),
|
||||||
token: 'mock-token',
|
useResourcePermissions: () => mockUseResourcePermissions(),
|
||||||
isAuthenticated: true,
|
|
||||||
error: undefined,
|
|
||||||
login: jest.fn(),
|
|
||||||
logout: jest.fn(),
|
|
||||||
setError: jest.fn(),
|
|
||||||
roles: {},
|
|
||||||
}),
|
|
||||||
useHasAccess: () => true,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createBaseMutation = <T = Agent, P = any>(
|
const createBaseMutation = <T = Agent, P = any>(
|
||||||
@@ -126,9 +148,9 @@ jest.mock('../DeleteButton', () => ({
|
|||||||
default: jest.fn(() => <div data-testid="delete-button" />),
|
default: jest.fn(() => <div data-testid="delete-button" />),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../ShareAgent', () => ({
|
jest.mock('../Sharing/GrantAccessDialog', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: jest.fn(() => <div data-testid="share-agent" />),
|
default: jest.fn(() => <div data-testid="grant-access-dialog" />),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../DuplicateAgent', () => ({
|
jest.mock('../DuplicateAgent', () => ({
|
||||||
@@ -186,6 +208,40 @@ describe('AgentFooter', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
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', () => {
|
describe('Main Functionality', () => {
|
||||||
@@ -196,8 +252,8 @@ describe('AgentFooter', () => {
|
|||||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,42 +283,125 @@ describe('AgentFooter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adjusts UI based on agent ID existence', () => {
|
test('adjusts UI based on agent ID existence', () => {
|
||||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
mockUseWatch.mockImplementation(({ name }) => {
|
||||||
agent: { name: 'Test Agent', author: 'user-123' },
|
if (name === 'agent') {
|
||||||
id: undefined,
|
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} />);
|
render(<AgentFooter {...defaultProps} />);
|
||||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
|
||||||
});
|
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('grant-access-dialog')).not.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.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adjusts UI based on permissions', () => {
|
test('adjusts UI based on user role', () => {
|
||||||
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
|
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} />);
|
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', () => {
|
describe('Edge Cases', () => {
|
||||||
test('handles null agent data', () => {
|
test('handles null agent data', () => {
|
||||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
mockUseWatch.mockImplementation(({ name }) => {
|
||||||
agent: null,
|
if (name === 'agent') {
|
||||||
id: 'agent-123',
|
return null;
|
||||||
}));
|
}
|
||||||
|
if (name === 'id') {
|
||||||
|
return 'agent-123';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
render(<AgentFooter {...defaultProps} />);
|
render(<AgentFooter {...defaultProps} />);
|
||||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useChatHelpers to avoid Recoil dependency
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useChatHelpers: 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();
|
||||||
|
|
||||||
|
// Mock useChatHelpers return value
|
||||||
|
const { useChatHelpers } = require('~/hooks');
|
||||||
|
(useChatHelpers as jest.Mock).mockReturnValue({
|
||||||
|
conversation: {
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
conversationId: 'marketplace',
|
||||||
|
title: 'Agent Marketplace',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides correct marketplace context values', () => {
|
||||||
|
const mockContext = {
|
||||||
|
conversation: {
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
conversationId: 'marketplace',
|
||||||
|
title: 'Agent Marketplace',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedUseChatContext.mockReturnValue(mockContext as ReturnType<typeof useChatContext>);
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,7 +97,13 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||||||
<Select.SelectPopover
|
<Select.SelectPopover
|
||||||
portal={portal}
|
portal={portal}
|
||||||
store={selectProps}
|
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) => {
|
{options.map((item, index) => {
|
||||||
if (isDivider(item)) {
|
if (isDivider(item)) {
|
||||||
|
|||||||
192
client/src/components/ui/SearchPicker.tsx
Normal file
192
client/src/components/ui/SearchPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
|
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
|
||||||
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
|
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
|
||||||
import type { Option } from '~/common';
|
import type { Option } from '~/common';
|
||||||
@@ -32,6 +32,7 @@ function SelectDropDownPop({
|
|||||||
footer,
|
footer,
|
||||||
}: SelectDropDownProps) {
|
}: SelectDropDownProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const transitionProps = { className: 'top-full mt-3' };
|
const transitionProps = { className: 'top-full mt-3' };
|
||||||
if (showAbove) {
|
if (showAbove) {
|
||||||
transitionProps.className = 'bottom-full mb-3';
|
transitionProps.className = 'bottom-full mb-3';
|
||||||
@@ -54,8 +55,13 @@ function SelectDropDownPop({
|
|||||||
const hasSearchRender = Boolean(searchRender);
|
const hasSearchRender = Boolean(searchRender);
|
||||||
const options = hasSearchRender ? filteredValues : availableValues;
|
const options = hasSearchRender ? filteredValues : availableValues;
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
setValue(selectedValue);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root>
|
<Root open={open} onOpenChange={setOpen}>
|
||||||
<div className={'flex items-center justify-center gap-2'}>
|
<div className={'flex items-center justify-center gap-2'}>
|
||||||
<div className={'relative w-full'}>
|
<div className={'relative w-full'}>
|
||||||
<Trigger asChild>
|
<Trigger asChild>
|
||||||
@@ -108,19 +114,32 @@ function SelectDropDownPop({
|
|||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
className={cn(
|
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',
|
hasSearchRender && 'relative',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{searchRender}
|
{searchRender}
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
|
if (typeof option === 'string') {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={option}
|
||||||
|
title={option}
|
||||||
|
value={option}
|
||||||
|
selected={!!(value && value === option)}
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={option}
|
key={option.value}
|
||||||
title={option}
|
title={option.label}
|
||||||
value={option}
|
description={option.description}
|
||||||
selected={!!(value && value === option)}
|
value={option.value}
|
||||||
onClick={() => setValue(option)}
|
icon={option.icon}
|
||||||
|
selected={!!(value && value === option.value)}
|
||||||
|
onClick={() => handleSelect(option.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
12
client/src/constants/agentCategories.ts
Normal file
12
client/src/constants/agentCategories.ts
Normal 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',
|
||||||
|
};
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
export * from './queries';
|
export * from './queries';
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
|
|
||||||
|
// Re-export specific marketplace queries for easier imports
|
||||||
|
export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries';
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
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 * as t from 'librechat-data-provider';
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { QueryClient, UseMutationResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AGENTS
|
* AGENTS
|
||||||
*/
|
*/
|
||||||
|
export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [
|
||||||
|
{ requiredPermission: PERMISSION_BITS.VIEW },
|
||||||
|
{ requiredPermission: PERMISSION_BITS.EDIT },
|
||||||
|
];
|
||||||
/**
|
/**
|
||||||
* Create a new agent
|
* Create a new agent
|
||||||
*/
|
*/
|
||||||
@@ -18,21 +21,22 @@ export const useCreateAgentMutation = (
|
|||||||
onMutate: (variables) => options?.onMutate?.(variables),
|
onMutate: (variables) => options?.onMutate?.(variables),
|
||||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||||
onSuccess: (newAgent, variables, context) => {
|
onSuccess: (newAgent, variables, context) => {
|
||||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
((keys: t.AgentListParams[]) => {
|
||||||
QueryKeys.agents,
|
keys.forEach((key) => {
|
||||||
defaultOrderQuery,
|
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) {
|
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
|
||||||
return options?.onSuccess?.(newAgent, variables, context);
|
...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);
|
return options?.onSuccess?.(newAgent, variables, context);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -63,26 +67,33 @@ export const useUpdateAgentMutation = (
|
|||||||
return options?.onError?.(typedError, variables, context);
|
return options?.onError?.(typedError, variables, context);
|
||||||
},
|
},
|
||||||
onSuccess: (updatedAgent, variables, context) => {
|
onSuccess: (updatedAgent, variables, context) => {
|
||||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
((keys: t.AgentListParams[]) => {
|
||||||
QueryKeys.agents,
|
keys.forEach((key) => {
|
||||||
defaultOrderQuery,
|
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
|
||||||
]);
|
|
||||||
|
|
||||||
if (!listRes) {
|
if (!listRes) {
|
||||||
return options?.onSuccess?.(updatedAgent, variables, context);
|
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;
|
|
||||||
}
|
}
|
||||||
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], updatedAgent);
|
||||||
|
queryClient.setQueryData<t.Agent>(
|
||||||
|
[QueryKeys.agent, variables.agent_id, 'expanded'],
|
||||||
|
updatedAgent,
|
||||||
|
);
|
||||||
|
invalidateAgentMarketplaceQueries(queryClient);
|
||||||
|
|
||||||
return options?.onSuccess?.(updatedAgent, variables, context);
|
return options?.onSuccess?.(updatedAgent, variables, context);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -104,23 +115,28 @@ export const useDeleteAgentMutation = (
|
|||||||
onMutate: (variables) => options?.onMutate?.(variables),
|
onMutate: (variables) => options?.onMutate?.(variables),
|
||||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||||
onSuccess: (_data, variables, context) => {
|
onSuccess: (_data, variables, context) => {
|
||||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
const data = ((keys: t.AgentListParams[]) => {
|
||||||
QueryKeys.agents,
|
let data: t.Agent[] = [];
|
||||||
defaultOrderQuery,
|
keys.forEach((key) => {
|
||||||
]);
|
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
|
||||||
|
|
||||||
if (!listRes) {
|
if (!listRes) {
|
||||||
return options?.onSuccess?.(_data, variables, context);
|
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], {
|
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
|
||||||
...listRes,
|
...listRes,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
})(allAgentViewAndEditQueryKeys);
|
||||||
|
|
||||||
queryClient.removeQueries([QueryKeys.agent, variables.agent_id]);
|
queryClient.removeQueries([QueryKeys.agent, variables.agent_id]);
|
||||||
|
queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']);
|
||||||
|
invalidateAgentMarketplaceQueries(queryClient);
|
||||||
|
|
||||||
return options?.onSuccess?.(_data, variables, data);
|
return options?.onSuccess?.(_data, variables, data);
|
||||||
},
|
},
|
||||||
@@ -142,22 +158,23 @@ export const useDuplicateAgentMutation = (
|
|||||||
onMutate: options?.onMutate,
|
onMutate: options?.onMutate,
|
||||||
onError: options?.onError,
|
onError: options?.onError,
|
||||||
onSuccess: ({ agent, actions }, variables, context) => {
|
onSuccess: ({ agent, actions }, variables, context) => {
|
||||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
((keys: t.AgentListParams[]) => {
|
||||||
QueryKeys.agents,
|
keys.forEach((key) => {
|
||||||
defaultOrderQuery,
|
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
|
||||||
]);
|
if (listRes) {
|
||||||
|
const currentAgents = [agent, ...listRes.data];
|
||||||
if (listRes) {
|
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
|
||||||
const currentAgents = [agent, ...listRes.data];
|
...listRes,
|
||||||
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
|
data: currentAgents,
|
||||||
...listRes,
|
});
|
||||||
data: currentAgents,
|
}
|
||||||
});
|
});
|
||||||
}
|
})(allAgentViewAndEditQueryKeys);
|
||||||
|
|
||||||
const existingActions = queryClient.getQueryData<t.Action[]>([QueryKeys.actions]) || [];
|
const existingActions = queryClient.getQueryData<t.Action[]>([QueryKeys.actions]) || [];
|
||||||
|
|
||||||
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], existingActions.concat(actions));
|
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], existingActions.concat(actions));
|
||||||
|
invalidateAgentMarketplaceQueries(queryClient);
|
||||||
|
|
||||||
return options?.onSuccess?.({ agent, actions }, variables, context);
|
return options?.onSuccess?.({ agent, actions }, variables, context);
|
||||||
},
|
},
|
||||||
@@ -177,8 +194,7 @@ export const useUploadAgentAvatarMutation = (
|
|||||||
unknown // context
|
unknown // context
|
||||||
> => {
|
> => {
|
||||||
return useMutation([MutationKeys.agentAvatarUpload], {
|
return useMutation([MutationKeys.agentAvatarUpload], {
|
||||||
mutationFn: ({ postCreation, ...variables }: t.AgentAvatarVariables) =>
|
mutationFn: (variables: t.AgentAvatarVariables) => dataService.uploadAgentAvatar(variables),
|
||||||
dataService.uploadAgentAvatar(variables),
|
|
||||||
...(options || {}),
|
...(options || {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -202,26 +218,25 @@ export const useUpdateAgentAction = (
|
|||||||
onMutate: (variables) => options?.onMutate?.(variables),
|
onMutate: (variables) => options?.onMutate?.(variables),
|
||||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||||
onSuccess: (updateAgentActionResponse, 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];
|
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], {
|
if (!listRes) {
|
||||||
...listRes,
|
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
|
||||||
data: listRes.data.map((agent) => {
|
|
||||||
if (agent.id === variables.agent_id) {
|
|
||||||
return updatedAgent;
|
|
||||||
}
|
}
|
||||||
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) => {
|
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@@ -241,6 +256,10 @@ export const useUpdateAgentAction = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
|
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);
|
return options?.onSuccess?.(updateAgentActionResponse, variables, context);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -271,30 +290,29 @@ export const useDeleteAgentAction = (
|
|||||||
return action.action_id !== variables.action_id;
|
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>(
|
return {
|
||||||
[QueryKeys.agents, defaultOrderQuery],
|
...prev,
|
||||||
(prev) => {
|
data: prev.data.map((agent) => {
|
||||||
if (!prev) {
|
if (agent.id === variables.agent_id) {
|
||||||
return prev;
|
return {
|
||||||
}
|
...agent,
|
||||||
|
tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')),
|
||||||
return {
|
};
|
||||||
...prev,
|
}
|
||||||
data: prev.data.map((agent) => {
|
return agent;
|
||||||
if (agent.id === variables.agent_id) {
|
}),
|
||||||
return {
|
};
|
||||||
...agent,
|
});
|
||||||
tools: agent.tools?.filter((tool) => !tool.includes(domain ?? '')),
|
});
|
||||||
};
|
})(allAgentViewAndEditQueryKeys);
|
||||||
}
|
const updaterFn = (prev) => {
|
||||||
return agent;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], (prev) => {
|
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
@@ -303,7 +321,12 @@ export const useDeleteAgentAction = (
|
|||||||
...prev,
|
...prev,
|
||||||
tools: prev.tools?.filter((tool) => !tool.includes(domain ?? '')),
|
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);
|
return options?.onSuccess?.(_data, variables, context);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -329,25 +352,30 @@ export const useRevertAgentVersionMutation = (
|
|||||||
onSuccess: (revertedAgent, variables, context) => {
|
onSuccess: (revertedAgent, variables, context) => {
|
||||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
|
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
|
||||||
|
|
||||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
((keys: t.AgentListParams[]) => {
|
||||||
QueryKeys.agents,
|
keys.forEach((key) => {
|
||||||
defaultOrderQuery,
|
const listRes = queryClient.getQueryData<t.AgentListResponse>([QueryKeys.agents, key]);
|
||||||
]);
|
|
||||||
|
|
||||||
if (listRes) {
|
if (listRes) {
|
||||||
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
|
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, key], {
|
||||||
...listRes,
|
...listRes,
|
||||||
data: listRes.data.map((agent) => {
|
data: listRes.data.map((agent) => {
|
||||||
if (agent.id === variables.agent_id) {
|
if (agent.id === variables.agent_id) {
|
||||||
return revertedAgent;
|
return revertedAgent;
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
}),
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
})(allAgentViewAndEditQueryKeys);
|
||||||
|
|
||||||
return options?.onSuccess?.(revertedAgent, variables, context);
|
return options?.onSuccess?.(revertedAgent, variables, context);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const invalidateAgentMarketplaceQueries = (queryClient: QueryClient) => {
|
||||||
|
queryClient.invalidateQueries([QueryKeys.marketplaceAgents]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
|
import {
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
QueryKeys,
|
||||||
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
|
dataService,
|
||||||
|
Permissions,
|
||||||
|
EModelEndpoint,
|
||||||
|
PERMISSION_BITS,
|
||||||
|
PermissionTypes,
|
||||||
|
} 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';
|
import type t from 'librechat-data-provider';
|
||||||
|
import { useHasAccess } from '~/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine the appropriate permission level for agent queries based on marketplace configuration
|
||||||
|
*/
|
||||||
|
export const useAgentListingDefaultPermissionLevel = () => {
|
||||||
|
const hasMarketplaceAccess = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.MARKETPLACE,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When marketplace is active: EDIT permissions (builder mode)
|
||||||
|
// When marketplace is not active: VIEW permissions (browse mode)
|
||||||
|
return hasMarketplaceAccess ? PERMISSION_BITS.EDIT : PERMISSION_BITS.VIEW;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AGENTS
|
* AGENTS
|
||||||
*/
|
*/
|
||||||
|
export const defaultAgentParams: t.AgentListParams = {
|
||||||
|
limit: 10,
|
||||||
|
requiredPermission: PERMISSION_BITS.EDIT,
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Hook for getting all available tools for A
|
* Hook for getting all available tools for A
|
||||||
*/
|
*/
|
||||||
@@ -27,7 +56,7 @@ export const useAvailableAgentToolsQuery = (): QueryObserverResult<t.TPlugin[]>
|
|||||||
* Hook for listing all Agents, with optional parameters provided for pagination and sorting
|
* Hook for listing all Agents, with optional parameters provided for pagination and sorting
|
||||||
*/
|
*/
|
||||||
export const useListAgentsQuery = <TData = t.AgentListResponse>(
|
export const useListAgentsQuery = <TData = t.AgentListResponse>(
|
||||||
params: t.AgentListParams = defaultOrderQuery,
|
params: t.AgentListParams = defaultAgentParams,
|
||||||
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
|
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
|
||||||
): QueryObserverResult<TData> => {
|
): QueryObserverResult<TData> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -54,7 +83,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 = (
|
export const useGetAgentByIdQuery = (
|
||||||
agent_id: string,
|
agent_id: string,
|
||||||
@@ -75,3 +104,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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export { default as useAgentsMap } from './useAgentsMap';
|
export { default as useAgentsMap } from './useAgentsMap';
|
||||||
export { default as useSelectAgent } from './useSelectAgent';
|
export { default as useSelectAgent } from './useSelectAgent';
|
||||||
|
export { default as useAgentCategories } from './useAgentCategories';
|
||||||
|
export type { ProcessedAgentCategory } from './useAgentCategories';
|
||||||
export { default as useAgentCapabilities } from './useAgentCapabilities';
|
export { default as useAgentCapabilities } from './useAgentCapabilities';
|
||||||
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
||||||
|
|||||||
58
client/src/hooks/Agents/useAgentCategories.tsx
Normal file
58
client/src/hooks/Agents/useAgentCategories.tsx
Normal 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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TAgentsMap } from 'librechat-data-provider';
|
import { TAgentsMap } from 'librechat-data-provider';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useListAgentsQuery } from '~/data-provider';
|
import { useListAgentsQuery, useAgentListingDefaultPermissionLevel } from '~/data-provider';
|
||||||
import { mapAgents } from '~/utils';
|
import { mapAgents } from '~/utils';
|
||||||
|
|
||||||
export default function useAgentsMap({
|
export default function useAgentsMap({
|
||||||
@@ -8,10 +8,15 @@ export default function useAgentsMap({
|
|||||||
}: {
|
}: {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}): TAgentsMap | undefined {
|
}): TAgentsMap | undefined {
|
||||||
const { data: agentsList = null } = useListAgentsQuery(undefined, {
|
const permissionLevel = useAgentListingDefaultPermissionLevel();
|
||||||
select: (res) => mapAgents(res.data),
|
|
||||||
enabled: isAuthenticated,
|
const { data: agentsList = null } = useListAgentsQuery(
|
||||||
});
|
{ requiredPermission: permissionLevel },
|
||||||
|
{
|
||||||
|
select: (res) => mapAgents(res.data),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const agents = useMemo<TAgentsMap | undefined>(() => {
|
const agents = useMemo<TAgentsMap | undefined>(() => {
|
||||||
return agentsList !== null ? agentsList : undefined;
|
return agentsList !== null ? agentsList : undefined;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user