From d6b9e7c8fb76123e2e881e1113b8241e115a8406 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Mon, 1 Dec 2025 20:50:15 +0200 Subject: [PATCH 001/167] feat(core): Make chat hub workflows treat activeWorkflowId correctly (#22546) --- .../src/modules/chat-hub/chat-hub.service.ts | 179 ++++++++++-------- 1 file changed, 99 insertions(+), 80 deletions(-) diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 13a825d8299..77057cd1b47 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -17,15 +17,14 @@ import { ChatHubCustomAgentModel, emptyChatModelsResponse, type ChatHubUpdateConversationRequest, + ChatModelDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { ExecutionRepository, IExecutionResponse, User, WorkflowRepository } from '@n8n/db'; +import { ExecutionRepository, IExecutionResponse, User, WorkflowRepository, In } from '@n8n/db'; import { Service } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; import type { Response } from 'express'; - import { ErrorReporter } from 'n8n-core'; - import { CHAT_TRIGGER_NODE_TYPE, OperationalError, @@ -35,6 +34,7 @@ import { type IWorkflowExecuteAdditionalData, type IRun, jsonParse, + jsonStringify, StructuredChunk, RESPOND_TO_CHAT_NODE_TYPE, IRunExecutionData, @@ -45,9 +45,21 @@ import { WorkflowExecuteMode, } from 'n8n-workflow'; +import { ActiveExecutions } from '@/active-executions'; +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ExecutionService } from '@/executions/execution.service'; +import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; +import { getBase } from '@/workflow-execute-additional-data'; +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import { WorkflowService } from '@/workflows/workflow.service'; + import { ChatHubAgentService } from './chat-hub-agent.service'; import { ChatHubCredentialsService } from './chat-hub-credentials.service'; import type { ChatHubMessage } from './chat-hub-message.entity'; +import type { ChatHubSession } from './chat-hub-session.entity'; import { ChatHubWorkflowService } from './chat-hub-workflow.service'; import { ChatHubAttachmentService } from './chat-hub.attachment.service'; import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; @@ -62,18 +74,6 @@ import { ChatHubMessageRepository } from './chat-message.repository'; import { ChatHubSessionRepository } from './chat-session.repository'; import { interceptResponseWrites, createStructuredChunkAggregator } from './stream-capturer'; -import { ActiveExecutions } from '@/active-executions'; -import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ExecutionService } from '@/executions/execution.service'; -import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; -import { getBase } from '@/workflow-execute-additional-data'; -import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; -import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import { WorkflowService } from '@/workflows/workflow.service'; -import type { ChatHubSession } from './chat-hub-session.entity'; - @Service() export class ChatHubService { constructor( @@ -894,46 +894,59 @@ export class ChatHubService { } private async fetchAgentWorkflowsAsModels(user: User): Promise { - const nodeTypes = [CHAT_TRIGGER_NODE_TYPE]; - const workflows = await this.workflowService.getWorkflowsWithNodesIncluded( + // Workflows are scanned by their latest version for chat trigger nodes. + // This means that we might miss some active workflow versions that had chat triggers but + // the latest version does not, but this trade-off is done for performance. + const workflowsWithChatTrigger = await this.workflowService.getWorkflowsWithNodesIncluded( user, - nodeTypes, + [CHAT_TRIGGER_NODE_TYPE], true, ); + const activeWorkflows = workflowsWithChatTrigger + // Ensure the user has at least read access to the workflows + .filter((workflow) => workflow.scopes.includes('workflow:read')) + .filter((workflow) => !!workflow.activeVersionId); + + const workflows = await this.workflowRepository.find({ + select: { id: true }, + where: { id: In(activeWorkflows.map((workflow) => workflow.id)) }, + relations: { activeVersion: true }, + }); + + const models: ChatModelDto[] = []; + + for (const { id, activeVersion } of workflows) { + if (!activeVersion) { + continue; + } + + const chatTrigger = activeVersion.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); + + if (!chatTrigger) { + continue; + } + + const chatTriggerParams = validChatTriggerParamsShape.safeParse(chatTrigger.parameters).data; + if (!chatTriggerParams) { + continue; + } + + models.push({ + name: chatTriggerParams.agentName ?? activeVersion.name ?? 'Unknown Agent', + description: chatTriggerParams.agentDescription ?? null, + model: { + provider: 'n8n', + workflowId: id, + }, + createdAt: activeVersion.createdAt ? activeVersion.createdAt.toISOString() : null, + updatedAt: activeVersion.updatedAt ? activeVersion.updatedAt.toISOString() : null, + allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false, + }); + } + return { - models: workflows - // Ensure the user has at least read access to the workflow - .filter((workflow) => workflow.scopes.includes('workflow:read')) - .filter((workflow) => !!workflow.activeVersionId) - .flatMap((workflow) => { - const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); - if (!chatTrigger) { - return []; - } - - const chatTriggerParams = validChatTriggerParamsShape.safeParse( - chatTrigger.parameters, - ).data; - - if (!chatTriggerParams) { - return []; - } - - return [ - { - name: chatTriggerParams.agentName ?? workflow.name ?? 'Unknown Agent', - description: chatTriggerParams.agentDescription ?? null, - model: { - provider: 'n8n', - workflowId: workflow.id, - }, - createdAt: workflow.createdAt ? workflow.createdAt.toISOString() : null, - updatedAt: workflow.updatedAt ? workflow.updatedAt.toISOString() : null, - allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false, - }, - ]; - }), + models, }; } @@ -1372,14 +1385,14 @@ export class ChatHubService { workflowId, user, ['workflow:read'], - { includeTags: false, includeParentFolder: false }, + { includeTags: false, includeParentFolder: false, includeActiveVersion: true }, ); - if (!workflowEntity) { + if (!workflowEntity?.activeVersion) { throw new BadRequestError('Workflow not found'); } - const chatTriggers = workflowEntity.nodes.filter( + const chatTriggers = workflowEntity.activeVersion.nodes.filter( (node) => node.type === CHAT_TRIGGER_NODE_TYPE, ); @@ -1389,7 +1402,7 @@ export class ChatHubService { const chatTriggerNode = chatTriggers[0]; - const chatResponseNodes = workflowEntity.nodes.filter( + const chatResponseNodes = workflowEntity.activeVersion.nodes.filter( (node) => node.type === RESPOND_TO_CHAT_NODE_TYPE, ); @@ -1415,10 +1428,16 @@ export class ChatHubService { }, }); + const workflowData: IWorkflowBase = { + ...workflowEntity.activeVersion, + id: workflowEntity.id, + active: true, + isArchived: workflowEntity.isArchived, + activeVersionId: workflowEntity.activeVersionId, + }; + return { - workflowData: { - ...workflowEntity, - }, + workflowData, executionData, }; } @@ -1566,7 +1585,7 @@ export class ChatHubService { }, }; - return JSON.stringify(enriched) + '\n'; + return jsonStringify(enriched) + '\n'; }; const stream = interceptResponseWrites(res, transform); @@ -1694,7 +1713,7 @@ export class ChatHubService { } this.logger.debug( - `Using credential ID ${credential.id} for title generation in project ${credential.projectId}, model ${JSON.stringify(resolvedModel)}`, + `Using credential ID ${credential.id} for title generation in project ${credential.projectId}, model ${jsonStringify(resolvedModel)}`, ); return await this.chatHubWorkflowService.createTitleGenerationWorkflow( @@ -1754,14 +1773,14 @@ export class ChatHubService { workflowId, user, ['workflow:read'], - { includeTags: false, includeParentFolder: false, em: trx }, + { includeTags: false, includeParentFolder: false, includeActiveVersion: true, em: trx }, ); - if (!workflowEntity) { + if (!workflowEntity?.activeVersion) { throw new BadRequestError('Workflow not found for title generation'); } - const modelNodes = this.findSupportedLLMNodes(workflowEntity); + const modelNodes = this.findSupportedLLMNodes(workflowEntity.activeVersion.nodes); this.logger.debug( `Found ${modelNodes.length} LLM nodes in workflow ${workflowEntity.id} for title generation`, ); @@ -1812,20 +1831,17 @@ export class ChatHubService { return { resolvedCredentials, resolvedModel, credential }; } - private findSupportedLLMNodes(workflowEntity: { nodes: INode[]; id: string }) { - return workflowEntity.nodes.reduce>( - (acc, node) => { - const supportedProvider = Object.entries(PROVIDER_NODE_TYPE_MAP).find( - ([_provider, { name }]) => node.type === name, - ); - if (supportedProvider) { - const [provider] = supportedProvider; - acc.push({ node, provider: provider as ChatHubLLMProvider }); - } - return acc; - }, - [], - ); + private findSupportedLLMNodes(nodes: INode[]) { + return nodes.reduce>((acc, node) => { + const supportedProvider = Object.entries(PROVIDER_NODE_TYPE_MAP).find( + ([_provider, { name }]) => node.type === name, + ); + if (supportedProvider) { + const [provider] = supportedProvider; + acc.push({ node, provider: provider as ChatHubLLMProvider }); + } + return acc; + }, []); } private async resolveFromCustomAgent( @@ -2238,18 +2254,21 @@ export class ChatHubService { if (model.provider === 'n8n') { // Find the workflow to get its name - const workflow = await this.workflowFinderService.findWorkflowForUser( + const workflowEntity = await this.workflowFinderService.findWorkflowForUser( model.workflowId, user, ['workflow:read'], - { includeTags: false, includeParentFolder: false }, + { includeTags: false, includeParentFolder: false, includeActiveVersion: true }, ); - if (!workflow) { + if (!workflowEntity?.activeVersion) { throw new BadRequestError('Workflow not found for chat session initialization'); } - const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); + const chatTrigger = workflowEntity.activeVersion.nodes?.find( + (node) => node.type === CHAT_TRIGGER_NODE_TYPE, + ); + if (!chatTrigger) { throw new BadRequestError( 'Chat trigger not found in workflow for chat session initialization', From 6d67db4449db7ab8a5f8d413461ffc46f57d50bb Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:10:38 +0200 Subject: [PATCH 002/167] =?UTF-8?q?=F0=9F=9A=80=20Release=201.123.0=20(#22?= =?UTF-8?q?565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 68 +++++++++++++++++++ nsjail | 1 + package.json | 2 +- .../@n8n/ai-workflow-builder.ee/package.json | 2 +- packages/@n8n/api-types/package.json | 2 +- packages/@n8n/backend-common/package.json | 2 +- packages/@n8n/backend-test-utils/package.json | 2 +- packages/@n8n/client-oauth2/package.json | 2 +- packages/@n8n/config/package.json | 2 +- packages/@n8n/db/package.json | 2 +- packages/@n8n/decorators/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/@n8n/task-runner/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/frontend/@n8n/chat/package.json | 2 +- .../frontend/@n8n/design-system/package.json | 2 +- packages/frontend/@n8n/i18n/package.json | 2 +- .../@n8n/rest-api-client/package.json | 2 +- packages/frontend/@n8n/stores/package.json | 2 +- packages/frontend/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 24 files changed, 91 insertions(+), 22 deletions(-) create mode 160000 nsjail diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0993a394d..2e55f839325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,71 @@ +# [1.123.0](https://github.com/n8n-io/n8n/compare/n8n@1.122.0...n8n@1.123.0) (2025-12-01) + + +### Bug Fixes + +* **AI Agent Node:** Unify memory management for streaming/non-streaming ([#22243](https://github.com/n8n-io/n8n/issues/22243)) ([df81c77](https://github.com/n8n-io/n8n/commit/df81c77a248136b141e2324036cc8ecdc41f3590)) +* **ai-builder:** Fix import of multiple nodes with maxNode, add validation ([#22348](https://github.com/n8n-io/n8n/issues/22348)) ([4319da6](https://github.com/n8n-io/n8n/commit/4319da6f1cc1421285ada04551969a170ca5c518)) +* **ai-builder:** Keep existing pin data when modifying the workflow ([#22266](https://github.com/n8n-io/n8n/issues/22266)) ([83ea8e1](https://github.com/n8n-io/n8n/commit/83ea8e1f91c5ee07fee89e39743b3c39bbd479db)) +* Bump node-forge and body-parser ([#22418](https://github.com/n8n-io/n8n/issues/22418)) ([3c0e809](https://github.com/n8n-io/n8n/commit/3c0e809e416569c64d3c5e96c4c5f5f84b15e255)) +* **core, editor:** Support inclusive and exclusive destination node mode in the frontend and API ([#22244](https://github.com/n8n-io/n8n/issues/22244)) ([344c909](https://github.com/n8n-io/n8n/commit/344c90940e7e75ddaeba5e23c0953feebb7aa98e)) +* **core:** Add missing workflowId when creating base subworkflowworkflow additional data ([#22312](https://github.com/n8n-io/n8n/issues/22312)) ([8f6c3b2](https://github.com/n8n-io/n8n/commit/8f6c3b2dce25fde6c64a04b85179d829d1d45feb)) +* **core:** Add response validation for requests during DCR ([#22076](https://github.com/n8n-io/n8n/issues/22076)) ([9ab9d1c](https://github.com/n8n-io/n8n/commit/9ab9d1c8f1fd4e65e9a7924f5d7bc3925b024261)) +* **core:** Defer requirements check for Python runner in internal mode ([#22448](https://github.com/n8n-io/n8n/issues/22448)) ([97d8b39](https://github.com/n8n-io/n8n/commit/97d8b393261eb8306d8cc33bb4883fc7be3ee1b2)) +* **core:** Introduce batch workflow rule to fix subworkflow with wait node detection ([#22447](https://github.com/n8n-io/n8n/issues/22447)) ([e0bc441](https://github.com/n8n-io/n8n/commit/e0bc4416ea1709cf5468f580074d46cd43cff09d)) +* **core:** Mark `''` and `[]` as empty when filtering numbers ([#22347](https://github.com/n8n-io/n8n/issues/22347)) ([a4d2cfa](https://github.com/n8n-io/n8n/commit/a4d2cfae4af2a2080b0e610ea3298e24238871ae)) +* **core:** OIDC form breaks when enabling OIDC while SAML is active ([#22463](https://github.com/n8n-io/n8n/issues/22463)) ([613b088](https://github.com/n8n-io/n8n/commit/613b08843d6542de2e3e98ee2a0535535294ce1b)) +* **core:** Prevent credentials saving if missing required properties ([#22213](https://github.com/n8n-io/n8n/issues/22213)) ([6d88007](https://github.com/n8n-io/n8n/commit/6d88007f014b1dba779f9217d5f3cf5fab854c3e)) +* **core:** Send prod workflow succeeded for wfs in projects ([#22223](https://github.com/n8n-io/n8n/issues/22223)) ([7068fe2](https://github.com/n8n-io/n8n/commit/7068fe2510882c438cbd73bede40dbbb1a1eebbb)) +* Detect workflow calling itself expression in migration report ([#22516](https://github.com/n8n-io/n8n/issues/22516)) ([1ddb634](https://github.com/n8n-io/n8n/commit/1ddb6344a5dbddd6385f1d68d94314fa5ab72cb1)) +* **editor, core:** Fix display for binary data metadata origin ([#22270](https://github.com/n8n-io/n8n/issues/22270)) ([df1aa13](https://github.com/n8n-io/n8n/commit/df1aa130cb3a417b5795b13af50e8c57f5137aff)) +* **editor:** Avoid clearing EULA license key when closing dialog ([#22424](https://github.com/n8n-io/n8n/issues/22424)) ([fe05ea4](https://github.com/n8n-io/n8n/commit/fe05ea4df8b415f600bb8d29bf29544683f70c3a)) +* **editor:** Eval trigger node with data table works when underlying data changes ([#22389](https://github.com/n8n-io/n8n/issues/22389)) ([6f1b09e](https://github.com/n8n-io/n8n/commit/6f1b09eeda8554abc207e0640fee8cc500cea3c6)) +* **editor:** Fix editor pan / zoom when inserting nodes ([#22401](https://github.com/n8n-io/n8n/issues/22401)) ([f435430](https://github.com/n8n-io/n8n/commit/f4354301ecb0cbb0d6386d4f32c2641845cfc8fe)) +* **editor:** Fix issue for blinking/jumping UI when switching projects ([#22256](https://github.com/n8n-io/n8n/issues/22256)) ([6185550](https://github.com/n8n-io/n8n/commit/6185550971e133beb9092d3695eecfa6e66e6113)) +* **editor:** Fix sub-nodes connection labels counters ([#21549](https://github.com/n8n-io/n8n/issues/21549)) ([d366cb4](https://github.com/n8n-io/n8n/commit/d366cb4f37eacc422cafc7795180837a3f1087e8)) +* **editor:** Missing duplicate workflow action on workflow list ([#22230](https://github.com/n8n-io/n8n/issues/22230)) ([000cccb](https://github.com/n8n-io/n8n/commit/000cccb62700144fd41ed70e2177de1c3cb32c31)) +* **editor:** Preserve paired item data in pinned data ([#21417](https://github.com/n8n-io/n8n/issues/21417)) ([254a53e](https://github.com/n8n-io/n8n/commit/254a53e7bb8acdd3202841b0ec85a87907e4379b)) +* **editor:** Remove unwanted outlines when waiting for webhook event in light mode in new NDV ([#22425](https://github.com/n8n-io/n8n/issues/22425)) ([4d68b6c](https://github.com/n8n-io/n8n/commit/4d68b6c6987d7193fd35aa1c41958252df56ab02)) +* **editor:** Standardize CSS class naming conventions across new design system components ([#22551](https://github.com/n8n-io/n8n/issues/22551)) ([c82d95a](https://github.com/n8n-io/n8n/commit/c82d95aecbbdf3379929ca99fb474909f08bfbf4)) +* **editor:** Wording for migration rule detail table headers ([#22398](https://github.com/n8n-io/n8n/issues/22398)) ([cd9f565](https://github.com/n8n-io/n8n/commit/cd9f56569dcf1e65df2d4f72b7007514276dac01)) +* Enable streaming version of Respond To Webhook node ([#22350](https://github.com/n8n-io/n8n/issues/22350)) ([dc407c2](https://github.com/n8n-io/n8n/commit/dc407c25fd3e9cc60704ae53c9508ee738c24efb)) +* **Git Node:** Throw an error if the repository path is blocked ([#22253](https://github.com/n8n-io/n8n/issues/22253)) ([a49b179](https://github.com/n8n-io/n8n/commit/a49b179e891c0b3501b6784c82af72b5a8c5a02d)) +* **Gmail Node:** Use Reply-To header when replying to a message ([#22145](https://github.com/n8n-io/n8n/issues/22145)) ([2a3cba7](https://github.com/n8n-io/n8n/commit/2a3cba74ee8716cc94c96b8849bda02bc88dbbcb)) +* **HTTP Node:** Handle Azure Storage Shared Key ([#22136](https://github.com/n8n-io/n8n/issues/22136)) ([b581dbc](https://github.com/n8n-io/n8n/commit/b581dbc23262c4f96f262e23d9274765cb54e09c)) +* **HTTP Request Node:** Detect and handle non-UTF-8 response encodings ([#20889](https://github.com/n8n-io/n8n/issues/20889)) ([6068fb3](https://github.com/n8n-io/n8n/commit/6068fb3b2008ed6e4cbbd01057bca280c91f021b)) +* Improve insights tests execution failure visibility ([#22538](https://github.com/n8n-io/n8n/issues/22538)) ([f937c0f](https://github.com/n8n-io/n8n/commit/f937c0fb5089dedab01361f67e39e7280d964d8a)) +* **MCP Client Node:** Fix selecting PKCE auth flow for some servers and request scopes from `scopes_requested` during DCR ([#22405](https://github.com/n8n-io/n8n/issues/22405)) ([6765d15](https://github.com/n8n-io/n8n/commit/6765d15ead1e0226078d4a6adbbf7d36e7a827fb)) +* Pin n8n packages to workspace version ([#22460](https://github.com/n8n-io/n8n/issues/22460)) ([75b2cd0](https://github.com/n8n-io/n8n/commit/75b2cd0de6b02b392be2b1e4f61a243da7bfe5b8)) +* **PostgreSQL Node:** Input items with array being modified ([#22426](https://github.com/n8n-io/n8n/issues/22426)) ([42cda59](https://github.com/n8n-io/n8n/commit/42cda59ee5f175b2b9f887b8a5fbdd60fda8184b)) +* Renovate will now bump versions ([#22245](https://github.com/n8n-io/n8n/issues/22245)) ([17ea0dd](https://github.com/n8n-io/n8n/commit/17ea0dd4662beecc61ec7fbf4bb45abeab69de54)) +* Show correct date range in insight overview ([3130d20](https://github.com/n8n-io/n8n/commit/3130d205331124d08299c54db5a31a0a6781e5b3)) +* **Slack Node:** Sort messages manually ([#21822](https://github.com/n8n-io/n8n/issues/21822)) ([52b93ed](https://github.com/n8n-io/n8n/commit/52b93ed5b237e031c4e079ad3e620c0943fb8cda)) +* **Sort Node:** Periodic error when using Code sort type ([#22409](https://github.com/n8n-io/n8n/issues/22409)) ([2360d87](https://github.com/n8n-io/n8n/commit/2360d8719a529b61989da9b215a6c0a7da861087)) +* Support resolving $fromAI in vector store tools ([#22457](https://github.com/n8n-io/n8n/issues/22457)) ([d72d68c](https://github.com/n8n-io/n8n/commit/d72d68c7191576501e8b1a387a0d8c1366c83c05)) +* Update base image dependencies to latest security patches ([#22275](https://github.com/n8n-io/n8n/issues/22275)) ([5fd1702](https://github.com/n8n-io/n8n/commit/5fd1702429eb67bd13d5e0978faf93015c1d527f)) +* When chat session is reset, update session id ([#22288](https://github.com/n8n-io/n8n/issues/22288)) ([7d82c3c](https://github.com/n8n-io/n8n/commit/7d82c3c02a4f61941e714de76c450d3e9c21ad98)) + + +### Features + +* Add entries to context menu to copy webhook's urls ([#21769](https://github.com/n8n-io/n8n/issues/21769)) ([6e344f0](https://github.com/n8n-io/n8n/commit/6e344f0f2968a8367358761ca7606787c375cbcc)) +* Add the time saved node ([#22269](https://github.com/n8n-io/n8n/issues/22269)) ([b83c43f](https://github.com/n8n-io/n8n/commit/b83c43f8dd9d16932a3c45da908ee30053624f62)) +* Add time saved mode workflow setting ([#22343](https://github.com/n8n-io/n8n/issues/22343)) ([b2f78d7](https://github.com/n8n-io/n8n/commit/b2f78d7c7aba592a70c08751f2ef0a004a0be60f)) +* **ai-builder:** Using templates to improve generation ([#22521](https://github.com/n8n-io/n8n/issues/22521)) ([7186dcf](https://github.com/n8n-io/n8n/commit/7186dcfe7ee184319aefce4ae2c09ead6dece418)) +* Allow configuring workflow for time saved capture by node ([#22386](https://github.com/n8n-io/n8n/issues/22386)) ([4adfced](https://github.com/n8n-io/n8n/commit/4adfced9373ca2b4da57ec9a28cbae63c6e615f8)) +* **Azure AI Search Node:** Add clear index option to Azure AI Search vector store ([#22183](https://github.com/n8n-io/n8n/issues/22183)) ([5cb594d](https://github.com/n8n-io/n8n/commit/5cb594d7efcc52c834855d13850735939e7d7086)) +* **core:** Add get version public endpoint ([#22407](https://github.com/n8n-io/n8n/issues/22407)) ([b6f8050](https://github.com/n8n-io/n8n/commit/b6f8050dfab80647fa2ed336c23566654918f936)) +* **core:** Deactivate crashed workflows ([#21888](https://github.com/n8n-io/n8n/issues/21888)) ([710a654](https://github.com/n8n-io/n8n/commit/710a6548d444fe740158333ce362fec62c8d3075)) +* **core:** Inject hooks into applicable trigger node properties for the node UI ([#22290](https://github.com/n8n-io/n8n/issues/22290)) ([92dca5f](https://github.com/n8n-io/n8n/commit/92dca5f739c535d8145fd54be8235e441e2c08ba)) +* **core:** Introduce `database` mode for binary data storage ([#22162](https://github.com/n8n-io/n8n/issues/22162)) ([934b9a7](https://github.com/n8n-io/n8n/commit/934b9a7346c713c4f3ced9fb7b972eb231affc15)) +* **core:** Use new workflow history in mcp tools ([#22384](https://github.com/n8n-io/n8n/issues/22384)) ([32c2909](https://github.com/n8n-io/n8n/commit/32c2909d729375408db13696357895a5ec28d28a)) +* **Form Node:** Allow users to set a default value for form fields ([#22200](https://github.com/n8n-io/n8n/issues/22200)) ([b0fc88b](https://github.com/n8n-io/n8n/commit/b0fc88b437e376b8283ed6bddcca63bac74c1aae)) +* **Gemini Node:** Add support for Nano Banana Pro model ([#22254](https://github.com/n8n-io/n8n/issues/22254)) ([7a4cc40](https://github.com/n8n-io/n8n/commit/7a4cc40dc85998790d57e336ab3384905912222c)) +* **Guardrails Node:** Require Chat model only for LLM checks ([#22241](https://github.com/n8n-io/n8n/issues/22241)) ([c1dade7](https://github.com/n8n-io/n8n/commit/c1dade7ad397d0aed2245554d32906f774860a3f)) +* Reduce unauthentication information in settings endpoint further ([#22106](https://github.com/n8n-io/n8n/issues/22106)) ([83809e6](https://github.com/n8n-io/n8n/commit/83809e62ad0f3d22447549b03173f38f93f523a1)) + + + # [1.122.0](https://github.com/n8n-io/n8n/compare/n8n@1.121.0...n8n@1.122.0) (2025-11-24) diff --git a/nsjail b/nsjail new file mode 160000 index 00000000000..c168180d97f --- /dev/null +++ b/nsjail @@ -0,0 +1 @@ +Subproject commit c168180d97f4ee8107d6fbfc0ff94f8ebce7e0d0 diff --git a/package.json b/package.json index b73970a8cdf..67e7c433312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.122.0", + "version": "1.123.0", "private": true, "engines": { "node": ">=22.16", diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index ee06e8ee876..7a898940fe4 100644 --- a/packages/@n8n/ai-workflow-builder.ee/package.json +++ b/packages/@n8n/ai-workflow-builder.ee/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/ai-workflow-builder", - "version": "0.32.0", + "version": "0.33.0", "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 814fd1d308b..ac4e1077a86 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.56.0", + "version": "0.57.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index 307e86e2b7e..d84f6727d67 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-common", - "version": "0.32.0", + "version": "0.33.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-test-utils/package.json b/packages/@n8n/backend-test-utils/package.json index 8ee6fbe43ed..f6c07d31f18 100644 --- a/packages/@n8n/backend-test-utils/package.json +++ b/packages/@n8n/backend-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-test-utils", - "version": "0.25.0", + "version": "0.26.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index ad67275ef51..ae6bb575c9c 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "0.32.0", + "version": "0.33.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index eabf23399a4..a28dcccec87 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.64.0", + "version": "1.65.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index b591f437c8c..59b9d8bf0de 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/db", - "version": "0.33.0", + "version": "0.34.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index 61af4289d50..0bf0bb75456 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/decorators", - "version": "0.32.0", + "version": "0.33.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 22daaa387d1..da12d9a8d45 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.121.0", + "version": "1.122.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 28aafdf8cbc..8f24779e799 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.58.0", + "version": "1.59.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 953a6268ba3..a98f67579e1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.122.0", + "version": "1.123.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 4949e6841a0..0008763d94b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.121.0", + "version": "1.122.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index d424335ff6a..51214034706 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.67.0", + "version": "0.68.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index dd92625ce43..d86fec75651 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@n8n/design-system", - "version": "1.109.0", + "version": "1.110.0", "main": "src/index.ts", "import": "src/index.ts", "scripts": { diff --git a/packages/frontend/@n8n/i18n/package.json b/packages/frontend/@n8n/i18n/package.json index 98223d364d8..7502832244a 100644 --- a/packages/frontend/@n8n/i18n/package.json +++ b/packages/frontend/@n8n/i18n/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/i18n", "type": "module", - "version": "1.26.0", + "version": "1.27.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/rest-api-client/package.json b/packages/frontend/@n8n/rest-api-client/package.json index 172f49ec5a0..35c12d36dd6 100644 --- a/packages/frontend/@n8n/rest-api-client/package.json +++ b/packages/frontend/@n8n/rest-api-client/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/rest-api-client", "type": "module", - "version": "1.25.0", + "version": "1.26.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/stores/package.json b/packages/frontend/@n8n/stores/package.json index ecab59a3e4a..4700a24b94c 100644 --- a/packages/frontend/@n8n/stores/package.json +++ b/packages/frontend/@n8n/stores/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/stores", "type": "module", - "version": "1.29.0", + "version": "1.30.0", "files": [ "dist" ], diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index edb7f711dde..1a332c98c73 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.122.0", + "version": "1.123.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "type": "module", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 3b763fde9df..ebb06ed77ec 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.120.0", + "version": "1.121.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1ca95253cf0..767a63e3585 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.120.0", + "version": "1.121.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 7679752ac36..574acc23b62 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.119.0", + "version": "1.120.0", "description": "Workflow base code of n8n", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", From c9e4d2b1c89e706b089c539842d30df2051163fa Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Tue, 2 Dec 2025 08:50:14 +0100 Subject: [PATCH 003/167] feat: Reflect model's capabilities in UI (no-changelog) (#22356) --- packages/@n8n/api-types/src/chat-hub.ts | 11 +- packages/@n8n/api-types/src/index.ts | 2 + .../modules/chat-hub/chat-hub-agent.entity.ts | 10 +- .../chat-hub/chat-hub-agent.service.ts | 3 +- .../chat-hub/chat-hub-workflow.service.ts | 40 ++- .../modules/chat-hub/chat-hub.constants.ts | 300 +++++++++++++++++- .../src/modules/chat-hub/chat-hub.service.ts | 43 ++- .../src/modules/chat-hub/chat-hub.types.ts | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../src/features/ai/chatHub/ChatView.vue | 75 +++-- .../src/features/ai/chatHub/chat.store.ts | 56 +++- .../src/features/ai/chatHub/chat.utils.ts | 28 ++ .../chatHub/components/AgentEditorModal.vue | 55 +++- .../components/ChatConversationHeader.vue | 5 +- .../ai/chatHub/components/ChatPrompt.vue | 73 +++-- .../ai/chatHub/components/ModelSelector.vue | 37 +-- .../ai/chatHub/components/ToolsSelector.vue | 69 ++-- .../ai/chatHub/composables/useCustomAgent.ts | 29 +- 18 files changed, 665 insertions(+), 174 deletions(-) diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 518d78461ab..1bd6bd557b8 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -207,13 +207,22 @@ export const chatModelsRequestSchema = z.object({ export type ChatModelsRequest = z.infer; +export type ChatHubInputModality = 'text' | 'image' | 'audio' | 'video' | 'file'; + +export interface ChatModelMetadataDto { + inputModalities: ChatHubInputModality[]; + capabilities: { + functionCalling: boolean; + }; +} + export interface ChatModelDto { model: ChatHubConversationModel; name: string; description: string | null; updatedAt: string | null; createdAt: string | null; - allowFileUploads?: boolean; + metadata: ChatModelMetadataDto; } /** diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index faae5c3e485..ffd29775a75 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -9,6 +9,8 @@ export type * from './community-node-types'; export { chatHubConversationModelSchema, type ChatModelDto, + type ChatModelMetadataDto, + type ChatHubInputModality, type ChatHubOpenAIModel, type ChatHubAnthropicModel, type ChatHubGoogleModel, diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts index 8bbfa764cee..b858118d24f 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts @@ -1,4 +1,4 @@ -import { ChatHubProvider } from '@n8n/api-types'; +import { ChatHubLLMProvider } from '@n8n/api-types'; import { WithTimestamps, User, CredentialsEntity, JsonColumn } from '@n8n/db'; import { Column, Entity, ManyToOne, JoinColumn, PrimaryGeneratedColumn } from '@n8n/typeorm'; import { INode } from 'n8n-workflow'; @@ -40,23 +40,23 @@ export class ChatHubAgent extends WithTimestamps { owner?: User; /* - * ID of the selected credential to use by default with the selected LLM provider (if applicable). + * ID of the selected credential to use by default with the selected LLM provider. */ @Column({ type: 'varchar', length: 36, nullable: true }) credentialId: string | null; /** - * The selected credential to use by default with the selected LLM provider (if applicable). + * The selected credential to use by default with the selected LLM provider. */ @ManyToOne('CredentialsEntity', { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'credentialId' }) credential?: CredentialsEntity | null; /* - * Enum value of the LLM provider to use, e.g. 'openai', 'anthropic', 'google', 'n8n' (if applicable). + * Enum value of the LLM provider to use, e.g. 'openai', 'anthropic', 'google'. */ @Column({ type: 'varchar', length: 16, nullable: true }) - provider: ChatHubProvider; + provider: ChatHubLLMProvider; /* * LLM model to use from the provider (if applicable) diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts index 8c197ae2e53..36a23bb4fcb 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts @@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { ChatHubAgent } from './chat-hub-agent.entity'; import { ChatHubAgentRepository } from './chat-hub-agent.repository'; import { ChatHubCredentialsService } from './chat-hub-credentials.service'; +import { getModelMetadata } from './chat-hub.constants'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -32,7 +33,7 @@ export class ChatHubAgentService { }, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), - allowFileUploads: true, + metadata: getModelMetadata(agent.provider, agent.model), })), }; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index 65d05a25102..b3a9c06ca67 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -1,4 +1,4 @@ -import { ChatHubConversationModel, ChatSessionId } from '@n8n/api-types'; +import { ChatHubConversationModel, ChatSessionId, type ChatHubInputModality } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { SharedWorkflow, @@ -196,6 +196,44 @@ export class ChatHubWorkflowService { ]; } + /** + * Parses input modalities from chat trigger options + * Converts MIME types string to ChatHubInputModality array + */ + parseInputModalities(options?: { + allowFileUploads?: boolean; + allowedFilesMimeTypes?: string; + }): ChatHubInputModality[] { + const allowFileUploads = options?.allowFileUploads ?? false; + const allowedFilesMimeTypes = options?.allowedFilesMimeTypes; + + if (!allowFileUploads) { + return ['text']; + } + + if (!allowedFilesMimeTypes || allowedFilesMimeTypes === '*/*') { + return ['text', 'image', 'audio', 'video', 'file']; + } + + const mimeTypes = allowedFilesMimeTypes.split(',').map((type) => type.trim()); + const modalities = new Set(['text']); + + for (const mimeType of mimeTypes) { + if (mimeType.startsWith('image/')) { + modalities.add('image'); + } else if (mimeType.startsWith('audio/')) { + modalities.add('audio'); + } else if (mimeType.startsWith('video/')) { + modalities.add('video'); + } else { + // Any other MIME type falls under generic 'file' + modalities.add('file'); + } + } + + return Array.from(modalities); + } + private getUniqueNodeName(originalName: string, existingNames: Set): string { if (!existingNames.has(originalName)) { return originalName; diff --git a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts index e45df575a87..47e9137ab94 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts @@ -1,4 +1,4 @@ -import type { ChatHubLLMProvider } from '@n8n/api-types'; +import type { ChatHubLLMProvider, ChatModelMetadataDto } from '@n8n/api-types'; import type { INodeTypeNameVersion } from 'n8n-workflow'; export const CONVERSATION_TITLE_GENERATION_PROMPT = `Generate a concise, descriptive title for this conversation based on the user's message. @@ -89,3 +89,301 @@ export const JSONL_STREAM_HEADERS = { Connection: 'keep-alive', }; /* eslint-enable @typescript-eslint/naming-convention */ + +// Default metadata for all models +const DEFAULT_MODEL_METADATA: ChatModelMetadataDto = { + inputModalities: ['text', 'image', 'audio', 'video', 'file'], + capabilities: { + functionCalling: true, + }, +}; + +const MODEL_METADATA_REGISTRY: Partial< + Record>>> +> = { + anthropic: { + 'claude-3-5-haiku-20241022': { + inputModalities: ['text', 'image'], + }, + 'claude-3-haiku-20240307': { + inputModalities: ['text', 'image'], + }, + 'claude-3-opus-20240229': { + inputModalities: ['text', 'image'], + }, + 'claude-3-sonnet-20240229': { + inputModalities: ['text', 'image'], + }, + 'claude-3-5-sonnet-20241022': { + inputModalities: ['text', 'image'], + }, + 'claude-3-7-sonnet-20250219': { + inputModalities: ['text', 'image'], + }, + 'claude-sonnet-4-20250514': { + inputModalities: ['text', 'image'], + }, + 'claude-sonnet-4-5-20250929': { + inputModalities: ['text', 'image'], + }, + 'claude-haiku-4-5-20251001': { + inputModalities: ['text', 'image'], + }, + 'claude-opus-4-20250514': { + inputModalities: ['text', 'image'], + }, + 'claude-opus-4-1-20250805': { + inputModalities: ['text', 'image'], + }, + }, + openai: { + 'gpt-4o-mini-search-preview': { + inputModalities: ['text'], + capabilities: { + functionCalling: false, + }, + }, + 'gpt-4o-mini-search-preview-2025-03-11': { + inputModalities: ['text'], + capabilities: { + functionCalling: false, + }, + }, + 'gpt-4o-search-preview': { + inputModalities: ['text'], + capabilities: { + functionCalling: false, + }, + }, + 'gpt-4o-search-preview-2025-03-11': { + inputModalities: ['text'], + capabilities: { + functionCalling: false, + }, + }, + 'gpt-3.5-turbo': { + inputModalities: ['text'], + }, + 'gpt-4': { + inputModalities: ['text'], + }, + 'gpt-4-turbo': { + inputModalities: ['text', 'image'], + }, + 'o1-mini': { + inputModalities: ['text'], + }, + 'o1-mini-2024-09-12': { + inputModalities: ['text'], + }, + o1: { + inputModalities: ['text'], + }, + 'o1-pro': { + inputModalities: ['text'], + }, + 'o1-pro-2025-03-19': { + inputModalities: ['text'], + }, + 'o3-mini': { + inputModalities: ['text'], + }, + 'o4-mini': { + inputModalities: ['text'], + }, + 'o4-mini-2025-04-16': { + inputModalities: ['text'], + }, + 'o4-mini-high': { + inputModalities: ['text'], + }, + o3: { + inputModalities: ['text'], + }, + 'o3-2025-04-16': { + inputModalities: ['text'], + }, + 'o3-pro': { + inputModalities: ['text'], + }, + 'o3-pro-2025-06-10': { + inputModalities: ['text'], + }, + }, + mistralCloud: { + // Most Mistral models support text and image + 'mistral-tiny-2312': { + inputModalities: ['text'], + }, + 'mistral-tiny-2407': { + inputModalities: ['text'], + }, + 'mistral-tiny-latest': { + inputModalities: ['text'], + }, + 'mistral-tiny': { + inputModalities: ['text'], + }, + 'mistral-small-2312': { + inputModalities: ['text'], + }, + 'mistral-small-2409': { + inputModalities: ['text'], + }, + 'mistral-small-2501': { + inputModalities: ['text'], + }, + 'mistral-small-2503': { + inputModalities: ['text'], + }, + 'mistral-small-2506': { + inputModalities: ['text'], + }, + 'mistral-small-latest': { + inputModalities: ['text'], + }, + 'open-mistral-7b': { + inputModalities: ['text'], + }, + 'open-mistral-nemo': { + inputModalities: ['text'], + }, + 'open-mistral-nemo-2407': { + inputModalities: ['text'], + }, + 'open-mixtral-8x7b': { + inputModalities: ['text'], + }, + 'open-mixtral-8x22b': { + inputModalities: ['text'], + }, + 'open-mixtral-8x22b-2404': { + inputModalities: ['text'], + }, + 'ministral-3b-2410': { + inputModalities: ['text'], + }, + 'ministral-3b-latest': { + inputModalities: ['text'], + }, + 'ministral-8b-2410': { + inputModalities: ['text'], + }, + 'ministral-8b-latest': { + inputModalities: ['text'], + }, + }, + // Reference: https://ai.google.dev/gemini-api/docs/models + google: { + // Gemini 3 series - latest models with advanced multimodal understanding + 'models/gemini-3-pro-preview': {}, + 'models/gemini-3-pro-image-preview': { + inputModalities: ['text', 'image'], + capabilities: { functionCalling: false }, + }, + // Gemini 2.5 Pro series + 'models/gemini-2.5-pro': {}, + 'models/gemini-2.5-pro-exp-03-25': {}, + 'models/gemini-2.5-pro-preview-03-25': {}, + 'models/gemini-2.5-pro-preview-05-06': {}, + 'models/gemini-2.5-pro-preview-06-05': {}, + 'models/gemini-2.5-pro-preview-tts': { + inputModalities: ['text'], + capabilities: { functionCalling: false }, + }, + // Gemini 2.5 Flash series + 'models/gemini-2.5-flash': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-04-17': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-05-20': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-09-2025': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-tts': { + inputModalities: ['text'], + capabilities: { functionCalling: false }, + }, + 'models/gemini-2.5-flash-image': { + inputModalities: ['text', 'image'], + capabilities: { functionCalling: false }, + }, + 'models/gemini-2.5-flash-image-preview': { + inputModalities: ['text', 'image'], + capabilities: { functionCalling: false }, + }, + 'models/gemini-2.5-flash-native-audio-preview-09-2025': { + inputModalities: ['text', 'audio', 'video'], + }, + 'models/gemini-live-2.5-flash-preview': { + inputModalities: ['text', 'audio', 'video'], + }, + // Gemini 2.5 Flash-Lite series + 'models/gemini-2.5-flash-lite': {}, + 'models/gemini-2.5-flash-lite-preview-06-17': {}, + 'models/gemini-2.5-flash-lite-preview-09-2025': {}, + // Gemini 2.0 Pro series + 'models/gemini-2.0-pro-exp-02-05': {}, + // Gemini 2.0 Flash series + 'models/gemini-2.0-flash': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-001': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-exp': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-live-001': { + inputModalities: ['text', 'audio', 'video'], + }, + 'models/gemini-2.0-flash-thinking-exp': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-thinking-exp-01-21': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-thinking-exp-1219': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + // Gemini 2.0 Flash-Lite series + 'models/gemini-2.0-flash-lite': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-lite-001': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-lite-preview': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-lite-preview-02-05': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + }, +}; + +export function getModelMetadata( + provider: ChatHubLLMProvider, + modelId: string, +): ChatModelMetadataDto { + const providerModels = MODEL_METADATA_REGISTRY[provider]; + const modelOverride = providerModels?.[modelId]; + + if (!modelOverride) { + return DEFAULT_MODEL_METADATA; + } + + // Merge override with default metadata + return { + inputModalities: modelOverride.inputModalities ?? DEFAULT_MODEL_METADATA.inputModalities, + capabilities: { + functionCalling: + modelOverride.capabilities?.functionCalling ?? + DEFAULT_MODEL_METADATA.capabilities.functionCalling, + }, + }; +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 77057cd1b47..e3b00997034 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -62,7 +62,12 @@ import type { ChatHubMessage } from './chat-hub-message.entity'; import type { ChatHubSession } from './chat-hub-session.entity'; import { ChatHubWorkflowService } from './chat-hub-workflow.service'; import { ChatHubAttachmentService } from './chat-hub.attachment.service'; -import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; +import { + JSONL_STREAM_HEADERS, + NODE_NAMES, + PROVIDER_NODE_TYPE_MAP, + getModelMetadata, +} from './chat-hub.constants'; import { ChatHubSettingsService } from './chat-hub.settings.service'; import { HumanMessagePayload, @@ -218,7 +223,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, - allowFileUploads: true, + metadata: getModelMetadata('openai', String(result.value)), })), }; } @@ -246,7 +251,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, - allowFileUploads: true, + metadata: getModelMetadata('anthropic', String(result.value)), })), }; } @@ -312,7 +317,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, - allowFileUploads: true, + metadata: getModelMetadata('google', String(result.value)), })), }; } @@ -371,7 +376,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, - allowFileUploads: true, + metadata: getModelMetadata('ollama', String(result.value)), })), }; } @@ -489,7 +494,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, - allowFileUploads: true, + metadata: getModelMetadata('awsBedrock', String(result.value)), })), }; } @@ -552,6 +557,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('mistralCloud', String(result.value)), })), }; } @@ -609,6 +615,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('cohere', String(result.value)), })), }; } @@ -665,6 +672,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('deepSeek', String(result.value)), })), }; } @@ -721,6 +729,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('openRouter', String(result.value)), })), }; } @@ -777,6 +786,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('groq', String(result.value)), })), }; } @@ -833,6 +843,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('xAiGrok', String(result.value)), })), }; } @@ -889,6 +900,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + metadata: getModelMetadata('vercelAiGateway', String(result.value)), })), }; } @@ -932,6 +944,10 @@ export class ChatHubService { continue; } + const inputModalities = this.chatHubWorkflowService.parseInputModalities( + chatTriggerParams.options, + ); + models.push({ name: chatTriggerParams.agentName ?? activeVersion.name ?? 'Unknown Agent', description: chatTriggerParams.agentDescription ?? null, @@ -941,7 +957,12 @@ export class ChatHubService { }, createdAt: activeVersion.createdAt ? activeVersion.createdAt.toISOString() : null, updatedAt: activeVersion.updatedAt ? activeVersion.updatedAt.toISOString() : null, - allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false, + metadata: { + inputModalities, + capabilities: { + functionCalling: false, + }, + }, }); } @@ -1335,10 +1356,6 @@ export class ChatHubService { throw new BadRequestError('Provider or model not set for agent'); } - if (agent.provider === 'n8n' || agent.provider === 'custom-agent') { - throw new BadRequestError('Invalid provider'); - } - const credentialId = agent.credentialId; if (!credentialId) { throw new BadRequestError('Credentials not set for agent'); @@ -1858,10 +1875,6 @@ export class ChatHubService { throw new BadRequestError('Agent not found for title generation'); } - if (agent.provider === 'n8n' || agent.provider === 'custom-agent') { - throw new BadRequestError('Invalid provider for title generation'); - } - const credentialId = agent.credentialId; if (!credentialId) { throw new BadRequestError('Credentials not set for agent'); diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index 4e13bf93a3c..ad7248e7c11 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -57,6 +57,7 @@ export const validChatTriggerParamsShape = z.object({ options: z .object({ allowFileUploads: z.boolean().optional(), + allowedFilesMimeTypes: z.string().optional(), }) .optional(), }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 4f126150dbd..da098723645 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -379,6 +379,7 @@ "chatHub.chat.prompt.callout.setCredentials.existing": "Please {link} for {provider} to continue the conversation", "chatHub.chat.prompt.callout.setCredentials.existing.link": "set credentials", "chatHub.chat.prompt.button.attach": "Attach", + "chatHub.chat.prompt.button.attach.disabled": "File attachments are not supported by the selected model", "chatHub.chat.prompt.button.stopRecording": "Stop recording", "chatHub.chat.prompt.button.voiceInput": "Voice input", "chatHub.chat.prompt.button.send": "Send", @@ -394,6 +395,7 @@ "chatHub.tools.editor.cancel": "Cancel", "chatHub.tools.selector.label.count": "{count} Tool | {count} Tools", "chatHub.tools.selector.label.default": "Tools", + "chatHub.tools.selector.disabled.tooltip": "Tools are not supported by the selected model", "chatHub.credentials.selector.title": "Select {provider} credential", "chatHub.credentials.selector.chooseOrCreate": "Choose or create a credential for {provider}", "chatHub.credentials.selector.createNew": "Create new", diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue index a8c1b4c1331..2718f2cd9d2 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -9,6 +9,7 @@ import { findOneFromModelsResponse, isLlmProvider, unflattenModel, + createMimeTypes, } from '@/features/ai/chatHub/chat.utils'; import ChatConversationHeader from '@/features/ai/chatHub/components/ChatConversationHeader.vue'; import ChatMessage from '@/features/ai/chatHub/components/ChatMessage.vue'; @@ -47,6 +48,7 @@ import { chatHubConversationModelWithCachedDisplayNameSchema, } from '@/features/ai/chatHub/chat.types'; import { useI18n } from '@n8n/i18n'; +import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent'; const router = useRouter(); const route = useRoute(); @@ -72,8 +74,11 @@ const currentConversation = computed(() => ); const currentConversationTitle = computed(() => currentConversation.value?.title); -// TODO: This also depends on the model, not all base LLM models support tools. -const canSelectTools = computed(() => isLlmProvider(selectedModel.value?.model.provider)); +const canSelectTools = computed( + () => + selectedModel.value?.model.provider === 'custom-agent' || + !!selectedModel.value?.metadata.capabilities.functionCalling, +); const { arrivedState, measure } = useScroll(scrollContainerRef, { throttle: 100, @@ -99,8 +104,8 @@ const defaultModel = useLocalStorage - defaultModel.value ? chatStore.getAgent(defaultModel.value).name : undefined, +const defaultAgent = computed(() => + defaultModel.value ? chatStore.getAgent(defaultModel.value) : undefined, ); const defaultTools = useLocalStorage( @@ -122,22 +127,8 @@ const defaultTools = useLocalStorage( }, ); -const toolsSelection = ref(null); const shouldSkipNextScrollTrigger = ref(false); -const selectedTools = computed(() => { - if (currentConversation.value?.tools) { - return currentConversation.value.tools; - } - - // As soon as the user selects tools use the selection over the default - if (toolsSelection.value !== null) { - return toolsSelection.value; - } - - return defaultTools.value ?? []; -}); - const modelFromQuery = computed(() => { const agentId = route.query.agentId; const workflowId = route.query.workflowId; @@ -186,6 +177,25 @@ const selectedModel = computed(() => { return chatStore.getAgent(defaultModel.value, defaultModel.value.cachedDisplayName); }); +const customAgentId = computed(() => + selectedModel.value?.model.provider === 'custom-agent' + ? selectedModel.value.model.agentId + : undefined, +); +const customAgent = useCustomAgent(customAgentId); + +const selectedTools = computed(() => { + if (customAgent.value) { + return customAgent.value.tools; + } + + if (currentConversation.value?.tools) { + return currentConversation.value.tools; + } + + return modelFromQuery.value ? [] : (defaultTools.value ?? []); +}); + const { credentialsByProvider, selectCredential } = useChatCredentials( usersStore.currentUserId ?? 'anonymous', ); @@ -240,7 +250,7 @@ const didSubmitInCurrentSession = ref(false); const canAcceptFiles = computed( () => editingMessageId.value === undefined && - !!selectedModel.value?.allowFileUploads && + !!createMimeTypes(selectedModel.value?.metadata.inputModalities ?? []) && !isMissingSelectedCredential.value, ); @@ -350,10 +360,18 @@ watch( // Keep cached display name up-to-date watch( - defaultModelName, - (name) => { - if (defaultModel.value && name) { - defaultModel.value = { ...defaultModel.value, cachedDisplayName: name }; + defaultAgent, + (agent, prevAgent) => { + if (defaultModel.value && agent && agent.name !== prevAgent?.name) { + defaultModel.value = { ...defaultModel.value, cachedDisplayName: agent.name }; + } + + if ( + agent && + !agent.metadata.capabilities.functionCalling && + (defaultTools.value ?? []).length > 0 + ) { + defaultTools.value = []; } }, { immediate: true }, @@ -455,6 +473,9 @@ async function handleSelectModel(selection: ChatHubConversationModel, displayNam } } else { defaultModel.value = { ...selection, cachedDisplayName: agentName }; + + // Remove query params (if exists) and focus input + await router.push({ name: CHAT_VIEW, force: true }); // remove query params } } @@ -476,7 +497,6 @@ function handleConfigureModel() { } async function handleUpdateTools(newTools: INode[]) { - toolsSelection.value = newTools; defaultTools.value = newTools; if (currentConversation.value) { @@ -494,7 +514,7 @@ function handleEditAgent(agentId: string) { data: { agentId, credentials: credentialsByProvider, - onCreateCustomAgent: handleSelectModel, + onCreateCustomAgent: handleSelectAgent, }, }); } @@ -504,7 +524,7 @@ function openNewAgentCreator() { name: AGENT_EDITOR_MODAL_KEY, data: { credentials: credentialsByProvider, - onCreateCustomAgent: handleSelectModel, + onCreateCustomAgent: handleSelectAgent, }, }); } @@ -546,7 +566,7 @@ function onFilesDropped(files: File[]) { :selected-model="selectedModel" :credentials="credentialsByProvider" :ready-to-show-model-selector="isNewSession || !!currentConversation" - @select-model="handleSelectAgent" + @select-model="handleSelectModel" @edit-custom-agent="handleEditAgent" @create-custom-agent="openNewAgentCreator" @select-credential="selectCredential" @@ -617,6 +637,7 @@ function onFilesDropped(files: File[]) { @select-model="handleConfigureModel" @select-tools="handleUpdateTools" @set-credentials="handleConfigureCredentials" + @edit-agent="handleEditAgent" /> diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index 4d004f27dee..cafdd824848 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -65,6 +65,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { const telemetry = useTelemetry(); const agents = ref(); + const customAgents = ref>>({}); const sessions = ref<{ byId: Partial>; ids: string[] | null; @@ -730,8 +731,10 @@ export const useChatStore = defineStore(CHAT_STORE, () => { conversation.activeMessageChain = computeActiveChain(conversation.messages, messageId); } - async function fetchCustomAgent(agentId: string): Promise { - return await fetchAgentApi(rootStore.restApiContext, agentId); + async function fetchCustomAgent(agentId: string) { + const customAgent = await fetchAgentApi(rootStore.restApiContext, agentId); + + customAgents.value[agentId] = customAgent; } function getCustomAgent(agentId: string) { @@ -744,26 +747,32 @@ export const useChatStore = defineStore(CHAT_STORE, () => { payload: ChatHubCreateAgentRequest, credentials: CredentialsMap, ): Promise { - const agent = await createAgentApi(rootStore.restApiContext, payload); - const agentModel = { + const customAgent = await createAgentApi(rootStore.restApiContext, payload); + const baseModel = agents.value?.[customAgent.provider]?.models.find( + (model) => model.name === customAgent.model, + ); + const agent: ChatModelDto = { model: { provider: 'custom-agent' as const, - agentId: agent.id, + agentId: customAgent.id, + }, + name: customAgent.name, + description: customAgent.description ?? null, + createdAt: customAgent.createdAt, + updatedAt: customAgent.updatedAt, + metadata: baseModel?.metadata ?? { + capabilities: { functionCalling: false }, + inputModalities: [], }, - name: agent.name, - description: agent.description ?? null, - createdAt: agent.createdAt, - updatedAt: agent.updatedAt, - tools: agent.tools, - allowFileUploads: true, }; - agents.value?.['custom-agent'].models.push(agentModel); + agents.value?.['custom-agent'].models.push(agent); + customAgents.value[customAgent.id] = customAgent; await fetchAgents(credentials); telemetry.track('User created agent', { ...flattenModel(payload) }); - return agentModel; + return agent; } async function updateCustomAgent( @@ -771,18 +780,22 @@ export const useChatStore = defineStore(CHAT_STORE, () => { payload: ChatHubUpdateAgentRequest, credentials: CredentialsMap, ): Promise { - const agent = await updateAgentApi(rootStore.restApiContext, agentId, payload); + const customAgent = await updateAgentApi(rootStore.restApiContext, agentId, payload); // Update the agent in models as well if (agents.value?.['custom-agent']) { agents.value['custom-agent'].models = agents.value['custom-agent'].models.map((model) => - 'agentId' in model && model.agentId === agentId ? { ...model, name: agent.name } : model, + 'agentId' in model && model.agentId === agentId + ? { ...model, name: customAgent.name } + : model, ); } + customAgents.value[agentId] = customAgent; + await fetchAgents(credentials); - return agent; + return customAgent; } async function deleteCustomAgent(agentId: string, credentials: CredentialsMap) { @@ -795,6 +808,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => { ); } + delete customAgents.value[agentId]; + await fetchAgents(credentials); } @@ -813,7 +828,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => { description: null, createdAt: null, updatedAt: null, - allowFileUploads: true, + // Assume file attachment and tools are supported + metadata: { + inputModalities: ['text', 'file'], + capabilities: { + functionCalling: true, + }, + }, }; } @@ -858,6 +879,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { */ agents: computed(() => agents.value ?? emptyChatModelsResponse), agentsReady: computed(() => agents.value !== undefined), + customAgents, getAgent, fetchAgents, getCustomAgent, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts index a1839cf745c..3f60576b2af 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts @@ -8,6 +8,7 @@ import { type ChatMessageId, type ChatHubProvider, type ChatHubLLMProvider, + type ChatHubInputModality, } from '@n8n/api-types'; import type { ChatMessage, @@ -347,3 +348,30 @@ export function createSessionFromStreamingState(streaming: ChatStreamingState): ...flattenModel(streaming.model), }; } + +export function createMimeTypes(modalities: ChatHubInputModality[]): string | undefined { + if (modalities.length === 0) { + return undefined; + } + + // If 'file' modality is present, accept all file types + if (modalities.includes('file')) { + return '*/*'; + } + + const mimeTypes: string[] = []; + + for (const modality of modalities) { + if (modality === 'image') { + mimeTypes.push('image/*'); + } + if (modality === 'audio') { + mimeTypes.push('audio/*'); + } + if (modality === 'video') { + mimeTypes.push('video/*'); + } + } + + return mimeTypes.length > 0 ? mimeTypes.join(',') : undefined; +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue index 3b625b44ccd..c9f04b4a3a1 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue @@ -4,7 +4,12 @@ import { useMessage } from '@/app/composables/useMessage'; import { useToast } from '@/app/composables/useToast'; import { useChatStore } from '@/features/ai/chatHub/chat.store'; import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue'; -import type { ChatHubBaseLLMModel, ChatHubProvider, ChatModelDto } from '@n8n/api-types'; +import type { + ChatHubBaseLLMModel, + ChatHubConversationModel, + ChatHubProvider, + ChatModelDto, +} from '@n8n/api-types'; import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import { assert } from '@n8n/utils/assert'; @@ -15,6 +20,8 @@ import type { INode } from 'n8n-workflow'; import ToolsSelector from './ToolsSelector.vue'; import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils'; import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent'; +import { useUIStore } from '@/app/stores/ui.store'; +import { TOOLS_SELECTOR_MODAL_KEY } from '@/features/ai/chatHub/constants'; const props = defineProps<{ modalName: string; @@ -30,6 +37,8 @@ const chatStore = useChatStore(); const i18n = useI18n(); const toast = useToast(); const message = useMessage(); +const uiStore = useUIStore(); + const modalBus = ref(createEventBus()); const customAgent = useCustomAgent(props.data.agentId); @@ -78,6 +87,21 @@ const agentMergedCredentials = computed((): CredentialsMap => { }; }); +const canSelectTools = computed( + () => selectedAgent.value?.metadata.capabilities.functionCalling ?? false, +); + +// If the agent doesn't support tools anymore, reset tools +watch( + selectedAgent, + (agent) => { + if (agent && !agent.metadata.capabilities.functionCalling) { + tools.value = []; + } + }, + { immediate: true }, +); + watch( customAgent, (agent) => { @@ -103,9 +127,9 @@ function onCredentialSelected(provider: ChatHubProvider, credentialId: string | }; } -function onModelChange(agent: ChatModelDto) { - assert(isLlmProviderModel(agent.model)); - selectedModel.value = agent.model; +function onModelChange(model: ChatHubConversationModel) { + assert(isLlmProviderModel(model)); + selectedModel.value = model; } async function onSave() { @@ -182,8 +206,16 @@ async function onDelete() { } } -function onSelectTools(newTools: INode[]) { - tools.value = newTools; +function onSelectTools() { + uiStore.openModalWithData({ + name: TOOLS_SELECTOR_MODAL_KEY, + data: { + selected: tools.value, + onConfirm: (newTools: INode[]) => { + tools.value = newTools; + }, + }, + }); } @@ -283,7 +315,16 @@ function onSelectTools(newTools: INode[]) { :required="false" >
- +
diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue index 45b3416b34c..a76af0a05f2 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue @@ -5,6 +5,7 @@ import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue'; import { useChatHubSidebarState } from '@/features/ai/chatHub/composables/useChatHubSidebarState'; import { CHAT_VIEW } from '@/features/ai/chatHub/constants'; import type { + ChatHubConversationModel, ChatHubLLMProvider, ChatHubProvider, ChatModelDto, @@ -22,7 +23,7 @@ const { selectedModel, credentials, readyToShowModelSelector } = defineProps<{ }>(); const emit = defineEmits<{ - selectModel: [ChatModelDto]; + selectModel: [ChatHubConversationModel]; renameConversation: [id: ChatSessionId, title: string]; editCustomAgent: [agentId: string]; createCustomAgent: []; @@ -35,7 +36,7 @@ const router = useRouter(); const modelSelectorRef = useTemplateRef('modelSelectorRef'); const i18n = useI18n(); -function onModelChange(selection: ChatModelDto) { +function onModelChange(selection: ChatHubConversationModel) { emit('selectModel', selection); } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue index af4c8441e3a..8ce4e72d107 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatPrompt.vue @@ -1,16 +1,17 @@ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/__snapshots__/ActionBox.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/__snapshots__/ActionBox.test.ts.snap index 4761915cb30..a16e05fbb4d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/__snapshots__/ActionBox.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/__snapshots__/ActionBox.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`N8NActionBox > should render correctly 1`] = ` +exports[`N8NActionBox > should render correctly with emoji 1`] = ` "
-
😿
+
😿
@@ -13,5 +13,25 @@ exports[`N8NActionBox > should render correctly 1`] = ` + +
" +`; + +exports[`N8NActionBox > should render correctly with icon 1`] = ` +"
+
+ +
+
+ +
+
+ +
+ + + + +
" `; diff --git a/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.vue b/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.vue index 630347e70ac..a49a878eb66 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.vue +++ b/packages/frontend/editor-ui/src/app/components/layouts/ResourcesListLayout.vue @@ -595,7 +595,7 @@ defineExpose({ Date: Wed, 3 Dec 2025 15:28:01 +0000 Subject: [PATCH 035/167] test: Disable banners for e2e tests (#22680) --- packages/testing/playwright/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/testing/playwright/playwright.config.ts b/packages/testing/playwright/playwright.config.ts index ac2ffc43faf..88c2a2fa57d 100644 --- a/packages/testing/playwright/playwright.config.ts +++ b/packages/testing/playwright/playwright.config.ts @@ -62,6 +62,7 @@ export default defineConfig({ N8N_LOG_LEVEL: 'debug', N8N_METRICS: 'true', N8N_RESTRICT_FILE_ACCESS_TO: '', + N8N_DYNAMIC_BANNERS_ENABLED: 'false', ...getTestEnv(), }, } From 241bb0fe593a4a93518950239f2aa9c44dd2ee65 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Wed, 3 Dec 2025 17:54:10 +0200 Subject: [PATCH 036/167] feat(core): Chat only users (no-changelog) (#22355) --- .../invite-users-request.dto.test.ts | 1 + .../src/schemas/__tests__/user.schema.test.ts | 14 ++ .../@n8n/api-types/src/schemas/user.schema.ts | 1 + .../backend-test-utils/src/db/projects.ts | 1 + packages/@n8n/db/src/constants.ts | 4 +- packages/@n8n/db/src/entities/project.ts | 10 +- packages/@n8n/db/src/index.ts | 2 +- ...764276827837-AddCreatorIdToProjectTable.ts | 42 +++++ .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../db/src/migrations/postgresdb/index.ts | 2 + ...764276827837-AddCreatorIdToProjectTable.ts | 43 +++++ .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../db/src/repositories/project.repository.ts | 5 +- .../db/src/repositories/user.repository.ts | 23 ++- .../scope-information.test.ts.snap | 2 + .../permissions/src/__tests__/schemas.test.ts | 2 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/public-api-permissions.ee.ts | 7 + .../@n8n/permissions/src/roles/all-roles.ts | 2 + .../permissions/src/roles/role-maps.ee.ts | 2 + .../src/roles/scopes/global-scopes.ee.ts | 11 ++ packages/@n8n/permissions/src/schemas.ee.ts | 10 +- .../__tests__/get-global-scopes.test.ts | 2 +- .../get-resource-permissions.test.ts | 5 + .../__tests__/has-global-scope.test.ts | 1 + .../__tests__/roles-with-scope.test.ts | 1 + .../src/controllers/api-keys.controller.ts | 16 +- .../src/credentials/credentials.service.ts | 36 ++-- .../services/__tests__/user.service.test.ts | 158 +++++++++++++++++- .../cli/src/services/project.service.ee.ts | 2 +- .../src/services/public-api-key.service.ts | 14 +- packages/cli/src/services/user.service.ts | 93 ++++++++++- .../cli/src/workflows/workflow.service.ts | 4 +- .../cli/src/workflows/workflows.controller.ts | 38 +++-- .../credentials/credentials.api.ee.test.ts | 17 ++ .../credentials/credentials.api.test.ts | 99 ++++++++++- .../source-control.service.test.ts | 20 ++- .../test/integration/ldap/ldap.api.test.ts | 10 ++ packages/cli/test/integration/me.api.test.ts | 146 +++++++++++++++- .../endpoints-with-scopes-enabled.test.ts | 28 ++++ .../integration/public-api/projects.test.ts | 1 + .../cli/test/integration/shared/db/users.ts | 5 + .../test/integration/user.repository.test.ts | 23 ++- .../workflows/workflows.controller.test.ts | 26 ++- .../frontend/@n8n/i18n/src/locales/en.json | 18 +- packages/frontend/editor-ui/src/app/init.ts | 6 +- packages/frontend/editor-ui/src/app/router.ts | 7 +- .../editor-ui/src/app/stores/rbac.store.ts | 1 + .../editor-ui/src/app/stores/ui.store.ts | 2 +- .../src/app/utils/rbac/checks/hasRole.test.ts | 11 ++ .../components/ChatConversationHeader.vue | 20 ++- .../components/CredentialSelectorModal.vue | 65 +------ .../ai/chatHub/components/ModelSelector.vue | 9 +- .../chatHub/components/ToolsSelectorModal.vue | 36 +++- .../features/ai/chatHub/module.descriptor.ts | 7 +- .../ai/mcpAccess/module.descriptor.ts | 6 + .../collaboration/projects/projects.routes.ts | 11 ++ .../core/auth/views/SettingsPersonalView.vue | 4 + .../CredentialPicker/CredentialPicker.vue | 148 ++++++++++++++-- .../CredentialPicker/CredentialsDropdown.vue | 27 ++- .../users/components/InviteUsersModal.vue | 101 ++++++----- .../components/SettingsUsersRoleCell.vue | 6 +- .../users/components/SettingsUsersTable.vue | 21 ++- .../features/settings/users/users.store.ts | 5 + .../features/settings/users/users.types.ts | 2 +- .../users/views/SettingsUsersView.vue | 43 ++++- .../src/features/shared/tags/tags.store.ts | 7 +- 67 files changed, 1270 insertions(+), 227 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts create mode 100644 packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts diff --git a/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts index 103e9bb4ab6..f0fad0cd6a7 100644 --- a/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts @@ -17,6 +17,7 @@ describe('InviteUsersRequestDto', () => { { email: 'user1@example.com', role: 'global:member' }, { email: 'user2@example.com', role: 'global:admin' }, { email: 'user3@example.com', role: 'custom:role' }, + { email: 'user4@example.com', role: 'global:chatUser' }, ], }, ])('should validate $name', ({ request }) => { diff --git a/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts index b689e314923..ffe97f68507 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts @@ -136,6 +136,20 @@ describe('user.schema', () => { }, isValid: false, }, + { + name: 'chat user', + data: { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + role: 'global:chatUser', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: [], + }, + isValid: true, + }, { name: 'invalid role', data: { diff --git a/packages/@n8n/api-types/src/schemas/user.schema.ts b/packages/@n8n/api-types/src/schemas/user.schema.ts index a3bad669a75..953bb8f831f 100644 --- a/packages/@n8n/api-types/src/schemas/user.schema.ts +++ b/packages/@n8n/api-types/src/schemas/user.schema.ts @@ -7,6 +7,7 @@ export const ROLE = { Owner: 'global:owner', Member: 'global:member', Admin: 'global:admin', + ChatUser: 'global:chatUser', Default: 'default', // default user with no email when setting up instance } as const; diff --git a/packages/@n8n/backend-test-utils/src/db/projects.ts b/packages/@n8n/backend-test-utils/src/db/projects.ts index 59f681ccaaf..1fc78b95c09 100644 --- a/packages/@n8n/backend-test-utils/src/db/projects.ts +++ b/packages/@n8n/backend-test-utils/src/db/projects.ts @@ -27,6 +27,7 @@ export const createTeamProject = async (name?: string, adminUser?: User) => { projectRepository.create({ name: name ?? randomName(), type: 'team', + creatorId: adminUser?.id, }), ); diff --git a/packages/@n8n/db/src/constants.ts b/packages/@n8n/db/src/constants.ts index 194ebbad079..ee38ef185ca 100644 --- a/packages/@n8n/db/src/constants.ts +++ b/packages/@n8n/db/src/constants.ts @@ -3,8 +3,8 @@ import { PROJECT_EDITOR_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG, - type ProjectRole, ALL_ROLES, + type ProjectRole, type GlobalRole, type Role as RoleDTO, } from '@n8n/permissions'; @@ -51,6 +51,7 @@ export const ALL_BUILTIN_ROLES = toRoleMap([ export const GLOBAL_OWNER_ROLE = ALL_BUILTIN_ROLES['global:owner']; export const GLOBAL_ADMIN_ROLE = ALL_BUILTIN_ROLES['global:admin']; export const GLOBAL_MEMBER_ROLE = ALL_BUILTIN_ROLES['global:member']; +export const GLOBAL_CHAT_USER_ROLE = ALL_BUILTIN_ROLES['global:chatUser']; export const PROJECT_OWNER_ROLE = ALL_BUILTIN_ROLES[PROJECT_OWNER_ROLE_SLUG]; export const PROJECT_ADMIN_ROLE = ALL_BUILTIN_ROLES[PROJECT_ADMIN_ROLE_SLUG]; @@ -61,6 +62,7 @@ export const GLOBAL_ROLES: Record = { 'global:owner': GLOBAL_OWNER_ROLE, 'global:admin': GLOBAL_ADMIN_ROLE, 'global:member': GLOBAL_MEMBER_ROLE, + 'global:chatUser': GLOBAL_CHAT_USER_ROLE, }; export const PROJECT_ROLES: Record = { diff --git a/packages/@n8n/db/src/entities/project.ts b/packages/@n8n/db/src/entities/project.ts index 16b6b739c1a..ace0ac0ddee 100644 --- a/packages/@n8n/db/src/entities/project.ts +++ b/packages/@n8n/db/src/entities/project.ts @@ -1,9 +1,10 @@ -import { Column, Entity, OneToMany } from '@n8n/typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, Relation } from '@n8n/typeorm'; import { WithTimestampsAndStringId } from './abstract-entity'; import type { ProjectRelation } from './project-relation'; import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; +import { User } from './user'; import type { Variables } from './variables'; @Entity() @@ -31,4 +32,11 @@ export class Project extends WithTimestampsAndStringId { @OneToMany('Variables', 'project') variables: Variables[]; + + @Column({ type: String, nullable: true }) + creatorId: string | null; + + @ManyToOne('User', { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'creatorId' }) + creator?: Relation; } diff --git a/packages/@n8n/db/src/index.ts b/packages/@n8n/db/src/index.ts index 4b832e62ea6..54db96c74aa 100644 --- a/packages/@n8n/db/src/index.ts +++ b/packages/@n8n/db/src/index.ts @@ -39,5 +39,5 @@ export { DbConnectionOptions } from './connection/db-connection-options'; export { AuthRolesService } from './services/auth.roles.service'; -export { In, Like, DataSource } from '@n8n/typeorm'; +export { In, Like, Not, DataSource } from '@n8n/typeorm'; export type { FindOptionsWhere } from '@n8n/typeorm'; diff --git a/packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts b/packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts new file mode 100644 index 00000000000..0a93a80863c --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts @@ -0,0 +1,42 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + project: 'project', + projectRelation: 'project_relation', +} as const; + +const FOREIGN_KEY_NAME = 'projects_creatorId_foreign'; + +export class AddCreatorIdToProjectTable1764276827837 implements ReversibleMigration { + async up({ + escape, + schemaBuilder: { addColumns, addForeignKey, column }, + queryRunner, + }: MigrationContext) { + await addColumns(table.project, [ + column('creatorId').uuid.comment('ID of the user who created the project'), + ]); + + await addForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME, 'SET NULL'); + + // Populate creatorId for existing personal projects. + // We can only do this for personal projects as for team projects + // we don't have a reliable way of knowing who the creator was. + await queryRunner.query(` + UPDATE ${escape.tableName(table.project)} AS project + SET ${escape.columnName('creatorId')} = ( + SELECT pr.${escape.columnName('userId')} + FROM ${escape.tableName(table.projectRelation)} AS pr + WHERE pr.${escape.columnName('projectId')} = project.${escape.columnName('id')} + AND pr.${escape.columnName('role')} = 'project:personalOwner' + LIMIT 1 + ) + WHERE project.${escape.columnName('type')} = 'personal' + AND project.${escape.columnName('creatorId')} IS NULL;`); + } + + async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) { + await dropForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME); + await dropColumns(table.project, ['creatorId']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index e67f00de392..6c2ccf8aee9 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -119,6 +119,7 @@ import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-A import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import { CreateBinaryDataTable1763716655000 } from '../common/1763716655000-CreateBinaryDataTable'; import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/1764167920585-CreateWorkflowPublishHistoryTable'; +import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837-AddCreatorIdToProjectTable'; import type { Migration } from '../migration-types'; export const mysqlMigrations: Migration[] = [ @@ -243,4 +244,5 @@ export const mysqlMigrations: Migration[] = [ AddActiveVersionIdColumn1763047800000, CreateBinaryDataTable1763716655000, CreateWorkflowPublishHistoryTable1764167920585, + AddCreatorIdToProjectTable1764276827837, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 565e38cc6ba..b33ea3f9fcf 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -119,6 +119,7 @@ import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-A import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import { CreateBinaryDataTable1763716655000 } from '../common/1763716655000-CreateBinaryDataTable'; import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/1764167920585-CreateWorkflowPublishHistoryTable'; +import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837-AddCreatorIdToProjectTable'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -243,4 +244,5 @@ export const postgresMigrations: Migration[] = [ AddActiveVersionIdColumn1763047800000, CreateBinaryDataTable1763716655000, CreateWorkflowPublishHistoryTable1764167920585, + AddCreatorIdToProjectTable1764276827837, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts b/packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts new file mode 100644 index 00000000000..336f37ae8b3 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts @@ -0,0 +1,43 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + project: 'project', + projectRelation: 'project_relation', +} as const; + +const FOREIGN_KEY_NAME = 'projects_creatorId_foreign'; + +export class AddCreatorIdToProjectTable1764276827837 implements ReversibleMigration { + transaction = false as const; + + async up({ + escape, + schemaBuilder: { addColumns, addForeignKey, column }, + queryRunner, + }: MigrationContext) { + await addColumns(table.project, [ + column('creatorId').uuid.comment('ID of the user who created the project'), + ]); + + await addForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME, 'SET NULL'); + + // Populate creatorId for existing personal projects. + // We can only do this for personal projects as for team projects + // we don't have a reliable way of knowing who the creator was. + await queryRunner.query(` + UPDATE ${escape.tableName(table.project)} AS project + SET ${escape.columnName('creatorId')} = ( + SELECT pr.${escape.columnName('userId')} + FROM ${escape.tableName(table.projectRelation)} AS pr + WHERE pr.${escape.columnName('projectId')} = project.${escape.columnName('id')} + AND pr.${escape.columnName('role')} = 'project:personalOwner' + LIMIT 1 + ) + WHERE project.${escape.columnName('type')} = 'personal' + AND project.${escape.columnName('creatorId')} IS NULL;`); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(table.project, ['creatorId']); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index ca7ce03a5aa..53e364c62cb 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -46,6 +46,7 @@ import { AddScopesColumnToApiKeys1742918400000 } from './1742918400000-AddScopes import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable'; import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn'; import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson'; +import { AddCreatorIdToProjectTable1764276827837 } from './1764276827837-AddCreatorIdToProjectTable'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; @@ -235,6 +236,7 @@ const sqliteMigrations: Migration[] = [ AddActiveVersionIdColumn1763047800000, CreateBinaryDataTable1763716655000, CreateWorkflowPublishHistoryTable1764167920585, + AddCreatorIdToProjectTable1764276827837, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/project.repository.ts b/packages/@n8n/db/src/repositories/project.repository.ts index b0779831af0..9a21d0a4bfc 100644 --- a/packages/@n8n/db/src/repositories/project.repository.ts +++ b/packages/@n8n/db/src/repositories/project.repository.ts @@ -1,5 +1,4 @@ import { Service } from '@n8n/di'; -import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; @@ -17,7 +16,7 @@ export class ProjectRepository extends Repository { return await em.findOne(Project, { where: { type: 'personal', - projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + creatorId: userId, }, relations: ['projectRelations.role'], }); @@ -29,7 +28,7 @@ export class ProjectRepository extends Repository { return await em.findOneOrFail(Project, { where: { type: 'personal', - projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + creatorId: userId, }, }); } diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 0837778377f..36af7f120a9 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -1,6 +1,6 @@ import type { UsersListFilterDto } from '@n8n/api-types'; import { Service } from '@n8n/di'; -import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; +import { PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; @@ -123,15 +123,23 @@ export class UserRepository extends Repository { entityManager.create(Project, { type: 'personal', name: userWithRole.createPersonalProjectName(), + creatorId: savedUser.id, }), ); + await entityManager.save( entityManager.create(ProjectRelation, { projectId: savedProject.id, userId: savedUser.id, - role: { slug: PROJECT_OWNER_ROLE_SLUG }, + role: { + slug: + userWithRole.role.slug !== 'global:chatUser' + ? PROJECT_OWNER_ROLE_SLUG + : PROJECT_VIEWER_ROLE_SLUG, + }, }), ); + return { user: userWithRole, project: savedProject }; }; if (transactionManager) { @@ -151,8 +159,12 @@ export class UserRepository extends Repository { return await this.findOne({ where: { projectRelations: { - role: { slug: PROJECT_OWNER_ROLE_SLUG }, - project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } }, + role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG]) }, + project: { + type: 'personal', + creatorId: Not(IsNull()), + sharedWorkflows: { workflowId, role: 'workflow:owner' }, + }, }, }, relations: ['role'], @@ -168,8 +180,9 @@ export class UserRepository extends Repository { return await this.findOne({ where: { projectRelations: { - role: { slug: PROJECT_OWNER_ROLE_SLUG }, + role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG]) }, projectId, + project: { type: 'personal', creatorId: Not(IsNull()) }, }, }, relations: ['role'], diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 921bdcfe598..cea5aff1737 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -160,6 +160,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "chatHubAgent:*", "breakingChanges:list", "breakingChanges:*", + "apiKey:manage", + "apiKey:*", "*", ] `; diff --git a/packages/@n8n/permissions/src/__tests__/schemas.test.ts b/packages/@n8n/permissions/src/__tests__/schemas.test.ts index 40d5b53eebd..206b6e67855 100644 --- a/packages/@n8n/permissions/src/__tests__/schemas.test.ts +++ b/packages/@n8n/permissions/src/__tests__/schemas.test.ts @@ -35,6 +35,7 @@ describe('globalRoleSchema', () => { { name: 'valid role: global:owner', value: 'global:owner', expected: true }, { name: 'valid role: global:admin', value: 'global:admin', expected: true }, { name: 'valid role: global:member', value: 'global:member', expected: true }, + { name: 'valid role: global:chatUser', value: 'global:chatUser', expected: true }, { name: 'invalid role', value: 'global:invalid', expected: false }, { name: 'invalid prefix', value: 'invalid:admin', expected: false }, { name: 'empty string', value: '', expected: false }, @@ -50,6 +51,7 @@ describe('assignableGlobalRoleSchema', () => { { name: 'excluded role: global:owner', value: 'global:owner', expected: false }, { name: 'valid role: global:admin', value: 'global:admin', expected: true }, { name: 'valid role: global:member', value: 'global:member', expected: true }, + { name: 'valid role: global:chatUser', value: 'global:chatUser', expected: true }, { name: 'object value', value: {}, expected: false }, ])('should validate $name', ({ value, expected }) => { const result = assignableGlobalRoleSchema.safeParse(value); diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 1ef6aa52e48..8f9d5f83f7e 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -37,6 +37,7 @@ export const RESOURCES = { chatHub: ['manage', 'message'] as const, chatHubAgent: [...DEFAULT_OPERATIONS] as const, breakingChanges: ['list'] as const, + apiKey: ['manage'] as const, } as const; export const API_KEY_RESOURCES = { diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 29f7d0e9cd2..72f4c2a6136 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -67,6 +67,8 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'credential:delete', ]; +export const CHAT_USER_API_KEY_SCOPES: ApiKeyScope[] = []; + /** * This is a bit of a mess, because we are handing out scopes in API keys that are only * valid for the personal project, which is enforced in the public API, because the workflows, @@ -97,9 +99,14 @@ const MAP_ROLE_SCOPES: Record = { 'global:owner': OWNER_API_KEY_SCOPES, 'global:admin': ADMIN_API_KEY_SCOPES, 'global:member': MEMBER_API_KEY_SCOPES, + 'global:chatUser': CHAT_USER_API_KEY_SCOPES, }; export const getApiKeyScopesForRole = (user: AuthPrincipal) => { + if (user.role.slug === 'global:chatUser') { + return []; + } + return [ ...new Set( user.role.scopes diff --git a/packages/@n8n/permissions/src/roles/all-roles.ts b/packages/@n8n/permissions/src/roles/all-roles.ts index 0be8efdd185..30610d57710 100644 --- a/packages/@n8n/permissions/src/roles/all-roles.ts +++ b/packages/@n8n/permissions/src/roles/all-roles.ts @@ -17,6 +17,7 @@ const ROLE_NAMES: Record = { 'global:owner': 'Owner', 'global:admin': 'Admin', 'global:member': 'Member', + 'global:chatUser': 'Chat User', [PROJECT_OWNER_ROLE_SLUG]: 'Project Owner', [PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin', [PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor', @@ -31,6 +32,7 @@ const ROLE_DESCRIPTIONS: Record = { 'global:owner': 'Owner', 'global:admin': 'Admin', 'global:member': 'Member', + 'global:chatUser': 'Chat User', [PROJECT_OWNER_ROLE_SLUG]: 'Project Owner', [PROJECT_ADMIN_ROLE_SLUG]: 'Full control of settings, members, workflows, credentials and executions', diff --git a/packages/@n8n/permissions/src/roles/role-maps.ee.ts b/packages/@n8n/permissions/src/roles/role-maps.ee.ts index 5315b91a3a1..0c9e707f70a 100644 --- a/packages/@n8n/permissions/src/roles/role-maps.ee.ts +++ b/packages/@n8n/permissions/src/roles/role-maps.ee.ts @@ -6,6 +6,7 @@ import { GLOBAL_OWNER_SCOPES, GLOBAL_ADMIN_SCOPES, GLOBAL_MEMBER_SCOPES, + GLOBAL_CHAT_USER_SCOPES, } from './scopes/global-scopes.ee'; import { REGULAR_PROJECT_ADMIN_SCOPES, @@ -29,6 +30,7 @@ export const GLOBAL_SCOPE_MAP: Record = { 'global:owner': GLOBAL_OWNER_SCOPES, 'global:admin': GLOBAL_ADMIN_SCOPES, 'global:member': GLOBAL_MEMBER_SCOPES, + 'global:chatUser': GLOBAL_CHAT_USER_SCOPES, }; export const PROJECT_SCOPE_MAP: Record = { diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index b47c44aab70..6c6910504f4 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -112,6 +112,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'chatHubAgent:delete', 'chatHubAgent:list', 'breakingChanges:list', + 'apiKey:manage', ]; export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); @@ -141,4 +142,14 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'chatHubAgent:update', 'chatHubAgent:delete', 'chatHubAgent:list', + 'apiKey:manage', +]; + +export const GLOBAL_CHAT_USER_SCOPES: Scope[] = [ + 'chatHub:message', + 'chatHubAgent:create', + 'chatHubAgent:read', + 'chatHubAgent:update', + 'chatHubAgent:delete', + 'chatHubAgent:list', ]; diff --git a/packages/@n8n/permissions/src/schemas.ee.ts b/packages/@n8n/permissions/src/schemas.ee.ts index e763b723404..ba2ac186f73 100644 --- a/packages/@n8n/permissions/src/schemas.ee.ts +++ b/packages/@n8n/permissions/src/schemas.ee.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; -import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee'; import { ALL_SCOPES } from './scope-information'; export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']); -export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']); +export const globalRoleSchema = z.enum([ + 'global:owner', + 'global:admin', + 'global:member', + 'global:chatUser', +]); const customGlobalRoleSchema = z .string() @@ -32,7 +36,7 @@ export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'projec export const customProjectRoleSchema = z .string() .nonempty() - .refine((val) => val !== PROJECT_OWNER_ROLE_SLUG && !teamRoleSchema.safeParse(val).success, { + .refine((val) => !systemProjectRoleSchema.safeParse(val).success, { message: 'This global role value is not assignable', }); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts index 6b17d6b8262..c3f533681f2 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts @@ -3,7 +3,7 @@ import { getGlobalScopes } from '../get-global-scopes.ee'; import { createAuthPrincipal } from './utils'; describe('getGlobalScopes', () => { - test.each(['global:owner', 'global:admin', 'global:member'] as const)( + test.each(['global:owner', 'global:admin', 'global:member', 'global:chatUser'] as const)( 'should return correct scopes for %s', (role) => { const scopes = getGlobalScopes(createAuthPrincipal(role)); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 5df4560621e..f37b702507b 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -42,6 +42,7 @@ describe('permissions', () => { chatHub: {}, chatHubAgent: {}, breakingChanges: {}, + apiKey: {}, }); }); it('getResourcePermissions', () => { @@ -77,6 +78,7 @@ describe('permissions', () => { 'folder:create', 'insights:list', 'breakingChanges:list', + 'apiKey:manage', ]; const permissionRecord: PermissionsRecord = { @@ -158,6 +160,9 @@ describe('permissions', () => { breakingChanges: { list: true, }, + apiKey: { + manage: true, + }, }; expect(getResourcePermissions(scopes)).toEqual(permissionRecord); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts index d3129259f25..7ad158e8115 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts @@ -8,6 +8,7 @@ describe('hasGlobalScope', () => { { role: 'global:owner', scope: 'workflow:create', expected: true }, { role: 'global:admin', scope: 'user:delete', expected: true }, { role: 'global:member', scope: 'workflow:read', expected: false }, + { role: 'global:chatUser', scope: 'workflow:read', expected: false }, { role: 'non:existent', scope: 'workflow:read', expected: false }, ] as Array<{ role: GlobalRole; scope: Scope; expected: boolean }>)( '$role with $scope -> $expected', diff --git a/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts index 62df2558b3f..5e015cf413b 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts @@ -6,6 +6,7 @@ describe('rolesWithScope', () => { test.each([ ['workflow:create', ['global:owner', 'global:admin']], ['user:list', ['global:owner', 'global:admin', 'global:member']], + ['chatHub:message', ['global:owner', 'global:admin', 'global:member', 'global:chatUser']], ['invalid:scope', []], ] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => { expect(staticRolesWithScope('global', scope)).toEqual(expected); diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index 40016101d87..1242eb98d1e 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -1,6 +1,15 @@ import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; -import { Body, Delete, Get, Param, Patch, Post, RestController } from '@n8n/decorators'; +import { + Body, + Delete, + Get, + GlobalScope, + Param, + Patch, + Post, + RestController, +} from '@n8n/decorators'; import { getApiKeyScopesForRole } from '@n8n/permissions'; import type { RequestHandler } from 'express'; @@ -27,6 +36,7 @@ export class ApiKeysController { /** * Create an API Key */ + @GlobalScope('apiKey:manage') @Post('/', { middlewares: [isApiEnabledMiddleware] }) async createApiKey( req: AuthenticatedRequest, @@ -52,6 +62,7 @@ export class ApiKeysController { /** * Get API keys */ + @GlobalScope('apiKey:manage') @Get('/', { middlewares: [isApiEnabledMiddleware] }) async getApiKeys(req: AuthenticatedRequest) { const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user); @@ -61,6 +72,7 @@ export class ApiKeysController { /** * Delete an API Key */ + @GlobalScope('apiKey:manage') @Delete('/:id', { middlewares: [isApiEnabledMiddleware] }) async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) { await this.publicApiKeyService.deleteApiKeyForUser(req.user, apiKeyId); @@ -73,6 +85,7 @@ export class ApiKeysController { /** * Patch an API Key */ + @GlobalScope('apiKey:manage') @Patch('/:id', { middlewares: [isApiEnabledMiddleware] }) async updateApiKey( req: AuthenticatedRequest, @@ -89,6 +102,7 @@ export class ApiKeysController { return { success: true }; } + @GlobalScope('apiKey:manage') @Get('/scopes', { middlewares: [isApiEnabledMiddleware] }) async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) { const scopes = getApiKeyScopesForRole(req.user); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 1e043792c46..299f5fdef79 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -33,7 +33,6 @@ import { displayParameter, isINodePropertyCollection, NodeHelpers, - UnexpectedError, } from 'n8n-workflow'; import { CredentialsFinderService } from './credentials-finder.service'; @@ -546,30 +545,29 @@ export class CredentialsService { const { manager: dbManager } = this.credentialsRepository; const result = await dbManager.transaction(async (transactionManager) => { - const project = - projectId === undefined - ? await this.projectRepository.getPersonalProjectForUserOrFail( - user.id, - transactionManager, - ) - : await this.projectService.getProjectWithScope( - user, - projectId, - ['credential:create'], - transactionManager, - ); + if (projectId === undefined) { + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + transactionManager, + ); + // Chat users are not allowed to create credentials even within their personal project, + // so even though we found the project ensure it gets found via expected scope too. + projectId = personalProject.id; + } - if (typeof projectId === 'string' && project === null) { + const project = await this.projectService.getProjectWithScope( + user, + projectId, + ['credential:create'], + transactionManager, + ); + + if (project === null) { throw new BadRequestError( "You don't have the permissions to save the credential in this project.", ); } - // Safe guard in case the personal project does not exist for whatever reason. - if (project === null) { - throw new UnexpectedError('No personal project found'); - } - const savedCredential = await transactionManager.save(newCredential); savedCredential.data = newCredential.data; diff --git a/packages/cli/src/services/__tests__/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts index ae9c7a19b19..034f5431897 100644 --- a/packages/cli/src/services/__tests__/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -1,7 +1,16 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import type { Project } from '@n8n/db'; -import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, Role, User, UserRepository } from '@n8n/db'; +import { Project } from '@n8n/db'; +import { + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, + ProjectRelation, + ProjectRepository, + Role, + User, + UserRepository, +} from '@n8n/db'; +import { PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { v4 as uuid } from 'uuid'; @@ -11,8 +20,8 @@ import { UrlService } from '@/services/url.service'; import { UserService } from '@/services/user.service'; import type { UserManagementMailer } from '@/user-management/email'; +import type { PublicApiKeyService } from '../public-api-key.service'; import type { RoleService } from '../role.service'; -import { type PublicApiKeyService } from '../public-api-key.service'; describe('UserService', () => { const globalConfig = mockInstance(GlobalConfig, { @@ -28,12 +37,16 @@ describe('UserService', () => { const userRepository = mockInstance(UserRepository, { manager, }); + const projectRepository = mockInstance(ProjectRepository, { + manager, + }); const roleService = mock(); const mailer = mock(); const publicApiKeyService = mock(); const userService = new UserService( mock(), userRepository, + projectRepository, mailer, urlService, mock(), @@ -364,6 +377,7 @@ describe('UserService', () => { { role: { slug: 'global:admin' } }, ); expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).not.toHaveBeenCalled(); }); it('removes higher privilege scopes from API tokens of user who is demoted from admin', async () => { @@ -381,6 +395,144 @@ describe('UserService', () => { { role: { slug: 'global:member' } }, ); expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).not.toHaveBeenCalled(); + }); + + it('removes project roles of user who is demoted to chat user from member', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:member'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + const projectId = uuid(); + manager.find.mockResolvedValueOnce([ + Object.assign(new ProjectRelation(), { + userId: user.id, + role: Object.assign(new Role(), { slug: PROJECT_VIEWER_ROLE_SLUG }), + projectId, + }), + ]); + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:chatUser' }); + + expect(manager.delete).toHaveBeenCalledTimes(1); + expect(manager.delete).toHaveBeenCalledWith(ProjectRelation, { + userId: user.id, + projectId, + }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Ensure all their API keys are revoked + expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).toHaveBeenCalledWith(user, manager); + }); + + it('assigns chat user project:viewer on their personal project when demoted from member', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:member'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + manager.find.mockResolvedValueOnce([]); + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:chatUser' }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Ensure all their API keys are revoked + expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).toHaveBeenCalledWith(user, manager); + }); + + it('assigns chat user project:viewer on their personal project when demoted from admin', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:admin'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + manager.find.mockResolvedValueOnce([]); + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:chatUser' }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Ensure all their API keys are revoked. + expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).toHaveBeenCalledWith(user, manager); + }); + + it('assigns chat user project:personalOwner when upgraded to member', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:chatUser'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:member' }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_VIEWER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + ); }); }); }); diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index ec2f17f9bbc..d8a31f7bba9 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -237,7 +237,7 @@ export class ProjectService { const project = await trx.save( Project, - this.projectRepository.create({ ...data, type: 'team' }), + this.projectRepository.create({ ...data, type: 'team', creatorId: adminUser.id }), ); // Link admin diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index f67badd2458..0f152b7dc54 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -1,6 +1,6 @@ import type { CreateApiKeyRequestDto, UnixTimestamp, UpdateApiKeyRequestDto } from '@n8n/api-types'; import type { AuthenticatedRequest, User } from '@n8n/db'; -import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db'; +import { ApiKey, ApiKeyRepository, UserRepository, withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; import type { ApiKeyScope, AuthPrincipal } from '@n8n/permissions'; import { getApiKeyScopesForRole, getOwnerOnlyApiKeyScopes } from '@n8n/permissions'; @@ -73,6 +73,18 @@ export class PublicApiKeyService { await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); } + async deleteAllApiKeysForUser(user: User, tx?: EntityManager) { + return await withTransaction(this.apiKeyRepository.manager, tx, async (em) => { + const userApiKeys = await em.find(ApiKey, { + where: { userId: user.id, audience: API_KEY_AUDIENCE }, + }); + + return await Promise.all( + userApiKeys.map(async (apiKey) => await em.delete(ApiKey, { id: apiKey.id })), + ); + }); + } + async updateApiKeyForUser( user: User, apiKeyId: string, diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 69f46fbbd5c..959923f0ad2 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,11 +1,18 @@ import type { RoleChangeRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; import type { PublicUser } from '@n8n/db'; -import { User, UserRepository } from '@n8n/db'; +import { ProjectRelation, User, UserRepository, ProjectRepository, Not, In } from '@n8n/db'; import { Service } from '@n8n/di'; -import { getGlobalScopes, type AssignableGlobalRole } from '@n8n/permissions'; +import { + getGlobalScopes, + PROJECT_ADMIN_ROLE_SLUG, + PROJECT_OWNER_ROLE_SLUG, + PROJECT_VIEWER_ROLE_SLUG, + type AssignableGlobalRole, +} from '@n8n/permissions'; import type { IUserSettings } from 'n8n-workflow'; -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError, UserError } from 'n8n-workflow'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; @@ -17,13 +24,13 @@ import { UserManagementMailer } from '@/user-management/email'; import { PublicApiKeyService } from './public-api-key.service'; import { RoleService } from './role.service'; -import { GlobalConfig } from '@n8n/config'; @Service() export class UserService { constructor( private readonly logger: Logger, private readonly userRepository: UserRepository, + private readonly projectRepository: ProjectRepository, private readonly mailer: UserManagementMailer, private readonly urlService: UrlService, private readonly eventService: EventService, @@ -276,11 +283,83 @@ export class UserService { return await this.userRepository.manager.transaction(async (trx) => { await trx.update(User, { id: user.id }, { role: { slug: newRole.newRoleName } }); - const adminDowngradedToMember = - user.role.slug === 'global:admin' && newRole.newRoleName === 'global:member'; + const isAdminRole = (roleName: string) => { + return roleName === 'global:admin' || roleName === 'global:owner'; + }; - if (adminDowngradedToMember) { + const isDowngradedToChatUser = + user.role.slug !== 'global:chatUser' && newRole.newRoleName === 'global:chatUser'; + const isUpgradedChatUser = + user.role.slug === 'global:chatUser' && newRole.newRoleName !== 'global:chatUser'; + const isDowngradedAdmin = isAdminRole(user.role.slug) && !isAdminRole(newRole.newRoleName); + + if (isDowngradedToChatUser) { + // Revoke user's project roles in any shared projects they have access to. + const projectRelations = await trx.find(ProjectRelation, { + where: { userId: user.id, role: { slug: Not(PROJECT_OWNER_ROLE_SLUG) } }, + relations: ['role'], + }); + for (const relation of projectRelations) { + if (relation.role.slug === PROJECT_ADMIN_ROLE_SLUG) { + // Ensure there is at least one other admin in the project + const adminCount = await trx.count(ProjectRelation, { + where: { + projectId: relation.projectId, + role: { slug: In([PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG]) }, + userId: Not(user.id), + }, + }); + if (adminCount === 0) { + throw new UserError( + `Cannot downgrade user as they are the only project admin in project "${relation.projectId}".`, + ); + } + } + + await trx.delete(ProjectRelation, { + userId: user.id, + projectId: relation.projectId, + }); + } + + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + trx, + ); + + // Revoke 'project:personalOwner' role on their personal project + // and grant 'project:viewer' role instead. + await trx.update( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Revoke all API keys from chat users + await this.publicApiKeyService.deleteAllApiKeysForUser(user, trx); + } else if (isDowngradedAdmin) { await this.publicApiKeyService.removeOwnerOnlyScopesFromApiKeys(user, trx); + } else if (isUpgradedChatUser) { + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + trx, + ); + + // Revoke previous 'project:viewer' role on their personal project + // and grant 'project:personalOwner' role instead. + await trx.update( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_VIEWER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + ); } }); } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index f949d25d2cf..d9c683b4702 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -352,10 +352,10 @@ export class WorkflowService { // do not update active fields ]; - const updatePayload: QueryDeepPartialEntity = pick( + const updatePayload = pick( workflowUpdateData, fieldsToUpdate, - ); + ) as QueryDeepPartialEntity; // Save the workflow to history first, so we can retrieve the complete version object for the update if (versionChanged) { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index fd3417a935b..f528b645ea2 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -150,30 +150,34 @@ export class WorkflowsController { const { manager: dbManager } = this.projectRepository; - let project: Project | null; + let project: Project | null = null; const savedWorkflow = await dbManager.transaction(async (transactionManager) => { - const { projectId, parentFolderId } = req.body; - project = - projectId === undefined - ? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager) - : await this.projectService.getProjectWithScope( - req.user, - projectId, - ['workflow:create'], - transactionManager, - ); + const { parentFolderId } = req.body; + let { projectId } = req.body; - if (typeof projectId === 'string' && project === null) { + if (projectId === undefined) { + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + req.user.id, + transactionManager, + ); + // Chat users are not allowed to create workflows even within their personal project, + // so even though we found the project ensure it gets found via expected scope too. + projectId = personalProject.id; + } + + project = await this.projectService.getProjectWithScope( + req.user, + projectId, + ['workflow:create'], + transactionManager, + ); + + if (project === null) { throw new BadRequestError( "You don't have the permissions to save the workflow in this project.", ); } - // Safe guard in case the personal project does not exist for whatever reason. - if (project === null) { - throw new UnexpectedError('No personal project found'); - } - const workflow = await transactionManager.save(newWorkflow); if (parentFolderId) { diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 726e13e15f4..7647c663618 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -109,6 +109,23 @@ describe('POST /credentials', () => { "You don't have the permissions to save the credential in this project.", ); }); + + test('chat users cannot create credentials', async () => { + const chatUser = await createUser({ role: { slug: 'global:chatUser' } }); + const chatUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + chatUser.id, + ); + + const response = await testServer + .authAgentFor(chatUser) + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: chatUserPersonalProject.id }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); }); // ---------------------------------------- diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index d413d3d72ab..8cfc17b4a1d 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -30,7 +30,13 @@ import { shareCredentialWithProjects, shareCredentialWithUsers, } from '../shared/db/credentials'; -import { createAdmin, createManyUsers, createMember, createOwner } from '../shared/db/users'; +import { + createAdmin, + createChatUser, + createManyUsers, + createMember, + createOwner, +} from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import { initCredentialsTypes, setupTestServer } from '../shared/utils'; @@ -47,6 +53,7 @@ let secondMember: User; let ownerPersonalProject: Project; let memberPersonalProject: Project; +let teamProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; @@ -130,6 +137,28 @@ describe('GET /credentials', () => { expect(member1Credential.id).toBe(savedCredential1.id); }); + test('should return only own creds for chat user', async () => { + const [chatUser1, chatUser2] = await createManyUsers(2, { + role: { slug: 'global:chatUser' }, + }); + + const [savedCredential1] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: chatUser1, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: chatUser2, role: 'credential:owner' }), + ]); + + const response = await testServer.authAgentFor(chatUser1).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); // member retrieved only own cred + + const [chatUser1Credential] = response.body.data; + + validateMainCredentialData(chatUser1Credential); + expect(chatUser1Credential.data).toBeUndefined(); + expect(chatUser1Credential.id).toBe(savedCredential1.id); + }); + test('should return scopes when ?includeScopes=true', async () => { const [member1, member2] = await createManyUsers(2, { role: { slug: 'global:member' }, @@ -936,6 +965,49 @@ describe('POST /credentials', () => { }); }); + test('should fail when viewer user tries to create credential in team project', async () => { + const viewer = await createMember(); + teamProject = await createTeamProject(undefined, admin); + await linkUserToProject(viewer, teamProject, 'project:viewer'); + + const response = await testServer + .authAgentFor(viewer) + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: teamProject.id }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); + + test('should allow viewer user to create credential in their personal project', async () => { + const viewer = await createMember(); + teamProject = await createTeamProject(undefined, admin); + await linkUserToProject(viewer, teamProject, 'project:viewer'); + + const response = await testServer + .authAgentFor(viewer) + .post('/credentials') + .send({ ...randomCredentialPayload() }); + + expect(response.statusCode).toBe(200); + }); + + test('should fail when chat user tries to create credential in their personal project', async () => { + const chatUser = await createChatUser(); + + const response = await testServer + .authAgentFor(chatUser) + .post('/credentials') + .send({ ...randomCredentialPayload() }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); + test('should fail when member tries to create credential with isGlobal=true', async () => { const response = await authMemberAgent .post('/credentials') @@ -973,6 +1045,19 @@ describe('POST /credentials', () => { expect(credential.isGlobal).toBe(false); }); + test('should not allow chat user to create credential with isGlobal=false', async () => { + const chatUser = await createChatUser(); + const response = await testServer + .authAgentFor(chatUser) + .post('/credentials') + .send({ ...randomCredentialPayload(), isGlobal: false }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); + test('should allow member to create credential without passing isGlobal', async () => { const payload = randomCredentialPayload(); delete payload.isGlobal; @@ -986,6 +1071,18 @@ describe('POST /credentials', () => { }); expect(credential.isGlobal).toBe(false); }); + + test('should not allow chat user to create credential without passing isGlobal', async () => { + const chatUser = await createChatUser(); + const payload = randomCredentialPayload(); + delete payload.isGlobal; + + const response = await testServer.authAgentFor(chatUser).post('/credentials').send(payload); + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); }); describe('DELETE /credentials/:id', () => { diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts index c05f5da159f..09073d1c9ac 100644 --- a/packages/cli/test/integration/environments/source-control.service.test.ts +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -4,6 +4,7 @@ import { CredentialsEntity, type Folder, GLOBAL_ADMIN_ROLE, + GLOBAL_CHAT_USER_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, Project, @@ -147,6 +148,7 @@ describe('SourceControlService', () => { let globalAdmin: User; let globalOwner: User; let globalMember: User; + let globalChatUser: User; let projectAdmin: User; let projectA: Project; @@ -201,10 +203,11 @@ describe('SourceControlService', () => { /* Set up test conditions: - 4 users: + 5 users: globalAdmin globalOwner globalMember + globalChatUser projectAdmin 2 Team projects: @@ -220,11 +223,12 @@ describe('SourceControlService', () => { 1. Workflow moved in git to other project */ - [globalAdmin, globalOwner, globalMember, projectAdmin] = await Promise.all([ + [globalAdmin, globalOwner, globalMember, projectAdmin, globalChatUser] = await Promise.all([ createUser({ role: GLOBAL_ADMIN_ROLE }), createUser({ role: GLOBAL_OWNER_ROLE }), createUser({ role: GLOBAL_MEMBER_ROLE }), createUser({ role: GLOBAL_MEMBER_ROLE }), + createUser({ role: GLOBAL_CHAT_USER_ROLE }), ]); [projectA, projectB] = await Promise.all([ @@ -678,6 +682,18 @@ describe('SourceControlService', () => { }); }); + describe('global:chatUser user', () => { + it('should see nothing', async () => { + const result = await service.getStatus(globalChatUser, { + direction: 'push', + preferLocalVersion: true, + verbose: false, + }); + + expect(result).toBeEmptyArray(); + }); + }); + describe('project:Admin user', () => { it('should see only workflows in correct scope', async () => { const result = await service.getStatus(projectAdmin, { diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 29e9473447e..403c86951ac 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -79,6 +79,16 @@ test('Member role should not be able to access ldap routes', async () => { await authAgent.get('/ldap/sync').expect(403); }); +test('Chat user role should not be able to access ldap routes', async () => { + const chatUser = await createUser({ role: { slug: 'global:chatUser' } }); + const authAgent = testServer.authAgentFor(chatUser); + await authAgent.get('/ldap/config').expect(403); + await authAgent.put('/ldap/config').expect(403); + await authAgent.post('/ldap/test-connection').expect(403); + await authAgent.post('/ldap/sync').expect(403); + await authAgent.get('/ldap/sync').expect(403); +}); + describe('PUT /ldap/config', () => { test('route should validate payload', async () => { const invalidValuePayload = { diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 469ddb80d58..df0b2b35c8c 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -276,6 +276,148 @@ describe('Member', () => { }); }); +describe('Chat User', () => { + let member: User; + let authMemberAgent: SuperAgentTest; + + beforeEach(async () => { + member = await createUser({ + password: memberPassword, + role: { slug: 'global:chatUser' }, + }); + authMemberAgent = testServer.authAgentFor(member); + await utils.setInstanceOwnerSetUp(true); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + for (const validPayload of getValidPatchMePayloads('chatUser')) { + const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); + + const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } = + response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email.toLowerCase()); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(isPending).toBe(false); + expect(role).toBe('global:chatUser'); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); + + expect(storedMember.email).toBe(validPayload.email.toLowerCase()); + expect(storedMember.firstName).toBe(validPayload.firstName); + expect(storedMember.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); + } + }); + + test('PATCH /me should fail with invalid inputs', async () => { + for (const invalidPayload of getInvalidPatchMePayloads('chatUser')) { + const response = await authMemberAgent.patch('/me').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.email).toBe(member.email); + expect(storedMember.firstName).toBe(member.firstName); + expect(storedMember.lastName).toBe(member.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedMember.id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); + } + }); + + test('PATCH /me should fail when changing email without currentPassword', async () => { + const payloadWithoutPassword = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + }; + + const response = await authMemberAgent.patch('/me').send(payloadWithoutPassword); + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('Current password is required to change email'); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.email).toBe(member.email); + }); + + test('PATCH /me should fail when changing email with wrong currentPassword', async () => { + const payloadWithWrongPassword = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + currentPassword: 'WrongPassword123', + }; + + const response = await authMemberAgent.patch('/me').send(payloadWithWrongPassword); + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain( + 'Unable to update profile. Please check your credentials and try again.', + ); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.email).toBe(member.email); + }); + + test('PATCH /me/password should succeed with valid inputs', async () => { + const validPayload = { + currentPassword: memberPassword, + newPassword: randomValidPassword(), + }; + + const response = await authMemberAgent.patch('/me/password').send(validPayload); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.password).not.toBe(member.password); + expect(storedMember.password).not.toBe(validPayload.newPassword); + }); + + test('PATCH /me/password should fail with invalid inputs', async () => { + for (const payload of INVALID_PASSWORD_PAYLOADS) { + const response = await authMemberAgent.patch('/me/password').send(payload); + expect([400, 500].includes(response.statusCode)).toBe(true); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + + if (payload.newPassword) { + expect(storedMember.password).not.toBe(payload.newPassword); + } + if (payload.currentPassword) { + expect(storedMember.password).not.toBe(payload.currentPassword); + } + } + }); + + test('POST /me/survey should succeed with valid inputs', async () => { + const validPayloads = [SURVEY, EMPTY_SURVEY]; + + for (const validPayload of validPayloads) { + const response = await authMemberAgent.post('/me/survey').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const { personalizationAnswers: storedAnswers } = await Container.get( + UserRepository, + ).findOneByOrFail({}); + + expect(storedAnswers).toEqual(validPayload); + } + }); +}); + describe('Owner', () => { test('PATCH /me should succeed with valid inputs', async () => { const owner = await createUser({ @@ -352,7 +494,7 @@ const EMPTY_SURVEY: IPersonalizationSurveyAnswersV4 = { personalization_survey_n8n_version: '1.0.0', }; -function getValidPatchMePayloads(userType: 'owner' | 'member') { +function getValidPatchMePayloads(userType: 'owner' | 'member' | 'chatUser') { return VALID_PATCH_ME_PAYLOADS.map((payload) => { if (userType === 'owner') { return { ...payload, currentPassword: ownerPassword }; @@ -361,7 +503,7 @@ function getValidPatchMePayloads(userType: 'owner' | 'member') { }); } -function getInvalidPatchMePayloads(userType: 'owner' | 'member') { +function getInvalidPatchMePayloads(userType: 'owner' | 'member' | 'chatUser') { return INVALID_PATCH_ME_PAYLOADS.map((payload) => { if (userType === 'owner') { return { ...payload, currentPassword: ownerPassword }; diff --git a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts index 3cd452a8fce..38d76e4babe 100644 --- a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts +++ b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts @@ -322,6 +322,33 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { expect(formerAdminApiKey.scopes).not.toContain(ownerScope); } }); + + it('should remove all API keys when user downgrading to chatUser', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + + const owner = await createOwnerWithApiKey({ scopes: ['user:changeRole'] }); + const admin = await createAdminWithApiKey(); + const payload = { newRoleName: 'global:chatUser' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${admin.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(204); + + const formerAdminApiKey = await apiKeyRepository.findOneBy({ userId: admin.id }); + expect(formerAdminApiKey).toBeNull(); + }); }); describe('DELETE /users/:id', () => { @@ -1069,6 +1096,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { name: 'some-project', icon: null, type: 'team', + creatorId: owner.id, description: null, id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f0b675c8e27..e73423f18a9 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -143,6 +143,7 @@ describe('Projects in Public API', () => { name: 'some-project', icon: null, type: 'team', + creatorId: owner.id, description: null, id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index a3f684fb260..12831513dcc 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -3,6 +3,7 @@ import { AuthIdentity, AuthIdentityRepository, GLOBAL_ADMIN_ROLE, + GLOBAL_CHAT_USER_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type Role, @@ -154,6 +155,10 @@ export async function createAdmin() { return await createUser({ role: GLOBAL_ADMIN_ROLE }); } +export async function createChatUser() { + return await createUser({ role: GLOBAL_CHAT_USER_ROLE }); +} + export async function createUserShell(role: Role): Promise { const shell: DeepPartial = { role }; diff --git a/packages/cli/test/integration/user.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts index f4328ac3724..7a6f5f27d68 100644 --- a/packages/cli/test/integration/user.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -2,7 +2,7 @@ import { randomEmail, testDb } from '@n8n/backend-test-utils'; import { ProjectRelationRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import { createAdmin, createMember, createOwner } from './shared/db/users'; +import { createAdmin, createChatUser, createMember, createOwner } from './shared/db/users'; describe('UserRepository', () => { let userRepository: UserRepository; @@ -28,6 +28,7 @@ describe('UserRepository', () => { createMember(), createMember(), createMember(), + createChatUser(), ]); const usersByRole = await userRepository.countUsersByRole(); @@ -36,6 +37,7 @@ describe('UserRepository', () => { 'global:admin': 2, 'global:member': 3, 'global:owner': 1, + 'global:chatUser': 1, }); }); }); @@ -59,5 +61,24 @@ describe('UserRepository', () => { expect(projectRelation.project.id).toBe(project.id); }); + + test('should create personal project for a chat user', async () => { + const { user, project } = await userRepository.createUserWithProject({ + email: randomEmail(), + role: { slug: 'global:chatUser' }, + }); + + const projectRelation = await Container.get(ProjectRelationRepository).findOneOrFail({ + where: { + userId: user.id, + project: { + type: 'personal', + }, + }, + relations: ['project'], + }); + + expect(projectRelation.project.id).toBe(project.id); + }); }); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index ee6f5e33646..06f73288726 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -43,7 +43,7 @@ import { createFolder } from '@test-integration/db/folders'; import { saveCredential } from '../shared/db/credentials'; import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles'; import { assignTagToWorkflow, createTag } from '../shared/db/tags'; -import { createManyUsers, createMember, createOwner } from '../shared/db/users'; +import { createChatUser, createManyUsers, createMember, createOwner } from '../shared/db/users'; import { createWorkflowHistoryItem } from '../shared/db/workflow-history'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -55,7 +55,6 @@ let anotherMember: User; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; - const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], enabledFeatures: ['feat:sharing'], @@ -445,6 +444,29 @@ describe('POST /workflows', () => { }); }); + test('does not create the workflow in a personal project if the user is chat user', async () => { + // + // ARRANGE + // + const chatUser = await createChatUser(); + const workflow = makeWorkflow(); + + // + // ACT + // + await testServer + .authAgentFor(chatUser) + .post('/workflows') + .send({ ...workflow }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + test('create link workflow with folder if one is provided', async () => { // // ARRANGE diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index da098723645..a30c8627ed5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -198,6 +198,7 @@ "auth.roles.member": "Member", "auth.roles.admin": "@:_reusableBaseText.roles.admin", "auth.roles.owner": "Owner", + "auth.roles.chatUser": "Chat user", "auth.agreement.label": "I want to receive security and product updates", "auth.setup.next": "Next", "auth.setup.settingUpOwnerError": "Problem setting up owner", @@ -389,7 +390,8 @@ "chatHub.tools.editor.title": "Add Tools", "chatHub.tools.editor.credential": "Credential", "chatHub.tools.editor.credential.placeholder": "Select credential…", - "chatHub.tools.editor.credential.new": "Create New", + "chatHub.tools.editor.credential.createNew": "Create New", + "chatHub.tools.editor.credential.createNew.permissionDenied": "Your current role does not allow you to create credentials", "chatHub.tools.editor.selectedCount": "{count} tool selected | {count} tools selected", "chatHub.tools.editor.confirm": "Confirm", "chatHub.tools.editor.cancel": "Cancel", @@ -399,9 +401,9 @@ "chatHub.credentials.selector.title": "Select {provider} credential", "chatHub.credentials.selector.chooseOrCreate": "Choose or create a credential for {provider}", "chatHub.credentials.selector.createNew": "Create new", + "chatHub.credentials.selector.createNew.permissionDenied": "Your current role does not allow you to create credentials", "chatHub.credentials.selector.confirm": "Select", "chatHub.credentials.selector.cancel": "Cancel", - "chatHub.credentials.selector.deleteButton": "Delete credential", "chatHub.message.actions.readAloud": "Read aloud", "chatHub.message.actions.stopReading": "Stop reading", "chatHub.message.actions.edit": "Edit", @@ -1605,6 +1607,7 @@ "nodeCreator.preBuiltAgents.title": "Pre-built agents", "nodeCreator.preBuiltAgents.description": "Get started faster with ready to go agents", "nodeCredentials.createNew": "Create new credential", + "nodeCredentials.createNew.permissionDenied": "Your current role does not allow you to create credentials", "nodeCredentials.credentialFor": "Credential for {credentialType}", "nodeCredentials.credentialsLabel": "Credential to connect with", "nodeCredentials.issues": "Issues", @@ -1614,6 +1617,9 @@ "nodeCredentials.showMessage.title": "Node credential updated", "nodeCredentials.autoAssigned.message": "Added this credential to {count} other node(s)", "nodeCredentials.updateCredential": "Update Credential", + "nodeCredentials.updateCredential.permissionDenied": "Your current role does not allow you to update credentials", + "nodeCredentials.deleteCredential": "Delete Credential", + "nodeCredentials.deleteCredential.permissionDenied": "Your current role does not allow you to delete credentials", "nodeErrorView.cause": "Cause", "nodeErrorView.copyToClipboard": "Copy to Clipboard", "nodeErrorView.copyToClipboard.tooltip": "Copy error details for debugging. Copied data may contain sensitive information. Proceed with caution when sharing.", @@ -2409,6 +2415,7 @@ "settings.personal.personalSettingsUpdatedError": "Problem updating your details", "settings.personal.role.tooltip.default": "Default role for new users", "settings.personal.role.tooltip.member": "Create and manage own workflows and credentials", + "settings.personal.role.tooltip.chatUser": "Access to Chat feature only", "settings.personal.role.tooltip.admin": "Full access to manage workflows,tags, credentials, projects, users and more", "settings.personal.role.tooltip.owner": "Manage everything{cloudAccess}", "settings.personal.role.tooltip.cloud": " and access Cloud dashboard", @@ -2480,7 +2487,11 @@ "settings.users.advancedPermissions.warning": "{link} to unlock the ability to create additional admin users", "settings.users.userRoleUpdated": "Changes saved", "settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}", - "settings.users.userRoleUpdatedError": "Unable to updated role", + "settings.users.userRoleUpdatedError": "Unable to update role", + "settings.users.userRoleUpdated.confirm.message": "Are you sure you want to change user to a {role}? This will revoke their current project roles.", + "settings.users.userRoleUpdated.confirm.title": "Update role for {user}?", + "settings.users.userRoleUpdated.confirm.button": "Yes, update and revoke", + "settings.users.userRoleUpdated.cancel.button": "Cancel", "settings.users.table.update.error": "Failed to update table", "settings.users.table.header.user": "@:_reusableBaseText.user", "settings.users.table.header.accountType": "Account Type", @@ -2491,6 +2502,7 @@ "settings.users.table.row.deleteUser": "Remove user", "settings.users.table.row.role.description.admin": "Full access to all workflows, credentials, projects, users and more", "settings.users.table.row.role.description.member": "Manage and create own workflows and credentials", + "settings.users.table.row.role.description.chatUser": "Can use Chat but cannot create or view workflows or access other features", "settings.users.table.row.2fa.enabled": "@:_reusableBaseText.enabled", "settings.users.table.row.2fa.disabled": "@:_reusableBaseText.disabled", "settings.projectRoles": "Project roles", diff --git a/packages/frontend/editor-ui/src/app/init.ts b/packages/frontend/editor-ui/src/app/init.ts index b3b0a694356..139e86693fe 100644 --- a/packages/frontend/editor-ui/src/app/init.ts +++ b/packages/frontend/editor-ui/src/app/init.ts @@ -28,6 +28,7 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { h } from 'vue'; import { useRolesStore } from '@/app/stores/roles.store'; import { useDataTableStore } from '@/features/core/dataTable/dataTable.store'; +import { hasPermission } from '@/app/utils/rbac/permissions'; export const state = { initialized: false, @@ -173,7 +174,10 @@ export async function initializeAuthenticatedFeatures( }); } - if (settingsStore.isDataTableFeatureEnabled) { + if ( + settingsStore.isDataTableFeatureEnabled && + hasPermission(['rbac'], { rbac: { scope: 'dataTable:list' } }) + ) { void dataTableStore .fetchDataTableSize() .then(({ quotaStatus }) => { diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index a0090104f4d..3ee612ec785 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -686,7 +686,12 @@ export const routes: RouteRecordRaw[] = [ settingsView: SettingsApiView, }, meta: { - middleware: ['authenticated'], + middleware: ['authenticated', 'rbac'], + middlewareOptions: { + rbac: { + scope: ['apiKey:manage'], + }, + }, telemetry: { pageCategory: 'settings', getProperties() { diff --git a/packages/frontend/editor-ui/src/app/stores/rbac.store.ts b/packages/frontend/editor-ui/src/app/stores/rbac.store.ts index 7eb28b01c4a..93abc502349 100644 --- a/packages/frontend/editor-ui/src/app/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/rbac.store.ts @@ -48,6 +48,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { chatHub: {}, chatHubAgent: {}, breakingChanges: {}, + apiKey: {}, }); function addGlobalRole(role: Role) { diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.ts index b4f4f6491c7..b50eb880060 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.ts @@ -401,7 +401,7 @@ export const useUIStore = defineStore(STORES.UI, () => { const items: IMenuItem[] = []; Object.entries(registeredSettingsPages.value).forEach(([moduleName, moduleItems]) => { if (settingsStore.isModuleActive(moduleName)) { - items.push(...moduleItems.map((item) => ({ ...item, available: true }))); + items.push(...moduleItems.map((item) => ({ available: true, ...item }))); } }); return items; diff --git a/packages/frontend/editor-ui/src/app/utils/rbac/checks/hasRole.test.ts b/packages/frontend/editor-ui/src/app/utils/rbac/checks/hasRole.test.ts index cadee68992f..8966c57c769 100644 --- a/packages/frontend/editor-ui/src/app/utils/rbac/checks/hasRole.test.ts +++ b/packages/frontend/editor-ui/src/app/utils/rbac/checks/hasRole.test.ts @@ -19,6 +19,17 @@ describe('Checks', () => { expect(hasRole([ROLE.Owner])).toBe(true); }); + it('should return true if the user has specified chat user role', () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: { + isDefaultUser: false, + role: ROLE.ChatUser, + }, + } as ReturnType); + + expect(hasRole([ROLE.ChatUser])).toBe(true); + }); + it('should return false if the user does not have the specified role', () => { vi.mocked(useUsersStore).mockReturnValue({ currentUser: { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue index a76af0a05f2..44e303b7620 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue @@ -1,4 +1,5 @@