Merge branch 'n8n-io:master' into MongoDB_vector_store

This commit is contained in:
Pash10g 2025-03-04 08:16:46 +02:00 committed by GitHub
commit afe56adc0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 616 additions and 138 deletions

View file

@ -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) # [1.81.0](https://github.com/n8n-io/n8n/compare/n8n@1.80.0...n8n@1.81.0) (2025-02-24)

View file

@ -36,7 +36,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
it('should login and logout', () => { it('should login and logout', () => {
cy.visit('/'); 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.get('input[name="password"]').type(INSTANCE_OWNER.password);
cy.getByTestId('form-submit-button').click(); cy.getByTestId('form-submit-button').click();
mainSidebar.getters.logo().should('be.visible'); mainSidebar.getters.logo().should('be.visible');
@ -47,7 +47,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
mainSidebar.actions.openUserMenu(); mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click(); 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.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click(); cy.getByTestId('form-submit-button').click();
mainSidebar.getters.logo().should('be.visible'); mainSidebar.getters.logo().should('be.visible');

View file

@ -506,7 +506,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
mainSidebar.actions.openUserMenu(); mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click(); 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.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click(); cy.getByTestId('form-submit-button').click();

View file

@ -15,7 +15,7 @@ export class SigninPage extends BasePage {
getters = { getters = {
form: () => cy.getByTestId('auth-form'), form: () => cy.getByTestId('auth-form'),
email: () => cy.getByTestId('email'), email: () => cy.getByTestId('emailOrLdapLoginId'),
password: () => cy.getByTestId('password'), password: () => cy.getByTestId('password'),
submit: () => cy.get('button'), submit: () => cy.get('button'),
}; };

View file

@ -69,7 +69,7 @@ Cypress.Commands.add('signin', ({ email, password }) => {
.request({ .request({
method: 'POST', method: 'POST',
url: `${BACKEND_BASE_URL}/rest/login`, url: `${BACKEND_BASE_URL}/rest/login`,
body: { email, password }, body: { emailOrLdapLoginId: email, password },
failOnStatusCode: false, failOnStatusCode: false,
}) })
.then((response) => { .then((response) => {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-monorepo", "name": "n8n-monorepo",
"version": "1.81.0", "version": "1.82.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=20.15", "node": ">=20.15",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/api-types", "name": "@n8n/api-types",
"version": "0.16.0", "version": "0.17.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -6,7 +6,7 @@ describe('LoginRequestDto', () => {
{ {
name: 'complete valid login request', name: 'complete valid login request',
request: { request: {
email: 'test@example.com', emailOrLdapLoginId: 'test@example.com',
password: 'securePassword123', password: 'securePassword123',
mfaCode: '123456', mfaCode: '123456',
}, },
@ -14,14 +14,14 @@ describe('LoginRequestDto', () => {
{ {
name: 'login request without optional MFA', name: 'login request without optional MFA',
request: { request: {
email: 'test@example.com', emailOrLdapLoginId: 'test@example.com',
password: 'securePassword123', password: 'securePassword123',
}, },
}, },
{ {
name: 'login request with both mfaCode and mfaRecoveryCode', name: 'login request with both mfaCode and mfaRecoveryCode',
request: { request: {
email: 'test@example.com', emailOrLdapLoginId: 'test@example.com',
password: 'securePassword123', password: 'securePassword123',
mfaCode: '123456', mfaCode: '123456',
mfaRecoveryCode: 'recovery-code-123', mfaRecoveryCode: 'recovery-code-123',
@ -30,7 +30,7 @@ describe('LoginRequestDto', () => {
{ {
name: 'login request with only mfaRecoveryCode', name: 'login request with only mfaRecoveryCode',
request: { request: {
email: 'test@example.com', emailOrLdapLoginId: 'test@example.com',
password: 'securePassword123', password: 'securePassword123',
mfaRecoveryCode: 'recovery-code-123', mfaRecoveryCode: 'recovery-code-123',
}, },
@ -44,43 +44,35 @@ describe('LoginRequestDto', () => {
describe('Invalid requests', () => { describe('Invalid requests', () => {
test.each([ test.each([
{ {
name: 'invalid email', name: 'invalid emailOrLdapLoginId',
request: { request: {
email: 'invalid-email', emailOrLdapLoginId: 0,
password: 'securePassword123', password: 'securePassword123',
}, },
expectedErrorPath: ['email'], expectedErrorPath: ['emailOrLdapLoginId'],
}, },
{ {
name: 'empty password', name: 'empty password',
request: { request: {
email: 'test@example.com', emailOrLdapLoginId: 'test@example.com',
password: '', password: '',
}, },
expectedErrorPath: ['password'], expectedErrorPath: ['password'],
}, },
{ {
name: 'missing email', name: 'missing emailOrLdapLoginId',
request: { request: {
password: 'securePassword123', password: 'securePassword123',
}, },
expectedErrorPath: ['email'], expectedErrorPath: ['emailOrLdapLoginId'],
}, },
{ {
name: 'missing password', name: 'missing password',
request: { request: {
email: 'test@example.com', emailOrLdapLoginId: 'test@example.com',
}, },
expectedErrorPath: ['password'], expectedErrorPath: ['password'],
}, },
{
name: 'whitespace in email and password',
request: {
email: ' test@example.com ',
password: ' securePassword123 ',
},
expectedErrorPath: ['email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => { ])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = LoginRequestDto.safeParse(request); const result = LoginRequestDto.safeParse(request);
expect(result.success).toBe(false); expect(result.success).toBe(false);

View file

@ -2,7 +2,12 @@ import { z } from 'zod';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
export class LoginRequestDto extends Z.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), password: z.string().min(1),
mfaCode: z.string().optional(), mfaCode: z.string().optional(),
mfaRecoveryCode: z.string().optional(), mfaRecoveryCode: z.string().optional(),

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/n8n-benchmark", "name": "@n8n/n8n-benchmark",
"version": "1.11.0", "version": "1.12.0",
"description": "Cli for running benchmark tests for n8n", "description": "Cli for running benchmark tests for n8n",
"main": "dist/index", "main": "dist/index",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/client-oauth2", "name": "@n8n/client-oauth2",
"version": "0.22.0", "version": "0.23.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/config", "name": "@n8n/config",
"version": "1.30.0", "version": "1.31.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/di", "name": "@n8n/di",
"version": "0.3.0", "version": "0.4.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/imap", "name": "@n8n/imap",
"version": "0.8.0", "version": "0.9.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/json-schema-to-zod", "name": "@n8n/json-schema-to-zod",
"version": "1.2.0", "version": "1.3.0",
"description": "Converts JSON schema objects into Zod schemas", "description": "Converts JSON schema objects into Zod schemas",
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",

View file

@ -28,7 +28,7 @@ export class ToolWorkflow extends VersionedNodeType {
], ],
}, },
}, },
defaultVersion: 2, defaultVersion: 2.1,
}; };
const nodeVersions: IVersionedNodeType['nodeVersions'] = { const nodeVersions: IVersionedNodeType['nodeVersions'] = {
@ -37,6 +37,7 @@ export class ToolWorkflow extends VersionedNodeType {
1.2: new ToolWorkflowV1(baseDescription), 1.2: new ToolWorkflowV1(baseDescription),
1.3: new ToolWorkflowV1(baseDescription), 1.3: new ToolWorkflowV1(baseDescription),
2: new ToolWorkflowV2(baseDescription), 2: new ToolWorkflowV2(baseDescription),
2.1: new ToolWorkflowV2(baseDescription),
}; };
super(nodeVersions, baseDescription); super(nodeVersions, baseDescription);
} }

View file

@ -25,7 +25,9 @@ export class ToolWorkflowV2 implements INodeType {
}; };
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
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 name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex) as string;

View file

@ -187,6 +187,66 @@ describe('WorkflowTool::WorkflowToolService', () => {
expect(result.subExecutionId).toBe('test-execution'); 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 () => { it('should throw error when workflow execution fails', async () => {
jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed')); jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed'));

View file

@ -1,7 +1,6 @@
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import get from 'lodash/get'; import { isArray, isObject } from 'lodash';
import isObject from 'lodash/isObject';
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; 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 * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions'; import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
@ -29,6 +28,10 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { z } from 'zod'; 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 Main class for creating the Workflow tool
Processes the node parameters and creates AI Agent tool capable of executing n8n workflows 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 // Sub-workflow execution id, will be set after the sub-workflow is executed
private subExecutionId: string | undefined; 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 const subWorkflowInputs = this.baseContext.getNode().parameters
.workflowInputs as ResourceMapperValue; .workflowInputs as ResourceMapperValue;
this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0; this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0;
this.returnAllItems = options?.returnAllItems ?? false;
} }
// Creates the tool based on the provided parameters // Creates the tool based on the provided parameters
@ -65,7 +74,7 @@ export class WorkflowToolService {
const toolHandler = async ( const toolHandler = async (
query: string | IDataObject, query: string | IDataObject,
runManager?: CallbackManagerForToolRun, runManager?: CallbackManagerForToolRun,
): Promise<string> => { ): Promise<IDataObject | IDataObject[] | string> => {
const localRunIndex = runIndex++; const localRunIndex = runIndex++;
// We need to clone the context here to handle runIndex correctly // We need to clone the context here to handle runIndex correctly
// Otherwise the runIndex will be shared between different executions // Otherwise the runIndex will be shared between different executions
@ -74,10 +83,23 @@ export class WorkflowToolService {
runIndex: localRunIndex, runIndex: localRunIndex,
inputData: [[{ json: { query } }]], inputData: [[{ json: { query } }]],
}); });
try { try {
const response = await this.runFunction(context, query, itemIndex, runManager); const response = await this.runFunction(context, query, itemIndex, runManager);
const processedResponse = this.handleToolResponse(response); const processedResponse = this.handleToolResponse(response);
let responseData: INodeExecutionData[];
if (isNodeExecutionData(response)) {
responseData = response;
} else {
const reParsedData = jsonParse<IDataObject>(processedResponse, {
fallbackValue: { response: processedResponse },
});
responseData = [{ json: reParsedData }];
}
// Once the sub-workflow is executed, add the output data to the context // 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 // This will be used to link the sub-workflow execution in the parent workflow
let metadata: ITaskMetadata | undefined; let metadata: ITaskMetadata | undefined;
@ -89,13 +111,11 @@ export class WorkflowToolService {
}, },
}; };
} }
const json = jsonParse<IDataObject>(processedResponse, {
fallbackValue: { response: processedResponse },
});
void context.addOutputData( void context.addOutputData(
NodeConnectionType.AiTool, NodeConnectionType.AiTool,
localRunIndex, localRunIndex,
[[{ json }]], [responseData],
metadata, metadata,
); );
@ -126,6 +146,14 @@ export class WorkflowToolService {
return response.toString(); return response.toString();
} }
if (isNodeExecutionData(response)) {
return JSON.stringify(
response.map((item) => item.json),
null,
2,
);
}
if (isObject(response)) { if (isObject(response)) {
return JSON.stringify(response, null, 2); return JSON.stringify(response, null, 2);
} }
@ -148,7 +176,7 @@ export class WorkflowToolService {
items: INodeExecutionData[], items: INodeExecutionData[],
workflowProxy: IWorkflowDataProxyData, workflowProxy: IWorkflowDataProxyData,
runManager?: CallbackManagerForToolRun, runManager?: CallbackManagerForToolRun,
): Promise<{ response: string; subExecutionId: string }> { ): Promise<{ response: string | IDataObject | INodeExecutionData[]; subExecutionId: string }> {
let receivedData: ExecuteWorkflowData; let receivedData: ExecuteWorkflowData;
try { try {
receivedData = await context.executeWorkflow(workflowInfo, items, runManager?.getChild(), { receivedData = await context.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
@ -163,7 +191,12 @@ export class WorkflowToolService {
throw new NodeOperationError(context.getNode(), error as Error); 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) { if (response === undefined) {
throw new NodeOperationError( throw new NodeOperationError(
context.getNode(), context.getNode(),
@ -183,7 +216,7 @@ export class WorkflowToolService {
query: string | IDataObject, query: string | IDataObject,
itemIndex: number, itemIndex: number,
runManager?: CallbackManagerForToolRun, runManager?: CallbackManagerForToolRun,
): Promise<string> { ): Promise<string | IDataObject | INodeExecutionData[]> {
const source = context.getNodeParameter('source', itemIndex) as string; const source = context.getNodeParameter('source', itemIndex) as string;
const workflowProxy = context.getWorkflowDataProxy(0); const workflowProxy = context.getWorkflowDataProxy(0);
@ -304,7 +337,10 @@ export class WorkflowToolService {
private async createStructuredTool( private async createStructuredTool(
name: string, name: string,
description: string, description: string,
func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise<string>, func: (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
) => Promise<string | IDataObject | IDataObject[]>,
): Promise<DynamicStructuredTool | DynamicTool> { ): Promise<DynamicStructuredTool | DynamicTool> {
const collectedArguments = await this.extractFromAIParameters(); const collectedArguments = await this.extractFromAIParameters();

View file

@ -12,7 +12,7 @@ export const versionDescription: INodeTypeDescription = {
defaults: { defaults: {
name: 'Call n8n Workflow Tool', name: 'Call n8n Workflow Tool',
}, },
version: [2], version: [2, 2.1],
inputs: [], inputs: [],
outputs: [NodeConnectionType.AiTool], outputs: [NodeConnectionType.AiTool],
outputNames: ['Tool'], outputNames: ['Tool'],

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/n8n-nodes-langchain", "name": "@n8n/n8n-nodes-langchain",
"version": "1.81.0", "version": "1.82.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/permissions", "name": "@n8n/permissions",
"version": "0.18.0", "version": "0.19.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/task-runner", "name": "@n8n/task-runner",
"version": "1.18.0", "version": "1.19.0",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"start": "node dist/start.js", "start": "node dist/start.js",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/typescript-config", "name": "@n8n/typescript-config",
"version": "1.1.0", "version": "1.2.0",
"type": "module", "type": "module",
"files": [ "files": [
"tsconfig.backend.json", "tsconfig.backend.json",

View file

@ -1,7 +1,7 @@
{ {
"name": "@n8n/utils", "name": "@n8n/utils",
"type": "module", "type": "module",
"version": "1.2.0", "version": "1.3.0",
"files": [ "files": [
"dist" "dist"
], ],

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/vitest-config", "name": "@n8n/vitest-config",
"version": "1.1.0", "version": "1.2.0",
"type": "module", "type": "module",
"peerDependencies": { "peerDependencies": {
"vite": "catalog:frontend", "vite": "catalog:frontend",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "1.81.0", "version": "1.82.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -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<typeof auth>;
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<User>({
id: '123',
role: 'global:member',
mfaEnabled: false,
});
const body = mock<LoginRequestDto>({
emailOrLdapLoginId: 'non email',
password: 'password',
});
const req = mock<AuthenticatedRequest>({
user: member,
body,
browserId,
});
const res = mock<Response>();
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,
});
});
});
});

View file

@ -1,4 +1,5 @@
import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types'; import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types';
import { isEmail } from 'class-validator';
import { Response } from 'express'; import { Response } from 'express';
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
@ -44,14 +45,19 @@ export class AuthController {
res: Response, res: Response,
@Body payload: LoginRequestDto, @Body payload: LoginRequestDto,
): Promise<PublicUser | undefined> { ): Promise<PublicUser | undefined> {
const { email, password, mfaCode, mfaRecoveryCode } = payload; const { emailOrLdapLoginId, password, mfaCode, mfaRecoveryCode } = payload;
let user: User | undefined; let user: User | undefined;
let usedAuthenticationMethod = getCurrentAuthenticationMethod(); let usedAuthenticationMethod = getCurrentAuthenticationMethod();
if (usedAuthenticationMethod === 'email' && !isEmail(emailOrLdapLoginId)) {
throw new BadRequestError('Invalid email address');
}
if (isSamlCurrentAuthenticationMethod()) { if (isSamlCurrentAuthenticationMethod()) {
// attempt to fetch user data with the credentials, but don't log in yet // 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 the user is an owner, continue with the login
if ( if (
preliminaryUser?.role === 'global:owner' || preliminaryUser?.role === 'global:owner' ||
@ -63,15 +69,15 @@ export class AuthController {
throw new AuthError('SSO is enabled, please log in with SSO'); throw new AuthError('SSO is enabled, please log in with SSO');
} }
} else if (isLdapCurrentAuthenticationMethod()) { } else if (isLdapCurrentAuthenticationMethod()) {
const preliminaryUser = await handleEmailLogin(email, password); const preliminaryUser = await handleEmailLogin(emailOrLdapLoginId, password);
if (preliminaryUser?.role === 'global:owner') { if (preliminaryUser?.role === 'global:owner') {
user = preliminaryUser; user = preliminaryUser;
usedAuthenticationMethod = 'email'; usedAuthenticationMethod = 'email';
} else { } else {
user = await handleLdapLogin(email, password); user = await handleLdapLogin(emailOrLdapLoginId, password);
} }
} else { } else {
user = await handleEmailLogin(email, password); user = await handleEmailLogin(emailOrLdapLoginId, password);
} }
if (user) { if (user) {
@ -101,7 +107,7 @@ export class AuthController {
} }
this.eventService.emit('user-login-failed', { this.eventService.emit('user-login-failed', {
authenticationMethod: usedAuthenticationMethod, authenticationMethod: usedAuthenticationMethod,
userEmail: email, userEmail: emailOrLdapLoginId,
reason: 'wrong credentials', reason: 'wrong credentials',
}); });
throw new AuthError('Wrong username or password. Do you have caps lock on?'); throw new AuthError('Wrong username or password. Do you have caps lock on?');

View file

@ -43,7 +43,7 @@ describe('POST /login', () => {
test('should log user in', async () => { test('should log user in', async () => {
const response = await testServer.authlessAgent.post('/login').send({ const response = await testServer.authlessAgent.post('/login').send({
email: owner.email, emailOrLdapLoginId: owner.email,
password: ownerPassword, password: ownerPassword,
}); });
@ -87,7 +87,7 @@ describe('POST /login', () => {
await mfaService.enableMfa(owner.id); await mfaService.enableMfa(owner.id);
const response = await testServer.authlessAgent.post('/login').send({ const response = await testServer.authlessAgent.post('/login').send({
email: owner.email, emailOrLdapLoginId: owner.email,
password: ownerPassword, password: ownerPassword,
mfaCode: mfaService.totp.generateTOTP(secret), mfaCode: mfaService.totp.generateTOTP(secret),
}); });
@ -131,7 +131,7 @@ describe('POST /login', () => {
}); });
const response = await testServer.authlessAgent.post('/login').send({ const response = await testServer.authlessAgent.post('/login').send({
email: member.email, emailOrLdapLoginId: member.email,
password, password,
}); });
expect(response.statusCode).toBe(403); expect(response.statusCode).toBe(403);
@ -148,19 +148,16 @@ describe('POST /login', () => {
expect(response.statusCode).toBe(200); 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({ const response = await testServer.authlessAgent.post('/login').send({
email: 'invalid-email', emailOrLdapLoginId: 'invalid-email',
password: ownerPassword, password: ownerPassword,
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ expect(response.body.message).toBe('Invalid email address');
validation: 'email',
code: 'invalid_string',
message: 'Invalid email',
path: ['email'],
});
}); });
}); });

View file

@ -470,7 +470,7 @@ describe('POST /login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: ldapUser.mail, password: 'password' }); .send({ emailOrLdapLoginId: ldapUser.mail, password: 'password' });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.headers['set-cookie']).toBeDefined(); expect(response.headers['set-cookie']).toBeDefined();
@ -529,7 +529,7 @@ describe('POST /login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: owner.email, password: 'password' }); .send({ emailOrLdapLoginId: owner.email, password: 'password' });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.data?.signInType).toBeDefined(); expect(response.body.data?.signInType).toBeDefined();

View file

@ -268,7 +268,7 @@ describe('Change password with MFA enabled', () => {
.authAgentFor(user) .authAgentFor(user)
.post('/login') .post('/login')
.send({ .send({
email: user.email, emailOrLdapLoginId: user.email,
password: newPassword, password: newPassword,
mfaCode: new TOTPService().generateTOTP(rawSecret), mfaCode: new TOTPService().generateTOTP(rawSecret),
}) })
@ -306,7 +306,10 @@ describe('Login', () => {
const user = await createUser({ password }); 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 () => { test('GET /login should not include mfaSecret and mfaRecoveryCodes property in response', async () => {
@ -323,7 +326,7 @@ describe('Login', () => {
await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword }) .send({ emailOrLdapLoginId: user.email, password: rawPassword })
.expect(401); .expect(401);
}); });
@ -333,7 +336,7 @@ describe('Login', () => {
await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaCode: 'wrongvalue' }) .send({ emailOrLdapLoginId: user.email, password: rawPassword, mfaCode: 'wrongvalue' })
.expect(401); .expect(401);
}); });
@ -342,7 +345,7 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword }) .send({ emailOrLdapLoginId: user.email, password: rawPassword })
.expect(401); .expect(401);
expect(response.body.code).toBe(998); expect(response.body.code).toBe(998);
@ -355,7 +358,7 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaCode: token }) .send({ emailOrLdapLoginId: user.email, password: rawPassword, mfaCode: token })
.expect(200); .expect(200);
const data = response.body.data; const data = response.body.data;
@ -370,7 +373,11 @@ describe('Login', () => {
await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' }) .send({
emailOrLdapLoginId: user.email,
password: rawPassword,
mfaRecoveryCode: 'wrongvalue',
})
.expect(401); .expect(401);
}); });
@ -379,7 +386,11 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }) .send({
emailOrLdapLoginId: user.email,
password: rawPassword,
mfaRecoveryCode: rawRecoveryCodes[0],
})
.expect(200); .expect(200);
const data = response.body.data; const data = response.body.data;

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "1.80.0", "version": "1.81.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/chat", "name": "@n8n/chat",
"version": "0.34.0", "version": "0.35.0",
"scripts": { "scripts": {
"dev": "pnpm run storybook", "dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle", "build": "pnpm build:vite && pnpm build:bundle",

View file

@ -1,7 +1,7 @@
{ {
"name": "@n8n/composables", "name": "@n8n/composables",
"type": "module", "type": "module",
"version": "1.2.0", "version": "1.3.0",
"files": [ "files": [
"dist" "dist"
], ],

View file

@ -1,6 +1,6 @@
{ {
"name": "@n8n/design-system", "name": "@n8n/design-system",
"version": "1.69.0", "version": "1.70.0",
"main": "src/index.ts", "main": "src/index.ts",
"import": "src/index.ts", "import": "src/index.ts",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "1.81.0", "version": "1.82.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View file

@ -1,4 +1,5 @@
import type { import type {
LoginRequestDto,
PasswordUpdateRequestDto, PasswordUpdateRequestDto,
SettingsUpdateRequestDto, SettingsUpdateRequestDto,
UserUpdateRequestDto, UserUpdateRequestDto,
@ -21,7 +22,7 @@ export async function loginCurrentUser(
export async function login( export async function login(
context: IRestApiContext, context: IRestApiContext,
params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string }, params: LoginRequestDto,
): Promise<CurrentUserResponse> { ): Promise<CurrentUserResponse> {
return await makeRestApiRequest(context, 'POST', '/login', params); return await makeRestApiRequest(context, 'POST', '/login', params);
} }

View file

@ -166,14 +166,14 @@ describe('RunData', () => {
expect(queryByTestId('ndv-pin-data')).not.toBeInTheDocument(); 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({ const { getByTestId } = render({
defaultRunItems: [], defaultRunItems: [],
displayMode: 'table', displayMode: 'table',
pinnedData: [{ json: { name: 'Test' } }], pinnedData: [{ json: { name: 'Test' } }],
}); });
const pinDataButton = getByTestId('ndv-pin-data'); 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 () => { it('should render callout when data is pinned in output panel', async () => {

View file

@ -528,8 +528,7 @@ const showPinButton = computed(() => {
const pinButtonDisabled = computed( const pinButtonDisabled = computed(
() => () =>
pinnedData.hasData.value || (!rawInputData.value.length && !pinnedData.hasData.value) ||
!rawInputData.value.length ||
!!binaryData.value?.length || !!binaryData.value?.length ||
isReadOnlyRoute.value || isReadOnlyRoute.value ||
readOnlyEnv.value, readOnlyEnv.value,

View file

@ -30,7 +30,7 @@ const renderComponent = createComponentRenderer(RunDataPinButton, {
}, },
dataPinningDocsUrl: '', dataPinningDocsUrl: '',
pinnedData: { pinnedData: {
hasData: false, hasData: { value: false },
}, },
disabled: false, disabled: false,
}, },
@ -121,4 +121,30 @@ describe('RunDataPinButton.vue', () => {
expect(getByRole('tooltip')).toBeVisible(); expect(getByRole('tooltip')).toBeVisible();
expect(getByRole('tooltip')).toHaveTextContent('disabled'); 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();
});
}); });

View file

@ -36,6 +36,10 @@ const visible = computed(() =>
<div v-else-if="props.tooltipContentsVisibility.pinDataDiscoveryTooltipContent"> <div v-else-if="props.tooltipContentsVisibility.pinDataDiscoveryTooltipContent">
{{ locale.baseText('node.discovery.pinData.ndv') }} {{ locale.baseText('node.discovery.pinData.ndv') }}
</div> </div>
<div v-else>
<div v-if="pinnedData.hasData.value">
<strong>{{ locale.baseText('ndv.pinData.unpin.title') }}</strong>
</div>
<div v-else> <div v-else>
<strong>{{ locale.baseText('ndv.pinData.pin.title') }}</strong> <strong>{{ locale.baseText('ndv.pinData.pin.title') }}</strong>
<N8nText size="small" tag="p"> <N8nText size="small" tag="p">
@ -46,6 +50,7 @@ const visible = computed(() =>
</N8nLink> </N8nLink>
</N8nText> </N8nText>
</div> </div>
</div>
</template> </template>
<N8nIconButton <N8nIconButton
:class="$style.pinDataButton" :class="$style.pinDataButton"

View file

@ -1042,9 +1042,10 @@
"ndv.title.rename": "Rename", "ndv.title.rename": "Rename",
"ndv.title.renameNode": "Rename node", "ndv.title.renameNode": "Rename node",
"ndv.pinData.pin.title": "Pin data", "ndv.pinData.pin.title": "Pin data",
"ndv.pinData.pin.description": "Node will always output this data instead of executing.", "ndv.pinData.pin.description": "Node will always output current data instead of executing. Doesn't apply to production executions.",
"ndv.pinData.pin.binary": "Pin Data is disabled as this node's output contains binary data.", "ndv.pinData.pin.binary": "Pin Data is disabled as this node's output contains binary data.",
"ndv.pinData.pin.link": "More info", "ndv.pinData.pin.link": "More info",
"ndv.pinData.unpin.title": "Unpin data",
"ndv.pinData.pin.multipleRuns.title": "Run #{index} was pinned", "ndv.pinData.pin.multipleRuns.title": "Run #{index} was pinned",
"ndv.pinData.pin.multipleRuns.description": "This run will be outputted each time the node is run.", "ndv.pinData.pin.multipleRuns.description": "This run will be outputted each time the node is run.",
"ndv.pinData.unpinAndExecute.title": "Unpin output data?", "ndv.pinData.unpinAndExecute.title": "Unpin output data?",

View file

@ -1,4 +1,5 @@
import type { import type {
LoginRequestDto,
PasswordUpdateRequestDto, PasswordUpdateRequestDto,
SettingsUpdateRequestDto, SettingsUpdateRequestDto,
UserUpdateRequestDto, UserUpdateRequestDto,
@ -181,12 +182,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}; };
}; };
const loginWithCreds = async (params: { const loginWithCreds = async (params: LoginRequestDto) => {
email: string;
password: string;
mfaCode?: string;
mfaRecoveryCode?: string;
}) => {
const user = await usersApi.login(rootStore.restApiContext, params); const user = await usersApi.login(rootStore.restApiContext, params);
if (!user) { if (!user) {
return; return;

View file

@ -3,6 +3,7 @@ import Logo from '@/components/Logo/Logo.vue';
import SSOLogin from '@/components/SSOLogin.vue'; import SSOLogin from '@/components/SSOLogin.vue';
import type { IFormBoxConfig } from '@/Interface'; import type { IFormBoxConfig } from '@/Interface';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import type { EmailOrLdapLoginIdAndPassword } from './SigninView.vue';
withDefaults( withDefaults(
defineProps<{ defineProps<{
@ -19,7 +20,7 @@ withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
update: [{ name: string; value: string }]; update: [{ name: string; value: string }];
submit: [values: { [key: string]: string }]; submit: [values: EmailOrLdapLoginIdAndPassword];
secondaryClick: []; secondaryClick: [];
}>(); }>();
@ -27,7 +28,7 @@ const onUpdate = (e: { name: string; value: string }) => {
emit('update', e); emit('update', e);
}; };
const onSubmit = (values: { [key: string]: string }) => { const onSubmit = (values: EmailOrLdapLoginIdAndPassword) => {
emit('submit', values); emit('submit', values);
}; };

View file

@ -85,7 +85,7 @@ describe('SigninView', () => {
await userEvent.click(submitButton); await userEvent.click(submitButton);
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({ expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
email: 'test@n8n.io', emailOrLdapLoginId: 'test@n8n.io',
password: 'password', password: 'password',
mfaCode: undefined, mfaCode: undefined,
mfaRecoveryCode: undefined, mfaRecoveryCode: undefined,

View file

@ -15,6 +15,14 @@ import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import type { IFormBoxConfig } from '@/Interface'; import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/constants'; 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<LoginRequestDto, 'mfaCode' | 'mfaRecoveryCode'>;
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@ -29,7 +37,7 @@ const telemetry = useTelemetry();
const loading = ref(false); const loading = ref(false);
const showMfaView = ref(false); const showMfaView = ref(false);
const email = ref(''); const emailOrLdapLoginId = ref('');
const password = ref(''); const password = ref('');
const reportError = ref(false); const reportError = ref(false);
@ -50,7 +58,7 @@ const formConfig: IFormBoxConfig = reactive({
redirectLink: '/forgot-password', redirectLink: '/forgot-password',
inputs: [ inputs: [
{ {
name: 'email', name: 'emailOrLdapLoginId',
properties: { properties: {
label: emailLabel.value, label: emailLabel.value,
type: 'email', 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({ await login({
email: email.value, emailOrLdapLoginId: emailOrLdapLoginId.value,
password: password.value, password: password.value,
mfaCode: form.mfaCode, mfaCode: form.mfaCode,
mfaRecoveryCode: form.mfaRecoveryCode, mfaRecoveryCode: form.mfaRecoveryCode,
}); });
}; };
const isFormWithEmailAndPassword = (values: { const onEmailPasswordSubmitted = async (form: EmailOrLdapLoginIdAndPassword) => {
[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;
await login(form); await login(form);
}; };
@ -111,16 +112,11 @@ const getRedirectQueryParameter = () => {
return redirect; return redirect;
}; };
const login = async (form: { const login = async (form: LoginRequestDto) => {
email: string;
password: string;
mfaCode?: string;
mfaRecoveryCode?: string;
}) => {
try { try {
loading.value = true; loading.value = true;
await usersStore.loginWithCreds({ await usersStore.loginWithCreds({
email: form.email, emailOrLdapLoginId: form.emailOrLdapLoginId,
password: form.password, password: form.password,
mfaCode: form.mfaCode, mfaCode: form.mfaCode,
mfaRecoveryCode: form.mfaRecoveryCode, mfaRecoveryCode: form.mfaRecoveryCode,
@ -185,8 +181,8 @@ const onFormChanged = (toForm: string) => {
reportError.value = false; reportError.value = false;
} }
}; };
const cacheCredentials = (form: { email: string; password: string }) => { const cacheCredentials = (form: EmailOrLdapLoginIdAndPassword) => {
email.value = form.email; emailOrLdapLoginId.value = form.emailOrLdapLoginId;
password.value = form.password; password.value = form.password;
}; };
</script> </script>

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-node-dev", "name": "n8n-node-dev",
"version": "1.80.0", "version": "1.81.0",
"description": "CLI to simplify n8n credentials/node development", "description": "CLI to simplify n8n credentials/node development",
"main": "dist/src/index", "main": "dist/src/index",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",

View file

@ -457,14 +457,12 @@ export class Github implements INodeType {
required: true, required: true,
modes: [ modes: [
{ {
displayName: 'Workflow', displayName: 'From List',
name: 'list', name: 'list',
type: 'list', type: 'list',
placeholder: 'Select a workflow...', placeholder: 'Select a workflow...',
typeOptions: { typeOptions: {
searchListMethod: 'getWorkflows', 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: { displayOptions: {
show: { show: {
@ -2501,7 +2514,9 @@ export class Github implements INodeType {
requestMethod = 'POST'; 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`; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`;
body.ref = this.getNodeParameter('ref', i) as string; body.ref = this.getNodeParameter('ref', i) as string;

View file

@ -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);
});
});

View file

@ -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"
}
}

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-base", "name": "n8n-nodes-base",
"version": "1.80.0", "version": "1.81.0",
"description": "Base nodes of n8n", "description": "Base nodes of n8n",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-workflow", "name": "n8n-workflow",
"version": "1.79.0", "version": "1.80.0",
"description": "Workflow base code of n8n", "description": "Workflow base code of n8n",
"main": "dist/index.js", "main": "dist/index.js",
"module": "src/index.ts", "module": "src/index.ts",