diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index f4395b4b32..87fb053479 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -4,6 +4,7 @@ const { sendEvent, createRun, Tokenizer, + checkAccess, memoryInstructions, createMemoryProcessor, } = require('@librechat/api'); @@ -39,8 +40,8 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { getFormattedMemories, deleteMemory, setMemory } = require('~/models'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { getProviderConfig } = require('~/server/services/Endpoints'); -const { checkAccess } = require('~/server/middleware/roles/access'); const BaseClient = require('~/app/clients/BaseClient'); +const { getRoleByName } = require('~/models/Role'); const { loadAgent } = require('~/models/Agent'); const { getMCPManager } = require('~/config'); @@ -401,7 +402,12 @@ class AgentClient extends BaseClient { if (user.personalization?.memories === false) { return; } - const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]); + const hasAccess = await checkAccess({ + user, + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE], + getRoleByName, + }); if (!hasAccess) { logger.debug( diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js index 254ecb4f94..8d5d2e9ce6 100644 --- a/api/server/controllers/tools.js +++ b/api/server/controllers/tools.js @@ -1,5 +1,7 @@ const { nanoid } = require('nanoid'); const { EnvVar } = require('@librechat/agents'); +const { checkAccess } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { Tools, AuthType, @@ -13,9 +15,8 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadTools } = require('~/app/clients/tools/util'); -const { checkAccess } = require('~/server/middleware'); +const { getRoleByName } = require('~/models/Role'); const { getMessage } = require('~/models/Message'); -const { logger } = require('~/config'); const fieldsMap = { [Tools.execute_code]: [EnvVar.CODE_API_KEY], @@ -79,6 +80,7 @@ const verifyToolAuth = async (req, res) => { throwError: false, }); } catch (error) { + logger.error('Error loading auth values', error); res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED }); return; } @@ -132,7 +134,12 @@ const callTool = async (req, res) => { logger.debug(`[${toolId}/call] User: ${req.user.id}`); let hasAccess = true; if (toolAccessPermType[toolId]) { - hasAccess = await checkAccess(req.user, toolAccessPermType[toolId], [Permissions.USE]); + hasAccess = await checkAccess({ + user: req.user, + permissionType: toolAccessPermType[toolId], + permissions: [Permissions.USE], + getRoleByName, + }); } if (!hasAccess) { logger.warn( diff --git a/api/server/middleware/roles/access.js b/api/server/middleware/roles/access.js deleted file mode 100644 index cabbd405b0..0000000000 --- a/api/server/middleware/roles/access.js +++ /dev/null @@ -1,78 +0,0 @@ -const { getRoleByName } = require('~/models/Role'); -const { logger } = require('~/config'); - -/** - * Core function to check if a user has one or more required permissions - * - * @param {object} user - The user object - * @param {PermissionTypes} permissionType - The type of permission to check - * @param {Permissions[]} permissions - The list of specific permissions to check - * @param {Record} [bodyProps] - An optional object where keys are permissions and values are arrays of properties to check - * @param {object} [checkObject] - The object to check properties against - * @returns {Promise} Whether the user has the required permissions - */ -const checkAccess = async (user, permissionType, permissions, bodyProps = {}, checkObject = {}) => { - if (!user) { - return false; - } - - const role = await getRoleByName(user.role); - if (role && role.permissions && role.permissions[permissionType]) { - const hasAnyPermission = permissions.some((permission) => { - if (role.permissions[permissionType][permission]) { - return true; - } - - if (bodyProps[permission] && checkObject) { - return bodyProps[permission].some((prop) => - Object.prototype.hasOwnProperty.call(checkObject, prop), - ); - } - - return false; - }); - - return hasAnyPermission; - } - - return false; -}; - -/** - * Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties. - * - * @param {PermissionTypes} permissionType - The type of permission to check. - * @param {Permissions[]} permissions - The list of specific permissions to check. - * @param {Record} [bodyProps] - An optional object where keys are permissions and values are arrays of `req.body` properties to check. - * @returns {(req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise} Express middleware function. - */ -const generateCheckAccess = (permissionType, permissions, bodyProps = {}) => { - return async (req, res, next) => { - try { - const hasAccess = await checkAccess( - req.user, - permissionType, - permissions, - bodyProps, - req.body, - ); - - if (hasAccess) { - return next(); - } - - logger.warn( - `[${permissionType}] Forbidden: Insufficient permissions for User ${req.user.id}: ${permissions.join(', ')}`, - ); - return res.status(403).json({ message: 'Forbidden: Insufficient permissions' }); - } catch (error) { - logger.error(error); - return res.status(500).json({ message: `Server error: ${error.message}` }); - } - }; -}; - -module.exports = { - checkAccess, - generateCheckAccess, -}; diff --git a/api/server/middleware/roles/index.js b/api/server/middleware/roles/index.js index ebc0043f2f..f01b884e5a 100644 --- a/api/server/middleware/roles/index.js +++ b/api/server/middleware/roles/index.js @@ -1,8 +1,5 @@ const checkAdmin = require('./admin'); -const { checkAccess, generateCheckAccess } = require('./access'); module.exports = { checkAdmin, - checkAccess, - generateCheckAccess, }; diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 89d6a9dc42..2f11486a0e 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,14 +1,28 @@ const express = require('express'); const { nanoid } = require('nanoid'); -const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); +const { generateCheckAccess } = require('@librechat/api'); +const { + SystemRoles, + Permissions, + PermissionTypes, + actionDelimiter, + removeNullishValues, +} = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { isActionDomainAllowed } = require('~/server/services/domains'); const { getAgent, updateAgent } = require('~/models/Agent'); -const { logger } = require('~/config'); +const { getRoleByName } = require('~/models/Role'); const router = express.Router(); +const checkAgentCreate = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); + // If the user has ADMIN role // then action edition is possible even if not owner of the assistant const isAdmin = (req) => { @@ -41,7 +55,7 @@ router.get('/', async (req, res) => { * @param {ActionMetadata} req.body.metadata - Metadata for the action. * @returns {Object} 200 - success response - application/json */ -router.post('/:agent_id', async (req, res) => { +router.post('/:agent_id', checkAgentCreate, async (req, res) => { try { const { agent_id } = req.params; @@ -149,7 +163,7 @@ router.post('/:agent_id', async (req, res) => { * @param {string} req.params.action_id - The ID of the action to delete. * @returns {Object} 200 - success response - application/json */ -router.delete('/:agent_id/:action_id', async (req, res) => { +router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => { try { const { agent_id, action_id } = req.params; const admin = isAdmin(req); diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index ef66ef7896..0e07c83bd1 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -1,22 +1,28 @@ const express = require('express'); +const { generateCheckAccess, skipAgentCheck } = require('@librechat/api'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { setHeaders, moderateText, // validateModel, - generateCheckAccess, validateConvoAccess, buildEndpointOption, } = require('~/server/middleware'); const { initializeClient } = require('~/server/services/Endpoints/agents'); const AgentController = require('~/server/controllers/agents/request'); const addTitle = require('~/server/services/Endpoints/agents/title'); +const { getRoleByName } = require('~/models/Role'); const router = express.Router(); router.use(moderateText); -const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); +const checkAgentAccess = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + skipCheck: skipAgentCheck, + getRoleByName, +}); router.use(checkAgentAccess); router.use(validateConvoAccess); diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 657aa79414..0455b23948 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -1,29 +1,36 @@ const express = require('express'); +const { generateCheckAccess } = require('@librechat/api'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); -const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); +const { requireJwtAuth } = require('~/server/middleware'); const v1 = require('~/server/controllers/agents/v1'); +const { getRoleByName } = require('~/models/Role'); const actions = require('./actions'); const tools = require('./tools'); const router = express.Router(); const avatar = express.Router(); -const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); -const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [ - Permissions.USE, - Permissions.CREATE, -]); +const checkAgentAccess = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, +}); +const checkAgentCreate = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); -const checkGlobalAgentShare = generateCheckAccess( - PermissionTypes.AGENTS, - [Permissions.USE, Permissions.CREATE], - { +const checkGlobalAgentShare = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE, Permissions.CREATE], + bodyProps: { [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], }, -); + getRoleByName, +}); router.use(requireJwtAuth); -router.use(checkAgentAccess); /** * Agent actions route. diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js index 86065fecaa..fe520de000 100644 --- a/api/server/routes/memories.js +++ b/api/server/routes/memories.js @@ -1,37 +1,43 @@ const express = require('express'); -const { Tokenizer } = require('@librechat/api'); +const { Tokenizer, generateCheckAccess } = require('@librechat/api'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { getAllUserMemories, toggleUserMemories, createMemory, - setMemory, deleteMemory, + setMemory, } = require('~/models'); -const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); +const { requireJwtAuth } = require('~/server/middleware'); +const { getRoleByName } = require('~/models/Role'); const router = express.Router(); -const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [ - Permissions.USE, - Permissions.READ, -]); -const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [ - Permissions.USE, - Permissions.CREATE, -]); -const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [ - Permissions.USE, - Permissions.UPDATE, -]); -const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [ - Permissions.USE, - Permissions.UPDATE, -]); -const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [ - Permissions.USE, - Permissions.OPT_OUT, -]); +const checkMemoryRead = generateCheckAccess({ + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE, Permissions.READ], + getRoleByName, +}); +const checkMemoryCreate = generateCheckAccess({ + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); +const checkMemoryUpdate = generateCheckAccess({ + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE, Permissions.UPDATE], + getRoleByName, +}); +const checkMemoryDelete = generateCheckAccess({ + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE, Permissions.UPDATE], + getRoleByName, +}); +const checkMemoryOptOut = generateCheckAccess({ + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE, Permissions.OPT_OUT], + getRoleByName, +}); router.use(requireJwtAuth); diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index e3ab5bf5d3..c18418cba5 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -1,5 +1,7 @@ const express = require('express'); -const { PermissionTypes, Permissions, SystemRoles } = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); +const { generateCheckAccess } = require('@librechat/api'); +const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider'); const { getPrompt, getPrompts, @@ -14,24 +16,30 @@ const { // updatePromptLabels, makePromptProduction, } = require('~/models/Prompt'); -const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); -const { logger } = require('~/config'); +const { requireJwtAuth } = require('~/server/middleware'); +const { getRoleByName } = require('~/models/Role'); const router = express.Router(); -const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]); -const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [ - Permissions.USE, - Permissions.CREATE, -]); +const checkPromptAccess = generateCheckAccess({ + permissionType: PermissionTypes.PROMPTS, + permissions: [Permissions.USE], + getRoleByName, +}); +const checkPromptCreate = generateCheckAccess({ + permissionType: PermissionTypes.PROMPTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); -const checkGlobalPromptShare = generateCheckAccess( - PermissionTypes.PROMPTS, - [Permissions.USE, Permissions.CREATE], - { +const checkGlobalPromptShare = generateCheckAccess({ + permissionType: PermissionTypes.PROMPTS, + permissions: [Permissions.USE, Permissions.CREATE], + bodyProps: { [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], }, -); + getRoleByName, +}); router.use(requireJwtAuth); router.use(checkPromptAccess); diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index d3e27d3711..0a4ee5084c 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -1,18 +1,24 @@ const express = require('express'); +const { logger } = require('@librechat/data-schemas'); +const { generateCheckAccess } = require('@librechat/api'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { - getConversationTags, + updateTagsForConversation, updateConversationTag, createConversationTag, deleteConversationTag, - updateTagsForConversation, + getConversationTags, } = require('~/models/ConversationTag'); -const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); -const { logger } = require('~/config'); +const { requireJwtAuth } = require('~/server/middleware'); +const { getRoleByName } = require('~/models/Role'); const router = express.Router(); -const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]); +const checkBookmarkAccess = generateCheckAccess({ + permissionType: PermissionTypes.BOOKMARKS, + permissions: [Permissions.USE], + getRoleByName, +}); router.use(requireJwtAuth); router.use(checkBookmarkAccess); diff --git a/client/src/Providers/ActivePanelContext.tsx b/client/src/Providers/ActivePanelContext.tsx new file mode 100644 index 0000000000..4a8d6ccfc4 --- /dev/null +++ b/client/src/Providers/ActivePanelContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useState, ReactNode } from 'react'; + +interface ActivePanelContextType { + active: string | undefined; + setActive: (id: string) => void; +} + +const ActivePanelContext = createContext(undefined); + +export function ActivePanelProvider({ + children, + defaultActive, +}: { + children: ReactNode; + defaultActive?: string; +}) { + const [active, _setActive] = useState(defaultActive); + + const setActive = (id: string) => { + localStorage.setItem('side:active-panel', id); + _setActive(id); + }; + + return ( + + {children} + + ); +} + +export function useActivePanel() { + const context = useContext(ActivePanelContext); + if (context === undefined) { + throw new Error('useActivePanel must be used within an ActivePanelProvider'); + } + return context; +} diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 2cc64ba3ed..b15d334078 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -40,41 +40,40 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) agent_id: agent_id || '', })) || []; - const groupedTools = - tools?.reduce( - (acc, tool) => { - if (tool.tool_id.includes(Constants.mcp_delimiter)) { - const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); - const groupKey = `${serverName.toLowerCase()}`; - if (!acc[groupKey]) { - acc[groupKey] = { - tool_id: groupKey, - metadata: { - name: `${serverName}`, - pluginKey: groupKey, - description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, - icon: tool.metadata.icon || '', - } as TPlugin, - agent_id: agent_id || '', - tools: [], - }; - } - acc[groupKey].tools?.push({ - tool_id: tool.tool_id, - metadata: tool.metadata, - agent_id: agent_id || '', - }); - } else { - acc[tool.tool_id] = { - tool_id: tool.tool_id, - metadata: tool.metadata, + const groupedTools = tools?.reduce( + (acc, tool) => { + if (tool.tool_id.includes(Constants.mcp_delimiter)) { + const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); + const groupKey = `${serverName.toLowerCase()}`; + if (!acc[groupKey]) { + acc[groupKey] = { + tool_id: groupKey, + metadata: { + name: `${serverName}`, + pluginKey: groupKey, + description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, + icon: tool.metadata.icon || '', + } as TPlugin, agent_id: agent_id || '', + tools: [], }; } - return acc; - }, - {} as Record, - ) || {}; + acc[groupKey].tools?.push({ + tool_id: tool.tool_id, + metadata: tool.metadata, + agent_id: agent_id || '', + }); + } else { + acc[tool.tool_id] = { + tool_id: tool.tool_id, + metadata: tool.metadata, + agent_id: agent_id || '', + }; + } + return acc; + }, + {} as Record, + ); const value = { action, diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 8809532b49..b455cb3f1e 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -1,6 +1,7 @@ export { default as AssistantsProvider } from './AssistantsContext'; export { default as AgentsProvider } from './AgentsContext'; export { default as ToastProvider } from './ToastContext'; +export * from './ActivePanelContext'; export * from './AgentPanelContext'; export * from './ChatContext'; export * from './ShareContext'; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a29af7f143..bcc995740c 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -229,11 +229,11 @@ export type AgentPanelContextType = { mcps?: t.MCP[]; setMcp: React.Dispatch>; setMcps: React.Dispatch>; - groupedTools: Record; tools: t.AgentToolType[]; activePanel?: string; setActivePanel: React.Dispatch>; setCurrentAgentId: React.Dispatch>; + groupedTools?: Record; agent_id?: string; }; diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index 746c3d9c17..d49230ff89 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -4,9 +4,9 @@ import { supportsFiles, mergeFileConfig, isAgentsEndpoint, - EndpointFileConfig, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; +import type { EndpointFileConfig } from 'librechat-data-provider'; import { useGetFileConfig } from '~/data-provider'; import AttachFileMenu from './AttachFileMenu'; import { useChatContext } from '~/Providers'; @@ -14,22 +14,25 @@ import { useChatContext } from '~/Providers'; function AttachFileChat({ disableInputs }: { disableInputs: boolean }) { const { conversation } = useChatContext(); const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO; - const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; - const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]); + const { endpoint, endpointType } = conversation ?? { endpoint: null }; + const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]); const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); - const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as - | EndpointFileConfig - | undefined; - - const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false; + const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined; + const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false; const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) { - return ; + return ( + + ); } return null; diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 2bffa4f50c..c038f30114 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -2,6 +2,7 @@ import { useSetRecoilState } from 'recoil'; import * as Ariakit from '@ariakit/react'; import React, { useRef, useState, useMemo } from 'react'; import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; +import type { EndpointFileConfig } from 'librechat-data-provider'; import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components'; import { EToolResources, EModelEndpoint } from 'librechat-data-provider'; import { useGetEndpointsQuery } from '~/data-provider'; @@ -12,9 +13,10 @@ import { cn } from '~/utils'; interface AttachFileMenuProps { conversationId: string; disabled?: boolean | null; + endpointFileConfig?: EndpointFileConfig; } -const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => { +const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => { const localize = useLocalize(); const isUploadDisabled = disabled ?? false; const inputRef = useRef(null); @@ -24,6 +26,7 @@ const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => { const { data: endpointsConfig } = useGetEndpointsQuery(); const { handleFileChange } = useFileHandling({ overrideEndpoint: EModelEndpoint.agents, + overrideEndpointFileConfig: endpointFileConfig, }); /** TODO: Ephemeral Agent Capabilities diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index cc653d7d7c..b8630225ef 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -169,7 +169,7 @@ export default function AgentConfig({ const visibleToolIds = new Set(selectedToolIds); // Check what group parent tools should be shown if any subtool is present - Object.entries(allTools).forEach(([toolId, toolObj]) => { + Object.entries(allTools ?? {}).forEach(([toolId, toolObj]) => { if (toolObj.tools?.length) { // if any subtool of this group is selected, ensure group parent tool rendered if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) { @@ -300,6 +300,7 @@ export default function AgentConfig({
{/* // Render all visible IDs (including groups with subtools selected) */} {[...visibleToolIds].map((toolId, i) => { + if (!allTools) return null; const tool = allTools[toolId]; if (!tool) return null; return ( diff --git a/client/src/components/SidePanel/Agents/AgentTool.tsx b/client/src/components/SidePanel/Agents/AgentTool.tsx index 4876f447fb..6ea613dc78 100644 --- a/client/src/components/SidePanel/Agents/AgentTool.tsx +++ b/client/src/components/SidePanel/Agents/AgentTool.tsx @@ -19,7 +19,7 @@ export default function AgentTool({ allTools, }: { tool: string; - allTools: Record; + allTools?: Record; agent_id?: string; }) { const [isHovering, setIsHovering] = useState(false); @@ -30,8 +30,10 @@ export default function AgentTool({ const { showToast } = useToastContext(); const updateUserPlugins = useUpdateUserPluginsMutation(); const { getValues, setValue } = useFormContext(); + if (!allTools) { + return null; + } const currentTool = allTools[tool]; - const getSelectedTools = () => { if (!currentTool?.tools) return []; const formTools = getValues('tools') || []; diff --git a/client/src/components/SidePanel/Builder/AssistantPanel.tsx b/client/src/components/SidePanel/Builder/AssistantPanel.tsx index c78d456ff1..4c3a794823 100644 --- a/client/src/components/SidePanel/Builder/AssistantPanel.tsx +++ b/client/src/components/SidePanel/Builder/AssistantPanel.tsx @@ -17,9 +17,9 @@ import { } from '~/data-provider'; import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils'; import AssistantConversationStarters from './AssistantConversationStarters'; +import AssistantToolsDialog from '~/components/Tools/AssistantToolsDialog'; import { useAssistantsMapContext, useToastContext } from '~/Providers'; import { useSelectAssistant, useLocalize } from '~/hooks'; -import { ToolSelectDialog } from '~/components/Tools'; import AppendDateCheckbox from './AppendDateCheckbox'; import CapabilitiesForm from './CapabilitiesForm'; import { SelectDropDown } from '~/components/ui'; @@ -468,11 +468,10 @@ export default function AssistantPanel({
- diff --git a/client/src/components/SidePanel/Nav.tsx b/client/src/components/SidePanel/Nav.tsx index d901d6b47a..fa6d8751b1 100644 --- a/client/src/components/SidePanel/Nav.tsx +++ b/client/src/components/SidePanel/Nav.tsx @@ -1,21 +1,15 @@ -import { useState } from 'react'; import * as AccordionPrimitive from '@radix-ui/react-accordion'; import type { NavLink, NavProps } from '~/common'; -import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion'; -import { TooltipAnchor, Button } from '~/components'; +import { AccordionContent, AccordionItem, TooltipAnchor, Accordion, Button } from '~/components/ui'; +import { ActivePanelProvider, useActivePanel } from '~/Providers'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) { +function NavContent({ links, isCollapsed, resize }: Omit) { const localize = useLocalize(); - const [active, _setActive] = useState(defaultActive); + const { active, setActive } = useActivePanel(); const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost'); - const setActive = (id: string) => { - localStorage.setItem('side:active-panel', id + ''); - _setActive(id); - }; - return (
); } + +export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) { + return ( + + + + ); +} diff --git a/client/src/components/Tools/AssistantToolsDialog.tsx b/client/src/components/Tools/AssistantToolsDialog.tsx new file mode 100644 index 0000000000..ce013af135 --- /dev/null +++ b/client/src/components/Tools/AssistantToolsDialog.tsx @@ -0,0 +1,254 @@ +import { useEffect } from 'react'; +import { Search, X } from 'lucide-react'; +import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react'; +import { useFormContext } from 'react-hook-form'; +import { isAgentsEndpoint } from 'librechat-data-provider'; +import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; +import type { + AssistantsEndpoint, + EModelEndpoint, + TPluginAction, + TError, +} from 'librechat-data-provider'; +import type { TPluginStoreDialogProps } from '~/common/types'; +import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store'; +import { useLocalize, usePluginDialogHelpers } from '~/hooks'; +import { useAvailableToolsQuery } from '~/data-provider'; +import ToolItem from './ToolItem'; + +function AssistantToolsDialog({ + isOpen, + endpoint, + setIsOpen, +}: TPluginStoreDialogProps & { + endpoint: AssistantsEndpoint | EModelEndpoint.agents; +}) { + const localize = useLocalize(); + const { getValues, setValue } = useFormContext(); + const { data: tools } = useAvailableToolsQuery(endpoint); + const isAgentTools = isAgentsEndpoint(endpoint); + + const { + maxPage, + setMaxPage, + currentPage, + setCurrentPage, + itemsPerPage, + searchChanged, + setSearchChanged, + searchValue, + setSearchValue, + gridRef, + handleSearch, + handleChangePage, + error, + setError, + errorMessage, + setErrorMessage, + showPluginAuthForm, + setShowPluginAuthForm, + selectedPlugin, + setSelectedPlugin, + } = usePluginDialogHelpers(); + + const updateUserPlugins = useUpdateUserPluginsMutation(); + const handleInstallError = (error: TError) => { + setError(true); + const errorMessage = error.response?.data?.message ?? ''; + if (errorMessage) { + setErrorMessage(errorMessage); + } + setTimeout(() => { + setError(false); + setErrorMessage(''); + }, 5000); + }; + + const handleInstall = (pluginAction: TPluginAction) => { + const addFunction = () => { + const fns = getValues('functions').slice(); + fns.push(pluginAction.pluginKey); + setValue('functions', fns); + }; + + if (!pluginAction.auth) { + return addFunction(); + } + + updateUserPlugins.mutate(pluginAction, { + onError: (error: unknown) => { + handleInstallError(error as TError); + }, + onSuccess: addFunction, + }); + + setShowPluginAuthForm(false); + }; + + const onRemoveTool = (tool: string) => { + setShowPluginAuthForm(false); + updateUserPlugins.mutate( + { pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true }, + { + onError: (error: unknown) => { + handleInstallError(error as TError); + }, + onSuccess: () => { + const fns = getValues('functions').filter((fn: string) => fn !== tool); + setValue('functions', fns); + }, + }, + ); + }; + + const onAddTool = (pluginKey: string) => { + setShowPluginAuthForm(false); + const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey); + setSelectedPlugin(getAvailablePluginFromKey); + + const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {}; + + if (authConfig && authConfig.length > 0 && !authenticated) { + setShowPluginAuthForm(true); + } else { + handleInstall({ pluginKey, action: 'install', auth: null }); + } + }; + + const filteredTools = tools?.filter((tool) => + tool.name.toLowerCase().includes(searchValue.toLowerCase()), + ); + + useEffect(() => { + if (filteredTools) { + setMaxPage(Math.ceil(filteredTools.length / itemsPerPage)); + if (searchChanged) { + setCurrentPage(1); + setSearchChanged(false); + } + } + }, [ + tools, + itemsPerPage, + searchValue, + filteredTools, + searchChanged, + setMaxPage, + setCurrentPage, + setSearchChanged, + ]); + + return ( + { + setIsOpen(false); + setCurrentPage(1); + setSearchValue(''); + }} + className="relative z-[102]" + > + {/* The backdrop, rendered as a fixed sibling to the panel container */} +
+ {/* Full-screen container to center the panel */} +
+ +
+
+
+ + {isAgentTools + ? localize('com_nav_tool_dialog_agents') + : localize('com_nav_tool_dialog')} + + + {localize('com_nav_tool_dialog_description')} + +
+
+
+
+ +
+
+
+ {error && ( +
+ {localize('com_nav_plugin_auth_error')} {errorMessage} +
+ )} + {showPluginAuthForm && ( +
+ handleInstall(installActionData)} + isEntityTool={true} + /> +
+ )} +
+
+
+ + +
+
+ {filteredTools && + filteredTools + .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) + .map((tool, index) => ( + onAddTool(tool.pluginKey)} + onRemoveTool={() => onRemoveTool(tool.pluginKey)} + /> + ))} +
+
+
+ {maxPage > 0 ? ( + + ) : ( +
+ )} +
+
+
+
+
+ ); +} + +export default AssistantToolsDialog; diff --git a/client/src/components/Tools/ToolItem.tsx b/client/src/components/Tools/ToolItem.tsx index 0b16b0ba42..501c08848a 100644 --- a/client/src/components/Tools/ToolItem.tsx +++ b/client/src/components/Tools/ToolItem.tsx @@ -1,9 +1,9 @@ import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react'; -import { AgentToolType } from 'librechat-data-provider'; +import type { TPlugin, AgentToolType } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; type ToolItemProps = { - tool: AgentToolType; + tool: TPlugin | AgentToolType; onAddTool: () => void; onRemoveTool: () => void; isInstalled?: boolean; @@ -19,9 +19,13 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt } }; - const name = tool.metadata?.name || tool.tool_id; - const description = tool.metadata?.description || ''; - const icon = tool.metadata?.icon; + const name = + (tool as AgentToolType).metadata?.name || + (tool as AgentToolType).tool_id || + (tool as TPlugin).name; + const description = + (tool as AgentToolType).metadata?.description || (tool as TPlugin).description || ''; + const icon = (tool as AgentToolType).metadata?.icon || (tool as TPlugin).icon; return (
diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index cf8c958921..0d380fefbb 100644 --- a/client/src/components/Tools/ToolSelectDialog.tsx +++ b/client/src/components/Tools/ToolSelectDialog.tsx @@ -67,15 +67,14 @@ function ToolSelectDialog({ }, 5000); }; - const toolsFormKey = 'tools'; const handleInstall = (pluginAction: TPluginAction) => { const addFunction = () => { - const installedToolIds: string[] = getValues(toolsFormKey) || []; + const installedToolIds: string[] = getValues('tools') || []; // Add the parent installedToolIds.push(pluginAction.pluginKey); // If this tool is a group, add subtools too - const groupObj = groupedTools[pluginAction.pluginKey]; + const groupObj = groupedTools?.[pluginAction.pluginKey]; if (groupObj?.tools && groupObj.tools.length > 0) { for (const sub of groupObj.tools) { if (!installedToolIds.includes(sub.tool_id)) { @@ -83,7 +82,7 @@ function ToolSelectDialog({ } } } - setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case + setValue('tools', Array.from(new Set(installedToolIds))); // no duplicates just in case }; if (!pluginAction.auth) { @@ -101,7 +100,7 @@ function ToolSelectDialog({ }; const onRemoveTool = (toolId: string) => { - const groupObj = groupedTools[toolId]; + const groupObj = groupedTools?.[toolId]; const toolIdsToRemove = [toolId]; if (groupObj?.tools && groupObj.tools.length > 0) { toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id)); @@ -113,8 +112,8 @@ function ToolSelectDialog({ onError: (error: unknown) => handleInstallError(error as TError), onSuccess: () => { const remainingToolIds = - getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || []; - setValue(toolsFormKey, remainingToolIds); + getValues('tools')?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || []; + setValue('tools', remainingToolIds); }, }, ); @@ -268,7 +267,7 @@ function ToolSelectDialog({ onAddTool(tool.tool_id)} onRemoveTool={() => onRemoveTool(tool.tool_id)} /> diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 31443c900f..4f989484de 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -1,3 +1,4 @@ +export * from './Accordion'; export * from './AnimatedTabs'; export * from './AlertDialog'; export * from './Breadcrumb'; diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 7f74a02733..cd1c5834c8 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -1,33 +1,34 @@ -import { useQueryClient } from '@tanstack/react-query'; -import type { TEndpointsConfig, TError } from 'librechat-data-provider'; -import { - defaultAssistantsVersion, - fileConfig as defaultFileConfig, - EModelEndpoint, - isAgentsEndpoint, - isAssistantsEndpoint, - mergeFileConfig, - QueryKeys, -} from 'librechat-data-provider'; -import debounce from 'lodash/debounce'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { v4 } from 'uuid'; +import { useQueryClient } from '@tanstack/react-query'; +import { + QueryKeys, + EModelEndpoint, + mergeFileConfig, + isAgentsEndpoint, + isAssistantsEndpoint, + defaultAssistantsVersion, + fileConfig as defaultFileConfig, +} from 'librechat-data-provider'; +import debounce from 'lodash/debounce'; +import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; -import { useChatContext } from '~/Providers/ChatContext'; +import { useDelayedUploadToast } from './useDelayedUploadToast'; +import { processFileForUpload } from '~/utils/heicConverter'; import { useToastContext } from '~/Providers/ToastContext'; +import { useChatContext } from '~/Providers/ChatContext'; import { logger, validateFiles } from '~/utils'; import useClientResize from './useClientResize'; -import { processFileForUpload } from '~/utils/heicConverter'; -import { useDelayedUploadToast } from './useDelayedUploadToast'; import useUpdateFiles from './useUpdateFiles'; type UseFileHandling = { - overrideEndpoint?: EModelEndpoint; fileSetter?: FileSetter; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; + overrideEndpoint?: EModelEndpoint; + overrideEndpointFileConfig?: EndpointFileConfig; }; const useFileHandling = (params?: UseFileHandling) => { @@ -246,8 +247,9 @@ const useFileHandling = (params?: UseFileHandling) => { fileList, setError, endpointFileConfig: - fileConfig?.endpoints[endpoint] ?? - fileConfig?.endpoints.default ?? + params?.overrideEndpointFileConfig ?? + fileConfig?.endpoints?.[endpoint] ?? + fileConfig?.endpoints?.default ?? defaultFileConfig.endpoints[endpoint] ?? defaultFileConfig.endpoints.default, }); diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 306cb7f90e..a4dd90a665 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -77,7 +77,7 @@ export default function useSideNavLinks({ title: 'com_sidepanel_assistant_builder', label: '', icon: Blocks, - id: 'assistants', + id: EModelEndpoint.assistants, Component: PanelSwitch, }); } @@ -92,7 +92,7 @@ export default function useSideNavLinks({ title: 'com_sidepanel_agent_builder', label: '', icon: Blocks, - id: 'agents', + id: EModelEndpoint.agents, Component: AgentPanelSwitch, }); } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b5266343d0..4860200b16 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -12,6 +12,8 @@ export * from './oauth'; export * from './crypto'; /* Flow */ export * from './flow/manager'; +/* Middleware */ +export * from './middleware'; /* Agents */ export * from './agents'; /* Endpoints */ diff --git a/packages/api/src/middleware/access.ts b/packages/api/src/middleware/access.ts new file mode 100644 index 0000000000..d88ade1e56 --- /dev/null +++ b/packages/api/src/middleware/access.ts @@ -0,0 +1,141 @@ +import { logger } from '@librechat/data-schemas'; +import { + Permissions, + EndpointURLs, + EModelEndpoint, + PermissionTypes, + isAgentsEndpoint, +} from 'librechat-data-provider'; +import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express'; +import type { IRole, IUser } from '@librechat/data-schemas'; + +export function skipAgentCheck(req?: ServerRequest): boolean { + if (!req || !req?.body?.endpoint) { + return false; + } + + if (req.method !== 'POST') { + return false; + } + + if (!req.originalUrl?.includes(EndpointURLs[EModelEndpoint.agents])) { + return false; + } + return !isAgentsEndpoint(req.body.endpoint); +} + +/** + * Core function to check if a user has one or more required permissions + * @param user - The user object + * @param permissionType - The type of permission to check + * @param permissions - The list of specific permissions to check + * @param bodyProps - An optional object where keys are permissions and values are arrays of properties to check + * @param checkObject - The object to check properties against + * @param skipCheck - An optional function that takes the checkObject and returns true to skip permission checking + * @returns Whether the user has the required permissions + */ +export const checkAccess = async ({ + req, + user, + permissionType, + permissions, + getRoleByName, + bodyProps = {} as Record, + checkObject = {}, + skipCheck, +}: { + user: IUser; + req?: ServerRequest; + permissionType: PermissionTypes; + permissions: Permissions[]; + bodyProps?: Record; + checkObject?: object; + /** If skipCheck function is provided and returns true, skip permission checking */ + skipCheck?: (req?: ServerRequest) => boolean; + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; +}): Promise => { + if (skipCheck && skipCheck(req)) { + return true; + } + + if (!user || !user.role) { + return false; + } + + const role = await getRoleByName(user.role); + if (role && role.permissions && role.permissions[permissionType]) { + const hasAnyPermission = permissions.some((permission) => { + if ( + role.permissions?.[permissionType as keyof typeof role.permissions]?.[ + permission as keyof (typeof role.permissions)[typeof permissionType] + ] + ) { + return true; + } + + if (bodyProps[permission] && checkObject) { + return bodyProps[permission].some((prop) => + Object.prototype.hasOwnProperty.call(checkObject, prop), + ); + } + + return false; + }); + + return hasAnyPermission; + } + + return false; +}; + +/** + * Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties. + * @param permissionType - The type of permission to check. + * @param permissions - The list of specific permissions to check. + * @param bodyProps - An optional object where keys are permissions and values are arrays of `req.body` properties to check. + * @param skipCheck - An optional function that takes req.body and returns true to skip permission checking. + * @param getRoleByName - A function to get the role by name. + * @returns Express middleware function. + */ +export const generateCheckAccess = ({ + permissionType, + permissions, + bodyProps = {} as Record, + skipCheck, + getRoleByName, +}: { + permissionType: PermissionTypes; + permissions: Permissions[]; + bodyProps?: Record; + skipCheck?: (req?: ServerRequest) => boolean; + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; +}): ((req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise) => { + return async (req, res, next) => { + try { + const hasAccess = await checkAccess({ + req, + user: req.user as IUser, + permissionType, + permissions, + bodyProps, + checkObject: req.body, + skipCheck, + getRoleByName, + }); + + if (hasAccess) { + return next(); + } + + logger.warn( + `[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${req.user?.id}: ${permissions.join(', ')}`, + ); + return res.status(403).json({ message: 'Forbidden: Insufficient permissions' }); + } catch (error) { + logger.error(error); + return res.status(500).json({ + message: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } + }; +}; diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts new file mode 100644 index 0000000000..176e8bc9ac --- /dev/null +++ b/packages/api/src/middleware/index.ts @@ -0,0 +1 @@ +export * from './access'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 004ed572ca..4d154f4958 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -949,11 +949,11 @@ export const initialModelsConfig: TModelsConfig = { [EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock], }; -export const EndpointURLs: Record = { +export const EndpointURLs = { [EModelEndpoint.assistants]: '/api/assistants/v2/chat', [EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat', [EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`, -}; +} as const; export const modularEndpoints = new Set([ EModelEndpoint.gptPlugins, diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 469c378aba..877d6f31ae 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -134,7 +134,7 @@ export type EventSubmission = Omit & { initialRe export type TPluginAction = { pluginKey: string; action: 'install' | 'uninstall'; - auth?: Partial>; + auth?: Partial> | null; isEntityTool?: boolean; }; @@ -144,7 +144,7 @@ export type TUpdateUserPlugins = { isEntityTool?: boolean; pluginKey: string; action: string; - auth?: Partial>; + auth?: Partial> | null; }; // TODO `label` needs to be changed to the proper `TranslationKeys`