diff --git a/CHANGELOG.md b/CHANGELOG.md index 18175ee50e..4914da5eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,55 @@ +# [1.82.0](https://github.com/n8n-io/n8n/compare/n8n@1.81.0...n8n@1.82.0) (2025-03-03) + + +### Bug Fixes + +* **Call n8n Workflow Tool Node:** Support concurrent invocations of the tool ([#13526](https://github.com/n8n-io/n8n/issues/13526)) ([5334661](https://github.com/n8n-io/n8n/commit/5334661b76909f48aa4e45af889e6180c025eed6)) +* **core:** Gracefully handle missing tasks metadata ([#13632](https://github.com/n8n-io/n8n/issues/13632)) ([999fb81](https://github.com/n8n-io/n8n/commit/999fb8174ae6bb34354cb8c6f85f769cb64e8ae4)) +* **core:** Remove `index.html` caching entirely ([#13563](https://github.com/n8n-io/n8n/issues/13563)) ([afba8f9](https://github.com/n8n-io/n8n/commit/afba8f9ff89054d54e1cf70070ae5710bc9ddd37)) +* **editor:** Add workflows to the store when fetching current page ([#13583](https://github.com/n8n-io/n8n/issues/13583)) ([c4f3293](https://github.com/n8n-io/n8n/commit/c4f329377828d80a54b71f5733ea7d9b4ee91f48)) +* **editor:** Ai 672 minor UI fixes on evaluation creation ([#13461](https://github.com/n8n-io/n8n/issues/13461)) ([b791677](https://github.com/n8n-io/n8n/commit/b791677ffa8c82161c4c40b65bc62d93f2e7bc9e)) +* **editor:** Ai 675 minor tweaks to tests list ([#13467](https://github.com/n8n-io/n8n/issues/13467)) ([5ad950f](https://github.com/n8n-io/n8n/commit/5ad950f60371546414ff17eb31171f2259e70f57)) +* **editor:** Don't show duplicate logs when tree is deeply nested ([#13537](https://github.com/n8n-io/n8n/issues/13537)) ([d550382](https://github.com/n8n-io/n8n/commit/d550382a4a43c54cae47e9071236aa18efe38a5d)) +* **editor:** Fix browser crash with large execution result ([#13580](https://github.com/n8n-io/n8n/issues/13580)) ([1c8c7e3](https://github.com/n8n-io/n8n/commit/1c8c7e34f9d2c8363c441aeb8c562ac91088a687)) +* **editor:** Fix github star button layout ([#13630](https://github.com/n8n-io/n8n/issues/13630)) ([139b5b3](https://github.com/n8n-io/n8n/commit/139b5b378daba6df18639eeb4f326edce7752e11)) +* **editor:** Fix icon color on 'Call n8n Workflow Tool' node ([#13568](https://github.com/n8n-io/n8n/issues/13568)) ([90d0943](https://github.com/n8n-io/n8n/commit/90d09431af97570a3a6adfb0470a18681af28001)) +* **editor:** Fix icon spacing in accordion title ([#13539](https://github.com/n8n-io/n8n/issues/13539)) ([ebaaf0e](https://github.com/n8n-io/n8n/commit/ebaaf0e3d9602052f76f61b90fb073e390896cea)) +* **editor:** Fix keyboard shortcuts no longer working after editing sticky note ([#13502](https://github.com/n8n-io/n8n/issues/13502)) ([ab41fc3](https://github.com/n8n-io/n8n/commit/ab41fc3fb5f15e9c7ce7279b46cec90a511d0e0d)) +* **editor:** Fix workflows list status filter ([#13621](https://github.com/n8n-io/n8n/issues/13621)) ([4067fb0](https://github.com/n8n-io/n8n/commit/4067fb0b12d242c795c6598df6c4090d48cec7b1)) +* **editor:** Hide fromAI button in old workflow tool ([#13552](https://github.com/n8n-io/n8n/issues/13552)) ([6ef8d34](https://github.com/n8n-io/n8n/commit/6ef8d34f969ddb9e80b82dc50b38698249089af2)) +* **editor:** Parse out nodeType ([#13474](https://github.com/n8n-io/n8n/issues/13474)) ([1cd13b6](https://github.com/n8n-io/n8n/commit/1cd13b639efcfabf183740bb6634023c66d5ce99)) +* **editor:** Show dropdown scrollbars only when appropriate ([#13562](https://github.com/n8n-io/n8n/issues/13562)) ([615a42a](https://github.com/n8n-io/n8n/commit/615a42afd52d0d95dd30ed9aa231b9921e0708fe)) +* **editor:** Show JSON full-screen Editor Window in Full Height ([#13350](https://github.com/n8n-io/n8n/issues/13350)) ([46dcce3](https://github.com/n8n-io/n8n/commit/46dcce341fbfa1c2a44a08f3dc93f1f8f16808c8)) +* **editor:** Show scrollbar in Element UI popup ([#13259](https://github.com/n8n-io/n8n/issues/13259)) ([c021a7e](https://github.com/n8n-io/n8n/commit/c021a7e4b2daccc59541bab25c1447339dd68c09)) +* **editor:** Undo keybinding changes related to window focus/blur events ([#13559](https://github.com/n8n-io/n8n/issues/13559)) ([6ddcc1f](https://github.com/n8n-io/n8n/commit/6ddcc1f8c93f86b0d111cae1b24518d621d8fe84)) +* **Odoo Node:** Model and fields dynamic fetching errors ([#13511](https://github.com/n8n-io/n8n/issues/13511)) ([294f019](https://github.com/n8n-io/n8n/commit/294f0194145ca4139d9d9cea0729bf83d0871c94)) +* **Postgres Node:** Accommodate null values in query parameters for expressions ([#13544](https://github.com/n8n-io/n8n/issues/13544)) ([6c266ac](https://github.com/n8n-io/n8n/commit/6c266acced95500148532b4fc015fe5d9587db76)) +* **QuickBooks Online Node:** Add qty to quickbooks invoice line details ([#13602](https://github.com/n8n-io/n8n/issues/13602)) ([7c4e2f0](https://github.com/n8n-io/n8n/commit/7c4e2f014c0b38935a4d661646e773ad26fc97e1)) +* **seven Node:** Remove obsolete options and fix typos ([#13122](https://github.com/n8n-io/n8n/issues/13122)) ([d02c8b0](https://github.com/n8n-io/n8n/commit/d02c8b0d7dbd4144c954a66aa0e78e43122b6e9a)) +* **Switch Node:** Fix an issue in ordering rules in Switch Node ([#13476](https://github.com/n8n-io/n8n/issues/13476)) ([0fb6607](https://github.com/n8n-io/n8n/commit/0fb66076ba6120a7cb2401102ff8d1d6220ae106)) + + +### Features + +* **Anthropic Chat Model Node:** Fetch models dynamically & support thinking ([#13543](https://github.com/n8n-io/n8n/issues/13543)) ([461df37](https://github.com/n8n-io/n8n/commit/461df371f76b9dee9916a985a2bd2197facbcf6b)) +* **Azure Storage Node:** New node ([#12536](https://github.com/n8n-io/n8n/issues/12536)) ([727f6f3](https://github.com/n8n-io/n8n/commit/727f6f3c0e5cef2d0cd4cd1ef1c6fa8f4d3f69ec)) +* **core:** Add metric for active workflow count ([#13420](https://github.com/n8n-io/n8n/issues/13420)) ([3aa679e](https://github.com/n8n-io/n8n/commit/3aa679e4ac411d0d34e039fa6c43bc98f2e3670f)) +* **core:** Fix partial workflow execution with specific trigger data ([#13505](https://github.com/n8n-io/n8n/issues/13505)) ([9029dac](https://github.com/n8n-io/n8n/commit/9029dace5c682e4b5df4f18f2f51098dce6436e5)) +* **core:** Make Tools Agent the default Agent type, deprecate other agent types ([#13459](https://github.com/n8n-io/n8n/issues/13459)) ([a60d106](https://github.com/n8n-io/n8n/commit/a60d106ebb4fb71e80f90a17965d7fb79d7806c6)) +* **core:** Support executing single nodes not part of a graph as a partial execution ([#13529](https://github.com/n8n-io/n8n/issues/13529)) ([8a34f02](https://github.com/n8n-io/n8n/commit/8a34f027c531f0d37fc8088c13d7e289cd8897ce)) +* **editor:** Add functionality to create folders ([#13473](https://github.com/n8n-io/n8n/issues/13473)) ([2cb9d9e](https://github.com/n8n-io/n8n/commit/2cb9d9e29fc961a417d06c1449b79d4a0a66658e)) +* **editor:** Automatically tidy up workflows ([#13471](https://github.com/n8n-io/n8n/issues/13471)) ([f381a24](https://github.com/n8n-io/n8n/commit/f381a24145271f4df4fa5c9345bb12c984f6e1fc)) +* **editor:** Indicate dirty nodes with yellow borders/connectors on canvas ([#13040](https://github.com/n8n-io/n8n/issues/13040)) ([75493ef](https://github.com/n8n-io/n8n/commit/75493ef6ef4ee47d0ccf217cd5c2e58754f60c12)) +* **editor:** Rename 'In-Memory Vector Store' to 'Simple Vector Store' ([#13472](https://github.com/n8n-io/n8n/issues/13472)) ([35c00d0](https://github.com/n8n-io/n8n/commit/35c00d0c846e8a1e214aea3690ea60ff80d03eed)) +* **editor:** Rename 'Window Buffer Memory' to 'Simple Memory' ([#13477](https://github.com/n8n-io/n8n/issues/13477)) ([819fc2d](https://github.com/n8n-io/n8n/commit/819fc2da63ce7f06d4702bce698d382eb64c45a3)) +* Hackmation - automatically switch to expression mode ([#13213](https://github.com/n8n-io/n8n/issues/13213)) ([6953b0d](https://github.com/n8n-io/n8n/commit/6953b0d53a28448022c9de0a2f6294c9390a3b48)) +* **n8n Form Trigger Node, Chat Trigger Node:** Allow to customize form and chat css ([#13506](https://github.com/n8n-io/n8n/issues/13506)) ([289041e](https://github.com/n8n-io/n8n/commit/289041e997eedb660356cdbd259660b7c3117194)) +* **n8n Vertica credentials only Node:** New node ([#12256](https://github.com/n8n-io/n8n/issues/12256)) ([d3fe3de](https://github.com/n8n-io/n8n/commit/d3fe3dea32207dfdb2a43db0def96466a31daa66)) +* Update AWS credential to support more regions ([#13524](https://github.com/n8n-io/n8n/issues/13524)) ([b50658c](https://github.com/n8n-io/n8n/commit/b50658cbc64c0a6fc000b11dca0cca49cc707471)) +* WhatsApp Business Cloud Node - new operation sendAndWait ([#12941](https://github.com/n8n-io/n8n/issues/12941)) ([97defb3](https://github.com/n8n-io/n8n/commit/97defb3a833bb269a4a3fc573a8e250a0d0e0deb)) + + + # [1.81.0](https://github.com/n8n-io/n8n/compare/n8n@1.80.0...n8n@1.81.0) (2025-02-24) diff --git a/cypress/e2e/18-user-management.cy.ts b/cypress/e2e/18-user-management.cy.ts index d4eb5841cf..6f97597023 100644 --- a/cypress/e2e/18-user-management.cy.ts +++ b/cypress/e2e/18-user-management.cy.ts @@ -36,7 +36,7 @@ describe('User Management', { disableAutoLogin: true }, () => { it('should login and logout', () => { cy.visit('/'); - cy.get('input[name="email"]').type(INSTANCE_OWNER.email); + cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_OWNER.email); cy.get('input[name="password"]').type(INSTANCE_OWNER.password); cy.getByTestId('form-submit-button').click(); mainSidebar.getters.logo().should('be.visible'); @@ -47,7 +47,7 @@ describe('User Management', { disableAutoLogin: true }, () => { mainSidebar.actions.openUserMenu(); cy.getByTestId('user-menu-item-logout').click(); - cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email); + cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_MEMBERS[0].email); cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password); cy.getByTestId('form-submit-button').click(); mainSidebar.getters.logo().should('be.visible'); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index d8c618539f..5a06e05f8c 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -506,7 +506,7 @@ describe('Projects', { disableAutoLogin: true }, () => { mainSidebar.actions.openUserMenu(); cy.getByTestId('user-menu-item-logout').click(); - cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email); + cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_MEMBERS[0].email); cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password); cy.getByTestId('form-submit-button').click(); diff --git a/cypress/pages/signin.ts b/cypress/pages/signin.ts index bc0d7196a3..248ba49096 100644 --- a/cypress/pages/signin.ts +++ b/cypress/pages/signin.ts @@ -15,7 +15,7 @@ export class SigninPage extends BasePage { getters = { form: () => cy.getByTestId('auth-form'), - email: () => cy.getByTestId('email'), + email: () => cy.getByTestId('emailOrLdapLoginId'), password: () => cy.getByTestId('password'), submit: () => cy.get('button'), }; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 158fe129ec..fea929e12c 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -69,7 +69,7 @@ Cypress.Commands.add('signin', ({ email, password }) => { .request({ method: 'POST', url: `${BACKEND_BASE_URL}/rest/login`, - body: { email, password }, + body: { emailOrLdapLoginId: email, password }, failOnStatusCode: false, }) .then((response) => { diff --git a/package.json b/package.json index cf9e4cb9ea..611e5a269e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.81.0", + "version": "1.82.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 8d4031be28..9430af2894 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.16.0", + "version": "0.17.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts b/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts index f222f1d93e..8c9e888007 100644 --- a/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/auth/__tests__/login-request.dto.test.ts @@ -6,7 +6,7 @@ describe('LoginRequestDto', () => { { name: 'complete valid login request', request: { - email: 'test@example.com', + emailOrLdapLoginId: 'test@example.com', password: 'securePassword123', mfaCode: '123456', }, @@ -14,14 +14,14 @@ describe('LoginRequestDto', () => { { name: 'login request without optional MFA', request: { - email: 'test@example.com', + emailOrLdapLoginId: 'test@example.com', password: 'securePassword123', }, }, { name: 'login request with both mfaCode and mfaRecoveryCode', request: { - email: 'test@example.com', + emailOrLdapLoginId: 'test@example.com', password: 'securePassword123', mfaCode: '123456', mfaRecoveryCode: 'recovery-code-123', @@ -30,7 +30,7 @@ describe('LoginRequestDto', () => { { name: 'login request with only mfaRecoveryCode', request: { - email: 'test@example.com', + emailOrLdapLoginId: 'test@example.com', password: 'securePassword123', mfaRecoveryCode: 'recovery-code-123', }, @@ -44,43 +44,35 @@ describe('LoginRequestDto', () => { describe('Invalid requests', () => { test.each([ { - name: 'invalid email', + name: 'invalid emailOrLdapLoginId', request: { - email: 'invalid-email', + emailOrLdapLoginId: 0, password: 'securePassword123', }, - expectedErrorPath: ['email'], + expectedErrorPath: ['emailOrLdapLoginId'], }, { name: 'empty password', request: { - email: 'test@example.com', + emailOrLdapLoginId: 'test@example.com', password: '', }, expectedErrorPath: ['password'], }, { - name: 'missing email', + name: 'missing emailOrLdapLoginId', request: { password: 'securePassword123', }, - expectedErrorPath: ['email'], + expectedErrorPath: ['emailOrLdapLoginId'], }, { name: 'missing password', request: { - email: 'test@example.com', + emailOrLdapLoginId: 'test@example.com', }, expectedErrorPath: ['password'], }, - { - name: 'whitespace in email and password', - request: { - email: ' test@example.com ', - password: ' securePassword123 ', - }, - expectedErrorPath: ['email'], - }, ])('should fail validation for $name', ({ request, expectedErrorPath }) => { const result = LoginRequestDto.safeParse(request); expect(result.success).toBe(false); diff --git a/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts b/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts index 894263992c..d1f6771b9c 100644 --- a/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/auth/login-request.dto.ts @@ -2,7 +2,12 @@ import { z } from 'zod'; import { Z } from 'zod-class'; export class LoginRequestDto extends Z.class({ - email: z.string().email(), + /* + * The LDAP username does not need to be an email, so email validation + * is not enforced here. The controller determines whether this is an + * email and validates when LDAP is disabled + */ + emailOrLdapLoginId: z.string().trim(), password: z.string().min(1), mfaCode: z.string().optional(), mfaRecoveryCode: z.string().optional(), diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index 4039d2ef10..f00bab614a 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-benchmark", - "version": "1.11.0", + "version": "1.12.0", "description": "Cli for running benchmark tests for n8n", "main": "dist/index", "scripts": { diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index db073d959c..47260199b3 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.22.0", + "version": "0.23.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 2d36cc80c5..32328e837d 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.30.0", + "version": "1.31.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/di/package.json b/packages/@n8n/di/package.json index 39e830fd4f..ef51c8bba0 100644 --- a/packages/@n8n/di/package.json +++ b/packages/@n8n/di/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/di", - "version": "0.3.0", + "version": "0.4.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 00986ac93f..f2a41dced8 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/imap", - "version": "0.8.0", + "version": "0.9.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/json-schema-to-zod/package.json b/packages/@n8n/json-schema-to-zod/package.json index dd8638e8db..44cec8ccb1 100644 --- a/packages/@n8n/json-schema-to-zod/package.json +++ b/packages/@n8n/json-schema-to-zod/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/json-schema-to-zod", - "version": "1.2.0", + "version": "1.3.0", "description": "Converts JSON schema objects into Zod schemas", "types": "./dist/types/index.d.ts", "main": "./dist/cjs/index.js", diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 109a090259..295060de21 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -28,7 +28,7 @@ export class ToolWorkflow extends VersionedNodeType { ], }, }, - defaultVersion: 2, + defaultVersion: 2.1, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { @@ -37,6 +37,7 @@ export class ToolWorkflow extends VersionedNodeType { 1.2: new ToolWorkflowV1(baseDescription), 1.3: new ToolWorkflowV1(baseDescription), 2: new ToolWorkflowV2(baseDescription), + 2.1: new ToolWorkflowV2(baseDescription), }; super(nodeVersions, baseDescription); } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts index 98ca94cb1f..df682ce040 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.node.ts @@ -25,7 +25,9 @@ export class ToolWorkflowV2 implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const workflowToolService = new WorkflowToolService(this); + const returnAllItems = this.getNode().typeVersion > 2; + + const workflowToolService = new WorkflowToolService(this, { returnAllItems }); const name = this.getNodeParameter('name', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex) as string; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts index 688000b1ec..6205e1d6d9 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -187,6 +187,66 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(result.subExecutionId).toBe('test-execution'); }); + it('should successfully execute workflow and return first item of many', async () => { + const workflowInfo = { id: 'test-workflow' }; + const items: INodeExecutionData[] = []; + const workflowProxyMock = { + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData; + + const TEST_RESPONSE_1 = { msg: 'test response 1' }; + const TEST_RESPONSE_2 = { msg: 'test response 2' }; + + const mockResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE_1 }, { json: TEST_RESPONSE_2 }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); + + const result = await service['executeSubWorkflow']( + context, + workflowInfo, + items, + workflowProxyMock, + ); + + expect(result.response).toBe(TEST_RESPONSE_1); + expect(result.subExecutionId).toBe('test-execution'); + }); + + it('should successfully execute workflow and return all items', async () => { + const serviceWithReturnAllItems = new WorkflowToolService(context, { returnAllItems: true }); + const workflowInfo = { id: 'test-workflow' }; + const items: INodeExecutionData[] = []; + const workflowProxyMock = { + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData; + + const TEST_RESPONSE_1 = { msg: 'test response 1' }; + const TEST_RESPONSE_2 = { msg: 'test response 2' }; + + const mockResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE_1 }, { json: TEST_RESPONSE_2 }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); + + const result = await serviceWithReturnAllItems['executeSubWorkflow']( + context, + workflowInfo, + items, + workflowProxyMock, + undefined, + ); + + expect(result.response).toEqual([{ json: TEST_RESPONSE_1 }, { json: TEST_RESPONSE_2 }]); + expect(result.subExecutionId).toBe('test-execution'); + }); + it('should throw error when workflow execution fails', async () => { jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed')); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index 8fc366084e..57a6d1fc84 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -1,7 +1,6 @@ import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; -import get from 'lodash/get'; -import isObject from 'lodash/isObject'; +import { isArray, isObject } from 'lodash'; import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; @@ -29,6 +28,10 @@ import { } from 'n8n-workflow'; import { z } from 'zod'; +function isNodeExecutionData(data: unknown): data is INodeExecutionData[] { + return isArray(data) && Boolean(data.length) && isObject(data[0]) && 'json' in data[0]; +} + /** Main class for creating the Workflow tool Processes the node parameters and creates AI Agent tool capable of executing n8n workflows @@ -43,10 +46,16 @@ export class WorkflowToolService { // Sub-workflow execution id, will be set after the sub-workflow is executed private subExecutionId: string | undefined; - constructor(private baseContext: ISupplyDataFunctions) { + private returnAllItems: boolean = false; + + constructor( + private baseContext: ISupplyDataFunctions, + options?: { returnAllItems: boolean }, + ) { const subWorkflowInputs = this.baseContext.getNode().parameters .workflowInputs as ResourceMapperValue; this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0; + this.returnAllItems = options?.returnAllItems ?? false; } // Creates the tool based on the provided parameters @@ -65,7 +74,7 @@ export class WorkflowToolService { const toolHandler = async ( query: string | IDataObject, runManager?: CallbackManagerForToolRun, - ): Promise => { + ): Promise => { const localRunIndex = runIndex++; // We need to clone the context here to handle runIndex correctly // Otherwise the runIndex will be shared between different executions @@ -74,10 +83,23 @@ export class WorkflowToolService { runIndex: localRunIndex, inputData: [[{ json: { query } }]], }); + try { const response = await this.runFunction(context, query, itemIndex, runManager); + const processedResponse = this.handleToolResponse(response); + let responseData: INodeExecutionData[]; + if (isNodeExecutionData(response)) { + responseData = response; + } else { + const reParsedData = jsonParse(processedResponse, { + fallbackValue: { response: processedResponse }, + }); + + responseData = [{ json: reParsedData }]; + } + // Once the sub-workflow is executed, add the output data to the context // This will be used to link the sub-workflow execution in the parent workflow let metadata: ITaskMetadata | undefined; @@ -89,13 +111,11 @@ export class WorkflowToolService { }, }; } - const json = jsonParse(processedResponse, { - fallbackValue: { response: processedResponse }, - }); + void context.addOutputData( NodeConnectionType.AiTool, localRunIndex, - [[{ json }]], + [responseData], metadata, ); @@ -126,6 +146,14 @@ export class WorkflowToolService { return response.toString(); } + if (isNodeExecutionData(response)) { + return JSON.stringify( + response.map((item) => item.json), + null, + 2, + ); + } + if (isObject(response)) { return JSON.stringify(response, null, 2); } @@ -148,7 +176,7 @@ export class WorkflowToolService { items: INodeExecutionData[], workflowProxy: IWorkflowDataProxyData, runManager?: CallbackManagerForToolRun, - ): Promise<{ response: string; subExecutionId: string }> { + ): Promise<{ response: string | IDataObject | INodeExecutionData[]; subExecutionId: string }> { let receivedData: ExecuteWorkflowData; try { receivedData = await context.executeWorkflow(workflowInfo, items, runManager?.getChild(), { @@ -163,7 +191,12 @@ export class WorkflowToolService { throw new NodeOperationError(context.getNode(), error as Error); } - const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined; + let response: IDataObject | INodeExecutionData[] | undefined; + if (this.returnAllItems) { + response = receivedData?.data?.[0]?.length ? receivedData.data[0] : undefined; + } else { + response = receivedData?.data?.[0]?.[0]?.json; + } if (response === undefined) { throw new NodeOperationError( context.getNode(), @@ -183,7 +216,7 @@ export class WorkflowToolService { query: string | IDataObject, itemIndex: number, runManager?: CallbackManagerForToolRun, - ): Promise { + ): Promise { const source = context.getNodeParameter('source', itemIndex) as string; const workflowProxy = context.getWorkflowDataProxy(0); @@ -304,7 +337,10 @@ export class WorkflowToolService { private async createStructuredTool( name: string, description: string, - func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise, + func: ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ) => Promise, ): Promise { const collectedArguments = await this.extractFromAIParameters(); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts index 46fb3d1677..cd56a0f5d7 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -12,7 +12,7 @@ export const versionDescription: INodeTypeDescription = { defaults: { name: 'Call n8n Workflow Tool', }, - version: [2], + version: [2, 2.1], inputs: [], outputs: [NodeConnectionType.AiTool], outputNames: ['Tool'], diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 57cf265392..8e1d3b2c3f 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.81.0", + "version": "1.82.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 3d41e4390c..13a8824260 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.18.0", + "version": "0.19.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 95f6d0e7d7..c68c0ce393 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.18.0", + "version": "1.19.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/typescript-config/package.json b/packages/@n8n/typescript-config/package.json index 2c37e16101..4e43174a9c 100644 --- a/packages/@n8n/typescript-config/package.json +++ b/packages/@n8n/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/typescript-config", - "version": "1.1.0", + "version": "1.2.0", "type": "module", "files": [ "tsconfig.backend.json", diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index 08c7bbe2da..68010c668b 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/utils", "type": "module", - "version": "1.2.0", + "version": "1.3.0", "files": [ "dist" ], diff --git a/packages/@n8n/vitest-config/package.json b/packages/@n8n/vitest-config/package.json index 0830e53b02..d49cbb32b5 100644 --- a/packages/@n8n/vitest-config/package.json +++ b/packages/@n8n/vitest-config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/vitest-config", - "version": "1.1.0", + "version": "1.2.0", "type": "module", "peerDependencies": { "vite": "catalog:frontend", diff --git a/packages/cli/package.json b/packages/cli/package.json index d2a8285efe..92b676a125 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.81.0", + "version": "1.82.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/controllers/__tests__/auth.controller.test.ts b/packages/cli/src/controllers/__tests__/auth.controller.test.ts new file mode 100644 index 0000000000..0abebb5ecf --- /dev/null +++ b/packages/cli/src/controllers/__tests__/auth.controller.test.ts @@ -0,0 +1,99 @@ +import type { LoginRequestDto } from '@n8n/api-types'; +import { Container } from '@n8n/di'; +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import { Logger } from 'n8n-core'; + +import * as auth from '@/auth'; +import { AuthService } from '@/auth/auth.service'; +import config from '@/config'; +import type { User } from '@/databases/entities/user'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { EventService } from '@/events/event.service'; +import { License } from '@/license'; +import { MfaService } from '@/mfa/mfa.service'; +import { PostHogClient } from '@/posthog'; +import type { AuthenticatedRequest } from '@/requests'; +import { UserService } from '@/services/user.service'; +import { mockInstance } from '@test/mocking'; + +import { AuthController } from '../auth.controller'; + +jest.mock('@/auth'); + +const mockedAuth = auth as jest.Mocked; + +describe('AuthController', () => { + mockInstance(Logger); + mockInstance(EventService); + mockInstance(AuthService); + mockInstance(MfaService); + mockInstance(UserService); + mockInstance(UserRepository); + mockInstance(PostHogClient); + mockInstance(License); + const controller = Container.get(AuthController); + const userService = Container.get(UserService); + const authService = Container.get(AuthService); + const eventsService = Container.get(EventService); + const postHog = Container.get(PostHogClient); + + describe('login', () => { + it('should not validate email in "emailOrLdapLoginId" if LDAP is enabled', async () => { + // Arrange + + const browserId = '1'; + + const member = mock({ + id: '123', + role: 'global:member', + mfaEnabled: false, + }); + + const body = mock({ + emailOrLdapLoginId: 'non email', + password: 'password', + }); + + const req = mock({ + user: member, + body, + browserId, + }); + + const res = mock(); + + mockedAuth.handleEmailLogin.mockResolvedValue(member); + + mockedAuth.handleLdapLogin.mockResolvedValue(member); + + config.set('userManagement.authenticationMethod', 'ldap'); + + // Act + + await controller.login(req, res, body); + + // Assert + + expect(mockedAuth.handleEmailLogin).toHaveBeenCalledWith( + body.emailOrLdapLoginId, + body.password, + ); + expect(mockedAuth.handleLdapLogin).toHaveBeenCalledWith( + body.emailOrLdapLoginId, + body.password, + ); + + expect(authService.issueCookie).toHaveBeenCalledWith(res, member, browserId); + expect(eventsService.emit).toHaveBeenCalledWith('user-logged-in', { + user: member, + authenticationMethod: 'ldap', + }); + + expect(userService.toPublic).toHaveBeenCalledWith(member, { + posthog: postHog, + withScopes: true, + }); + }); + }); +}); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index fb06c1a80b..dac65903fe 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -1,4 +1,5 @@ import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types'; +import { isEmail } from 'class-validator'; import { Response } from 'express'; import { Logger } from 'n8n-core'; @@ -44,14 +45,19 @@ export class AuthController { res: Response, @Body payload: LoginRequestDto, ): Promise { - const { email, password, mfaCode, mfaRecoveryCode } = payload; + const { emailOrLdapLoginId, password, mfaCode, mfaRecoveryCode } = payload; let user: User | undefined; let usedAuthenticationMethod = getCurrentAuthenticationMethod(); + + if (usedAuthenticationMethod === 'email' && !isEmail(emailOrLdapLoginId)) { + throw new BadRequestError('Invalid email address'); + } + if (isSamlCurrentAuthenticationMethod()) { // attempt to fetch user data with the credentials, but don't log in yet - const preliminaryUser = await handleEmailLogin(email, password); + const preliminaryUser = await handleEmailLogin(emailOrLdapLoginId, password); // if the user is an owner, continue with the login if ( preliminaryUser?.role === 'global:owner' || @@ -63,15 +69,15 @@ export class AuthController { throw new AuthError('SSO is enabled, please log in with SSO'); } } else if (isLdapCurrentAuthenticationMethod()) { - const preliminaryUser = await handleEmailLogin(email, password); + const preliminaryUser = await handleEmailLogin(emailOrLdapLoginId, password); if (preliminaryUser?.role === 'global:owner') { user = preliminaryUser; usedAuthenticationMethod = 'email'; } else { - user = await handleLdapLogin(email, password); + user = await handleLdapLogin(emailOrLdapLoginId, password); } } else { - user = await handleEmailLogin(email, password); + user = await handleEmailLogin(emailOrLdapLoginId, password); } if (user) { @@ -101,7 +107,7 @@ export class AuthController { } this.eventService.emit('user-login-failed', { authenticationMethod: usedAuthenticationMethod, - userEmail: email, + userEmail: emailOrLdapLoginId, reason: 'wrong credentials', }); throw new AuthError('Wrong username or password. Do you have caps lock on?'); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 7fd3f50eac..92422c27b9 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -43,7 +43,7 @@ describe('POST /login', () => { test('should log user in', async () => { const response = await testServer.authlessAgent.post('/login').send({ - email: owner.email, + emailOrLdapLoginId: owner.email, password: ownerPassword, }); @@ -87,7 +87,7 @@ describe('POST /login', () => { await mfaService.enableMfa(owner.id); const response = await testServer.authlessAgent.post('/login').send({ - email: owner.email, + emailOrLdapLoginId: owner.email, password: ownerPassword, mfaCode: mfaService.totp.generateTOTP(secret), }); @@ -131,7 +131,7 @@ describe('POST /login', () => { }); const response = await testServer.authlessAgent.post('/login').send({ - email: member.email, + emailOrLdapLoginId: member.email, password, }); expect(response.statusCode).toBe(403); @@ -148,19 +148,16 @@ describe('POST /login', () => { expect(response.statusCode).toBe(200); }); - test('should fail on invalid email in the payload', async () => { + test('should fail with invalid email in the payload is the current authentication method is "email"', async () => { + config.set('userManagement.authenticationMethod', 'email'); + const response = await testServer.authlessAgent.post('/login').send({ - email: 'invalid-email', + emailOrLdapLoginId: 'invalid-email', password: ownerPassword, }); expect(response.statusCode).toBe(400); - expect(response.body).toEqual({ - validation: 'email', - code: 'invalid_string', - message: 'Invalid email', - path: ['email'], - }); + expect(response.body.message).toBe('Invalid email address'); }); }); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 9a4c214f10..1f4421e908 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -470,7 +470,7 @@ describe('POST /login', () => { const response = await testServer.authlessAgent .post('/login') - .send({ email: ldapUser.mail, password: 'password' }); + .send({ emailOrLdapLoginId: ldapUser.mail, password: 'password' }); expect(response.statusCode).toBe(200); expect(response.headers['set-cookie']).toBeDefined(); @@ -529,7 +529,7 @@ describe('POST /login', () => { const response = await testServer.authlessAgent .post('/login') - .send({ email: owner.email, password: 'password' }); + .send({ emailOrLdapLoginId: owner.email, password: 'password' }); expect(response.status).toBe(200); expect(response.body.data?.signInType).toBeDefined(); diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 935ede4c17..3a10e6b39b 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -268,7 +268,7 @@ describe('Change password with MFA enabled', () => { .authAgentFor(user) .post('/login') .send({ - email: user.email, + emailOrLdapLoginId: user.email, password: newPassword, mfaCode: new TOTPService().generateTOTP(rawSecret), }) @@ -306,7 +306,10 @@ describe('Login', () => { const user = await createUser({ password }); - await testServer.authlessAgent.post('/login').send({ email: user.email, password }).expect(200); + await testServer.authlessAgent + .post('/login') + .send({ emailOrLdapLoginId: user.email, password }) + .expect(200); }); test('GET /login should not include mfaSecret and mfaRecoveryCodes property in response', async () => { @@ -323,7 +326,7 @@ describe('Login', () => { await testServer.authlessAgent .post('/login') - .send({ email: user.email, password: rawPassword }) + .send({ emailOrLdapLoginId: user.email, password: rawPassword }) .expect(401); }); @@ -333,7 +336,7 @@ describe('Login', () => { await testServer.authlessAgent .post('/login') - .send({ email: user.email, password: rawPassword, mfaCode: 'wrongvalue' }) + .send({ emailOrLdapLoginId: user.email, password: rawPassword, mfaCode: 'wrongvalue' }) .expect(401); }); @@ -342,7 +345,7 @@ describe('Login', () => { const response = await testServer.authlessAgent .post('/login') - .send({ email: user.email, password: rawPassword }) + .send({ emailOrLdapLoginId: user.email, password: rawPassword }) .expect(401); expect(response.body.code).toBe(998); @@ -355,7 +358,7 @@ describe('Login', () => { const response = await testServer.authlessAgent .post('/login') - .send({ email: user.email, password: rawPassword, mfaCode: token }) + .send({ emailOrLdapLoginId: user.email, password: rawPassword, mfaCode: token }) .expect(200); const data = response.body.data; @@ -370,7 +373,11 @@ describe('Login', () => { await testServer.authlessAgent .post('/login') - .send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' }) + .send({ + emailOrLdapLoginId: user.email, + password: rawPassword, + mfaRecoveryCode: 'wrongvalue', + }) .expect(401); }); @@ -379,7 +386,11 @@ describe('Login', () => { const response = await testServer.authlessAgent .post('/login') - .send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }) + .send({ + emailOrLdapLoginId: user.email, + password: rawPassword, + mfaRecoveryCode: rawRecoveryCodes[0], + }) .expect(200); const data = response.body.data; diff --git a/packages/core/package.json b/packages/core/package.json index 6234dc225f..14e12e361a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.80.0", + "version": "1.81.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 ed93c63430..0f485b68ad 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.34.0", + "version": "0.35.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/frontend/@n8n/composables/package.json b/packages/frontend/@n8n/composables/package.json index 55c7a0a8cb..fe57c94f86 100644 --- a/packages/frontend/@n8n/composables/package.json +++ b/packages/frontend/@n8n/composables/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/composables", "type": "module", - "version": "1.2.0", + "version": "1.3.0", "files": [ "dist" ], diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 5189f53216..20adb0f215 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/design-system", - "version": "1.69.0", + "version": "1.70.0", "main": "src/index.ts", "import": "src/index.ts", "scripts": { diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 1b4b6fd0a2..227efb76b4 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.81.0", + "version": "1.82.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/frontend/editor-ui/src/api/users.ts b/packages/frontend/editor-ui/src/api/users.ts index bff4f65fac..dff84242b5 100644 --- a/packages/frontend/editor-ui/src/api/users.ts +++ b/packages/frontend/editor-ui/src/api/users.ts @@ -1,4 +1,5 @@ import type { + LoginRequestDto, PasswordUpdateRequestDto, SettingsUpdateRequestDto, UserUpdateRequestDto, @@ -21,7 +22,7 @@ export async function loginCurrentUser( export async function login( context: IRestApiContext, - params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string }, + params: LoginRequestDto, ): Promise { return await makeRestApiRequest(context, 'POST', '/login', params); } diff --git a/packages/frontend/editor-ui/src/components/RunData.test.ts b/packages/frontend/editor-ui/src/components/RunData.test.ts index b47f1eede9..dc10f24566 100644 --- a/packages/frontend/editor-ui/src/components/RunData.test.ts +++ b/packages/frontend/editor-ui/src/components/RunData.test.ts @@ -166,14 +166,14 @@ describe('RunData', () => { expect(queryByTestId('ndv-pin-data')).not.toBeInTheDocument(); }); - it('should disable pin data button when data is pinned', async () => { + it('should not disable pin data button when data is pinned [ADO-3143]', async () => { const { getByTestId } = render({ defaultRunItems: [], displayMode: 'table', pinnedData: [{ json: { name: 'Test' } }], }); const pinDataButton = getByTestId('ndv-pin-data'); - expect(pinDataButton).toBeDisabled(); + expect(pinDataButton).not.toBeDisabled(); }); it('should render callout when data is pinned in output panel', async () => { diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue index 75ea77f386..c0e77ea259 100644 --- a/packages/frontend/editor-ui/src/components/RunData.vue +++ b/packages/frontend/editor-ui/src/components/RunData.vue @@ -528,8 +528,7 @@ const showPinButton = computed(() => { const pinButtonDisabled = computed( () => - pinnedData.hasData.value || - !rawInputData.value.length || + (!rawInputData.value.length && !pinnedData.hasData.value) || !!binaryData.value?.length || isReadOnlyRoute.value || readOnlyEnv.value, diff --git a/packages/frontend/editor-ui/src/components/RunDataPinButton.test.ts b/packages/frontend/editor-ui/src/components/RunDataPinButton.test.ts index 1e6b7958c8..56d25bc547 100644 --- a/packages/frontend/editor-ui/src/components/RunDataPinButton.test.ts +++ b/packages/frontend/editor-ui/src/components/RunDataPinButton.test.ts @@ -30,7 +30,7 @@ const renderComponent = createComponentRenderer(RunDataPinButton, { }, dataPinningDocsUrl: '', pinnedData: { - hasData: false, + hasData: { value: false }, }, disabled: false, }, @@ -121,4 +121,30 @@ describe('RunDataPinButton.vue', () => { expect(getByRole('tooltip')).toBeVisible(); expect(getByRole('tooltip')).toHaveTextContent('disabled'); }); + + it('pins data on button click', async () => { + const { getByTestId, getByRole, emitted } = renderComponent({}); + // Should show 'Pin data' tooltip and emit togglePinData event + await userEvent.hover(getByTestId('ndv-pin-data')); + expect(getByRole('tooltip')).toBeVisible(); + expect(getByRole('tooltip').textContent).toContain('Pin data'); + await userEvent.click(getByTestId('ndv-pin-data')); + expect(emitted().togglePinData).toBeDefined(); + }); + + it('should show correct tooltip and unpin data on button click', async () => { + const { getByTestId, getByRole, emitted } = renderComponent({ + props: { + pinnedData: { + hasData: { value: true }, + }, + }, + }); + // Should show 'Unpin data' tooltip and emit togglePinData event + await userEvent.hover(getByTestId('ndv-pin-data')); + expect(getByRole('tooltip')).toBeVisible(); + expect(getByRole('tooltip').textContent).toContain('Unpin data'); + await userEvent.click(getByTestId('ndv-pin-data')); + expect(emitted().togglePinData).toBeDefined(); + }); }); diff --git a/packages/frontend/editor-ui/src/components/RunDataPinButton.vue b/packages/frontend/editor-ui/src/components/RunDataPinButton.vue index 4b79abb6fa..d8a46a475e 100644 --- a/packages/frontend/editor-ui/src/components/RunDataPinButton.vue +++ b/packages/frontend/editor-ui/src/components/RunDataPinButton.vue @@ -37,14 +37,19 @@ const visible = computed(() => {{ locale.baseText('node.discovery.pinData.ndv') }}
- {{ locale.baseText('ndv.pinData.pin.title') }} - - {{ locale.baseText('ndv.pinData.pin.description') }} +
+ {{ locale.baseText('ndv.pinData.unpin.title') }} +
+
+ {{ locale.baseText('ndv.pinData.pin.title') }} + + {{ locale.baseText('ndv.pinData.pin.description') }} - - {{ locale.baseText('ndv.pinData.pin.link') }} - - + + {{ locale.baseText('ndv.pinData.pin.link') }} + + +
{ }; }; - const loginWithCreds = async (params: { - email: string; - password: string; - mfaCode?: string; - mfaRecoveryCode?: string; - }) => { + const loginWithCreds = async (params: LoginRequestDto) => { const user = await usersApi.login(rootStore.restApiContext, params); if (!user) { return; diff --git a/packages/frontend/editor-ui/src/views/AuthView.vue b/packages/frontend/editor-ui/src/views/AuthView.vue index 426fac0345..5dc46aac4f 100644 --- a/packages/frontend/editor-ui/src/views/AuthView.vue +++ b/packages/frontend/editor-ui/src/views/AuthView.vue @@ -3,6 +3,7 @@ import Logo from '@/components/Logo/Logo.vue'; import SSOLogin from '@/components/SSOLogin.vue'; import type { IFormBoxConfig } from '@/Interface'; import { useSettingsStore } from '@/stores/settings.store'; +import type { EmailOrLdapLoginIdAndPassword } from './SigninView.vue'; withDefaults( defineProps<{ @@ -19,7 +20,7 @@ withDefaults( const emit = defineEmits<{ update: [{ name: string; value: string }]; - submit: [values: { [key: string]: string }]; + submit: [values: EmailOrLdapLoginIdAndPassword]; secondaryClick: []; }>(); @@ -27,7 +28,7 @@ const onUpdate = (e: { name: string; value: string }) => { emit('update', e); }; -const onSubmit = (values: { [key: string]: string }) => { +const onSubmit = (values: EmailOrLdapLoginIdAndPassword) => { emit('submit', values); }; diff --git a/packages/frontend/editor-ui/src/views/SigninView.test.ts b/packages/frontend/editor-ui/src/views/SigninView.test.ts index 71c578d595..5652122b13 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.test.ts +++ b/packages/frontend/editor-ui/src/views/SigninView.test.ts @@ -85,7 +85,7 @@ describe('SigninView', () => { await userEvent.click(submitButton); expect(usersStore.loginWithCreds).toHaveBeenCalledWith({ - email: 'test@n8n.io', + emailOrLdapLoginId: 'test@n8n.io', password: 'password', mfaCode: undefined, mfaRecoveryCode: undefined, diff --git a/packages/frontend/editor-ui/src/views/SigninView.vue b/packages/frontend/editor-ui/src/views/SigninView.vue index 0257870c7d..e2b9f6ccad 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.vue +++ b/packages/frontend/editor-ui/src/views/SigninView.vue @@ -15,6 +15,14 @@ import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import type { IFormBoxConfig } from '@/Interface'; import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/constants'; +import type { LoginRequestDto } from '@n8n/api-types'; + +export type EmailOrLdapLoginIdAndPassword = Pick< + LoginRequestDto, + 'emailOrLdapLoginId' | 'password' +>; + +export type MfaCodeOrMfaRecoveryCode = Pick; const usersStore = useUsersStore(); const settingsStore = useSettingsStore(); @@ -29,7 +37,7 @@ const telemetry = useTelemetry(); const loading = ref(false); const showMfaView = ref(false); -const email = ref(''); +const emailOrLdapLoginId = ref(''); const password = ref(''); const reportError = ref(false); @@ -50,7 +58,7 @@ const formConfig: IFormBoxConfig = reactive({ redirectLink: '/forgot-password', inputs: [ { - name: 'email', + name: 'emailOrLdapLoginId', properties: { label: emailLabel.value, type: 'email', @@ -78,23 +86,16 @@ const formConfig: IFormBoxConfig = reactive({ ], }); -const onMFASubmitted = async (form: { mfaCode?: string; mfaRecoveryCode?: string }) => { +const onMFASubmitted = async (form: MfaCodeOrMfaRecoveryCode) => { await login({ - email: email.value, + emailOrLdapLoginId: emailOrLdapLoginId.value, password: password.value, mfaCode: form.mfaCode, mfaRecoveryCode: form.mfaRecoveryCode, }); }; -const isFormWithEmailAndPassword = (values: { - [key: string]: string; -}): values is { email: string; password: string } => { - return 'email' in values && 'password' in values; -}; - -const onEmailPasswordSubmitted = async (form: { [key: string]: string }) => { - if (!isFormWithEmailAndPassword(form)) return; +const onEmailPasswordSubmitted = async (form: EmailOrLdapLoginIdAndPassword) => { await login(form); }; @@ -111,16 +112,11 @@ const getRedirectQueryParameter = () => { return redirect; }; -const login = async (form: { - email: string; - password: string; - mfaCode?: string; - mfaRecoveryCode?: string; -}) => { +const login = async (form: LoginRequestDto) => { try { loading.value = true; await usersStore.loginWithCreds({ - email: form.email, + emailOrLdapLoginId: form.emailOrLdapLoginId, password: form.password, mfaCode: form.mfaCode, mfaRecoveryCode: form.mfaRecoveryCode, @@ -185,8 +181,8 @@ const onFormChanged = (toForm: string) => { reportError.value = false; } }; -const cacheCredentials = (form: { email: string; password: string }) => { - email.value = form.email; +const cacheCredentials = (form: EmailOrLdapLoginIdAndPassword) => { + emailOrLdapLoginId.value = form.emailOrLdapLoginId; password.value = form.password; }; diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 26bb73b680..606f5f70a5 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.80.0", + "version": "1.81.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/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 75ba137c7e..4dfde42ecd 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -457,14 +457,12 @@ export class Github implements INodeType { required: true, modes: [ { - displayName: 'Workflow', + displayName: 'From List', name: 'list', type: 'list', placeholder: 'Select a workflow...', typeOptions: { searchListMethod: 'getWorkflows', - searchable: true, - searchFilterRequired: true, }, }, { @@ -482,6 +480,21 @@ export class Github implements INodeType { }, ], }, + { + displayName: 'By File Name', + name: 'filename', + type: 'string', + placeholder: 'e.g. main.yaml or main.yml', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9_-]+.(yaml|yml)', + errorMessage: 'Not a valid Github Workflow File Name', + }, + }, + ], + }, ], displayOptions: { show: { @@ -2501,7 +2514,9 @@ export class Github implements INodeType { requestMethod = 'POST'; - const workflowId = this.getNodeParameter('workflowId', i) as string; + const workflowId = this.getNodeParameter('workflowId', i, undefined, { + extractValue: true, + }) as string; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`; body.ref = this.getNodeParameter('ref', i) as string; diff --git a/packages/nodes-base/nodes/Github/__tests__/Github.node.test.ts b/packages/nodes-base/nodes/Github/__tests__/Github.node.test.ts new file mode 100644 index 0000000000..11f55cb7f0 --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/Github.node.test.ts @@ -0,0 +1,91 @@ +import nock from 'nock'; + +import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; + +const workflows = getWorkflowFilenames(__dirname); + +describe('Test Github Node', () => { + describe('Workflow Dispatch', () => { + const now = 1683028800000; + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + const usersResponse = { + total_count: 12, + items: [ + { + login: 'testOwner', + id: 1, + }, + ], + }; + const repositoriesResponse = { + total_count: 40, + items: [ + { + id: 3081286, + name: 'testRepository', + }, + ], + }; + const workflowsResponse = { + total_count: 2, + workflows: [ + { + id: workflowId, + node_id: 'MDg6V29ya2Zsb3cxNjEzMzU=', + name: 'CI', + path: '.github/workflows/blank.yaml', + state: 'active', + created_at: '2020-01-08T23:48:37.000-08:00', + updated_at: '2020-01-08T23:50:21.000-08:00', + url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/161335', + html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/161335', + badge_url: 'https://github.com/octo-org/octo-repo/workflows/CI/badge.svg', + }, + { + id: 269289, + node_id: 'MDE4OldvcmtmbG93IFNlY29uZGFyeTI2OTI4OQ==', + name: 'Linter', + path: '.github/workflows/linter.yaml', + state: 'active', + created_at: '2020-01-08T23:48:37.000-08:00', + updated_at: '2020-01-08T23:50:21.000-08:00', + url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/269289', + html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/269289', + badge_url: 'https://github.com/octo-org/octo-repo/workflows/Linter/badge.svg', + }, + ], + }; + + beforeAll(async () => { + jest.useFakeTimers({ doNotFake: ['nextTick'], now }); + await initBinaryDataService(); + }); + beforeEach(async () => { + const baseUrl = 'https://api.github.com'; + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/search/users') + .query(true) + .reply(200, usersResponse) + .get('/search/repositories') + .query(true) + .reply(200, repositoriesResponse) + .get(`/repos/${owner}/${repository}/actions/workflows`) + .reply(200, workflowsResponse) + .post(`/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, { + ref: 'main', + inputs: {}, + }) + .reply(200, {}); + }); + + afterEach(() => { + nock.cleanAll(); + }); + testWorkflows(workflows); + }); +}); diff --git a/packages/nodes-base/nodes/Github/__tests__/GithubTestWorkflow.json b/packages/nodes-base/nodes/Github/__tests__/GithubTestWorkflow.json new file mode 100644 index 0000000000..7404c5afa9 --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/GithubTestWorkflow.json @@ -0,0 +1,86 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-300, 260], + "id": "b14bf20f-78b0-490a-bbc6-d02b1af4c03c", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "workflow", + "workflowId": { + "__rl": true, + "value": 147025216, + "mode": "list", + "cachedResultName": "CI" + }, + "owner": { + "__rl": true, + "value": "testOwner", + "mode": "name" + }, + "repository": { + "__rl": true, + "value": "testRepository", + "mode": "name" + } + }, + "type": "n8n-nodes-base.github", + "typeVersion": 1, + "position": [-80, 260], + "id": "061752c9-507c-4b27-ba18-47b21d487aed", + "name": "GitHub", + "credentials": { + "githubApi": { + "id": "1", + "name": "GitHub account" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [120, 260], + "id": "3bc54e8f-eeba-496d-a95f-bb8927eff671", + "name": "No Operation, do nothing" + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "GitHub", + "type": "main", + "index": 0 + } + ] + ] + }, + "GitHub": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "No Operation, do nothing": [ + { + "json": {} + } + ] + }, + "meta": { + "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 5367fb2094..1c204e4f26 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.80.0", + "version": "1.81.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index a793542c52..e6361cd850 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.79.0", + "version": "1.80.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts",