diff --git a/CHANGELOG.md b/CHANGELOG.md index 56003af56b..e7d455c95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +# [1.71.0](https://github.com/n8n-io/n8n/compare/n8n@1.70.0...n8n@1.71.0) (2024-12-04) + + +### Bug Fixes + +* **core:** Fix push for waiting executions ([#11984](https://github.com/n8n-io/n8n/issues/11984)) ([8d71307](https://github.com/n8n-io/n8n/commit/8d71307da0398e7e39bf53e8e1cfa21ac1ceaf69)) +* **core:** Improve header parameter parsing on http client responses ([#11953](https://github.com/n8n-io/n8n/issues/11953)) ([41e9e39](https://github.com/n8n-io/n8n/commit/41e9e39b5b53ecd9d8d1b385df65a26ecb9bccd8)) +* **core:** Opt-out from optimizations if `$item` is used ([#12036](https://github.com/n8n-io/n8n/issues/12036)) ([872535a](https://github.com/n8n-io/n8n/commit/872535a40c85dcfad3a4b27c57c026ae003f562f)) +* **core:** Use the configured timezone in task runner ([#12032](https://github.com/n8n-io/n8n/issues/12032)) ([2e6845a](https://github.com/n8n-io/n8n/commit/2e6845afcbc30dff73c3f3f15f21278cab397387)) +* **core:** Validate node name when creating `NodeOperationErrror` ([#11999](https://github.com/n8n-io/n8n/issues/11999)) ([e68c9da](https://github.com/n8n-io/n8n/commit/e68c9da30c31cd5f994cb01ce759192562bfbd40)) +* **editor:** Add execution concurrency info and paywall ([#11847](https://github.com/n8n-io/n8n/issues/11847)) ([57d3269](https://github.com/n8n-io/n8n/commit/57d3269e400ee4e7e3636614870ebdfdb0aa8c1d)) +* **editor:** Fix bug causing connection lines to disappear when hovering stickies ([#11950](https://github.com/n8n-io/n8n/issues/11950)) ([439a1cc](https://github.com/n8n-io/n8n/commit/439a1cc4f39243e91715b21a84b8e7266ce872cd)) +* **editor:** Fix canvas keybindings using splitter keys such as zooming using `+` key ([#12022](https://github.com/n8n-io/n8n/issues/12022)) ([6af9c82](https://github.com/n8n-io/n8n/commit/6af9c82af6020e99d61e442ee9c2d40761baf027)) +* **editor:** Fix community check ([#11979](https://github.com/n8n-io/n8n/issues/11979)) ([af0398a](https://github.com/n8n-io/n8n/commit/af0398a5e3a8987c01c7112e6f689b35e1ef92fe)) +* **editor:** Fix copy/paste keyboard events in canvas chat ([#12004](https://github.com/n8n-io/n8n/issues/12004)) ([967340a](https://github.com/n8n-io/n8n/commit/967340a2938a79c89319121bf57a8d654f88e06c)) +* **editor:** Fix node showing as successful if errors exists on subsequent runs ([#12019](https://github.com/n8n-io/n8n/issues/12019)) ([8616b17](https://github.com/n8n-io/n8n/commit/8616b17cc6c305da69bbb54fd56ab7cb34213f7c)) +* **editor:** Fix pin data showing up in production executions on new canvas ([#11951](https://github.com/n8n-io/n8n/issues/11951)) ([5f6f8a1](https://github.com/n8n-io/n8n/commit/5f6f8a1bddfd76b586c08da821e8b59070f449fc)) +* **editor:** Handle source control initialization to prevent UI form crashing ([#11776](https://github.com/n8n-io/n8n/issues/11776)) ([6be8e86](https://github.com/n8n-io/n8n/commit/6be8e86c45bd64d000bc95d2ef2d68220e930c02)) +* **editor:** Implement dirty nodes for partial executions ([#11739](https://github.com/n8n-io/n8n/issues/11739)) ([b8da4ff](https://github.com/n8n-io/n8n/commit/b8da4ff9edb0fbb0093c4c41fe11f8e67b696ca3)) +* **editor:** Resolve going back from Settings ([#11958](https://github.com/n8n-io/n8n/issues/11958)) ([d74423c](https://github.com/n8n-io/n8n/commit/d74423c75198d38d0d99a1879051b5e964ecae74)) +* **editor:** Unify executions card label color ([#11949](https://github.com/n8n-io/n8n/issues/11949)) ([fc79718](https://github.com/n8n-io/n8n/commit/fc797188d63e87df34b3a153eb4a0d0b7361b3f5)) +* **editor:** Use optional chaining for all members in execution data when using the debug feature ([#12024](https://github.com/n8n-io/n8n/issues/12024)) ([67aa0c9](https://github.com/n8n-io/n8n/commit/67aa0c9107bda16b1cb6d273e17c3cde77035f51)) +* **GraphQL Node:** Throw error if GraphQL variables are not objects or strings ([#11904](https://github.com/n8n-io/n8n/issues/11904)) ([85f30b2](https://github.com/n8n-io/n8n/commit/85f30b27ae282da58a25186d13ff17196dcd7d9c)) +* **HTTP Request Node:** Use iconv-lite to decode http responses, to support more encoding types ([#11930](https://github.com/n8n-io/n8n/issues/11930)) ([461b39c](https://github.com/n8n-io/n8n/commit/461b39c5df5dd446cb8ceef469b204c7c5111229)) +* Load workflows with unconnected Switch outputs ([#12020](https://github.com/n8n-io/n8n/issues/12020)) ([abc851c](https://github.com/n8n-io/n8n/commit/abc851c0cff298607a0dc2f2882aa17136898f45)) +* **n8n Form Node:** Use https to load google fonts ([#11948](https://github.com/n8n-io/n8n/issues/11948)) ([eccd924](https://github.com/n8n-io/n8n/commit/eccd924f5e8dbe59e37099d1a6fbe8866fef55bf)) +* **Telegram Trigger Node:** Fix header secret check ([#12018](https://github.com/n8n-io/n8n/issues/12018)) ([f16de4d](https://github.com/n8n-io/n8n/commit/f16de4db01c0496205635a3203a44098e7908453)) +* **Webflow Node:** Fix issue with pagination in v2 node ([#11934](https://github.com/n8n-io/n8n/issues/11934)) ([1eb94bc](https://github.com/n8n-io/n8n/commit/1eb94bcaf54d9e581856ce0b87253e1c28fa68e2)) +* **Webflow Node:** Fix issue with publishing items ([#11982](https://github.com/n8n-io/n8n/issues/11982)) ([0a8a57e](https://github.com/n8n-io/n8n/commit/0a8a57e4ec8081ab1a53f36d686b3d5dcaae2476)) + + +### Features + +* **AI Transform Node:** Node Prompt improvements ([#11611](https://github.com/n8n-io/n8n/issues/11611)) ([40a7445](https://github.com/n8n-io/n8n/commit/40a7445f0873af2cdbd10b12bd691c07a43e27cc)) +* **Code Node:** Warning if pairedItem absent or could not be auto mapped ([#11737](https://github.com/n8n-io/n8n/issues/11737)) ([3a5bd12](https://github.com/n8n-io/n8n/commit/3a5bd129459272cbac960ae2754db3028943f87e)) +* **editor:** Canvas chat UI & UX improvements ([#11924](https://github.com/n8n-io/n8n/issues/11924)) ([1e25774](https://github.com/n8n-io/n8n/commit/1e25774541461c86da5c4af8efec792e2814eeb1)) +* **editor:** Persist user's preferred display modes on localStorage ([#11929](https://github.com/n8n-io/n8n/issues/11929)) ([bd69316](https://github.com/n8n-io/n8n/commit/bd693162b86a21c90880bab2c2e67aab733095ff)) + + +### Performance Improvements + +* **editor:** Virtualize SchemaView ([#11694](https://github.com/n8n-io/n8n/issues/11694)) ([9c6def9](https://github.com/n8n-io/n8n/commit/9c6def91975764522fa52cdf21e9cb5bdb4d721d)) + + + # [1.70.0](https://github.com/n8n-io/n8n/compare/n8n@1.69.0...n8n@1.70.0) (2024-11-27) diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index 251db6e75d..bc27048219 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -76,6 +76,10 @@ export function getCanvasNodes() { ); } +export function getCanvasNodeByName(nodeName: string) { + return getCanvasNodes().filter(`:contains(${nodeName})`); +} + export function getSaveButton() { return cy.getByTestId('workflow-save-button'); } @@ -194,3 +198,8 @@ export function pasteWorkflow(workflow: object) { export function clickZoomToFit() { getZoomToFitButton().click(); } + +export function deleteNode(name: string) { + getCanvasNodeByName(name).first().click(); + cy.get('body').type('{del}'); +} diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts index 759964f155..05949a188c 100644 --- a/cypress/e2e/27-two-factor-authentication.cy.ts +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -68,16 +68,28 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => { mainSidebar.actions.signout(); }); - it('Should be able to disable MFA in account with MFA code ', () => { + it('Should be able to disable MFA in account with MFA code', () => { const { email, password } = user; signinPage.actions.loginWithEmailAndPassword(email, password); personalSettingsPage.actions.enableMfa(); mainSidebar.actions.signout(); - const loginToken = generateOTPToken(user.mfaSecret); - mfaLoginPage.actions.loginWithMfaCode(email, password, loginToken); + const mfaCode = generateOTPToken(user.mfaSecret); + mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode); const disableToken = generateOTPToken(user.mfaSecret); personalSettingsPage.actions.disableMfa(disableToken); personalSettingsPage.getters.enableMfaButton().should('exist'); mainSidebar.actions.signout(); }); + + it('Should be able to disable MFA in account with recovery code', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + const mfaCode = generateOTPToken(user.mfaSecret); + mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode); + personalSettingsPage.actions.disableMfa(user.mfaRecoveryCodes[0]); + personalSettingsPage.getters.enableMfaButton().should('exist'); + mainSidebar.actions.signout(); + }); }); diff --git a/cypress/e2e/2929-ado-can-load-old-switch-node-workflows.cy.ts b/cypress/e2e/2929-ado-can-load-old-switch-node-workflows.cy.ts new file mode 100644 index 0000000000..39edc54163 --- /dev/null +++ b/cypress/e2e/2929-ado-can-load-old-switch-node-workflows.cy.ts @@ -0,0 +1,17 @@ +import { + deleteNode, + getCanvasNodes, + navigateToNewWorkflowPage, + pasteWorkflow, +} from '../composables/workflow'; +import Workflow from '../fixtures/Switch_node_with_null_connection.json'; + +describe('ADO-2929 can load Switch nodes', () => { + it('can load workflows with Switch nodes with null at connection index', () => { + navigateToNewWorkflowPage(); + pasteWorkflow(Workflow); + getCanvasNodes().should('have.length', 3); + deleteNode('Switch'); + getCanvasNodes().should('have.length', 2); + }); +}); diff --git a/cypress/fixtures/Switch_node_with_null_connection.json b/cypress/fixtures/Switch_node_with_null_connection.json new file mode 100644 index 0000000000..325e097bd0 --- /dev/null +++ b/cypress/fixtures/Switch_node_with_null_connection.json @@ -0,0 +1,85 @@ +{ + "nodes": [ + { + "parameters": {}, + "id": "418350b8-b402-4d3b-93ba-3794d36c1ad5", + "name": "When clicking \"Test workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [440, 380] + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "", + "rightValue": "", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + } + }, + {}, + {} + ] + }, + "options": {} + }, + "id": "b67ad46f-6b0d-4ff4-b2d2-dfbde44e287c", + "name": "Switch", + "type": "n8n-nodes-base.switch", + "typeVersion": 3, + "position": [660, 380] + }, + { + "parameters": { + "options": {} + }, + "id": "24731c11-e2a4-4854-81a6-277ce72e8a93", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.3, + "position": [840, 480] + } + ], + "connections": { + "When clicking \"Test workflow\"": { + "main": [ + [ + { + "node": "Switch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch": { + "main": [ + null, + null, + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {} +} diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 210fc0630f..f4c1da897b 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh / # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=0.6.0-rc +ARG LAUNCHER_VERSION=0.7.0-rc COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json # Download, verify, then extract the launcher binary RUN \ diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index fe4aee41dc..0f28ee706f 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -24,7 +24,7 @@ RUN set -eux; \ # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=0.6.0-rc +ARG LAUNCHER_VERSION=0.7.0-rc COPY n8n-task-runners.json /etc/n8n-task-runners.json # Download, verify, then extract the launcher binary RUN \ diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json index 10cf338731..d9575997c0 100644 --- a/docker/images/n8n/n8n-task-runners.json +++ b/docker/images/n8n/n8n-task-runners.json @@ -7,13 +7,14 @@ "args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"], "allowed-env": [ "PATH", + "GENERIC_TIMEZONE", "N8N_RUNNERS_GRANT_TOKEN", - "N8N_RUNNERS_N8N_URI", + "N8N_RUNNERS_TASK_BROKER_URI", "N8N_RUNNERS_MAX_PAYLOAD", "N8N_RUNNERS_MAX_CONCURRENCY", - "N8N_RUNNERS_SERVER_ENABLED", - "N8N_RUNNERS_SERVER_HOST", - "N8N_RUNNERS_SERVER_PORT", + "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED", + "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST", + "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT", "NODE_FUNCTION_ALLOW_BUILTIN", "NODE_FUNCTION_ALLOW_EXTERNAL", "NODE_OPTIONS", diff --git a/package.json b/package.json index 6a44807240..e2a0628773 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.70.0", + "version": "1.71.0", "private": true, "engines": { "node": ">=20.15", @@ -80,7 +80,7 @@ "tslib": "^2.6.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.2", - "vue-tsc": "^2.1.6", + "vue-tsc": "^2.1.10", "ws": ">=8.17.1" }, "patchedDependencies": { @@ -90,7 +90,7 @@ "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", "@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch", "@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch", - "vue-tsc@2.1.6": "patches/vue-tsc@2.1.6.patch" + "vue-tsc@2.1.10": "patches/vue-tsc@2.1.10.patch" } } } diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 5b6ce236e0..9f045e31d4 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.8.0", + "version": "0.9.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 6ad77655b4..4cc32dc8fe 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.31.0", + "version": "0.32.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", @@ -46,11 +46,12 @@ "devDependencies": { "@iconify-json/mdi": "^1.1.54", "@n8n/storybook": "workspace:*", + "@vitejs/plugin-vue": "catalog:frontend", "@vitest/coverage-v8": "catalog:frontend", "unplugin-icons": "^0.19.0", "vite": "catalog:frontend", "vitest": "catalog:frontend", - "vite-plugin-dts": "^4.2.3", + "vite-plugin-dts": "^4.3.0", "vue-tsc": "catalog:frontend" }, "files": [ diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 8bec6363b5..8ba9cc43bf 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.20.0", + "version": "1.21.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/frontend.config.ts b/packages/@n8n/config/src/configs/frontend.config.ts index 63f812952f..62fa004dd5 100644 --- a/packages/@n8n/config/src/configs/frontend.config.ts +++ b/packages/@n8n/config/src/configs/frontend.config.ts @@ -7,5 +7,5 @@ export type FrontendBetaFeatures = 'canvas_v2'; export class FrontendConfig { /** Which UI experiments to enable. Separate multiple values with a comma `,` */ @Env('N8N_UI_BETA_FEATURES') - betaFeatures: StringArray = []; + betaFeatures: StringArray = ['canvas_v2']; } diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index d3fca6da08..06e262fe49 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -24,7 +24,7 @@ export class TaskRunnersConfig { authToken: string = ''; /** IP address task runners server should listen on */ - @Env('N8N_RUNNERS_SERVER_PORT') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT') port: number = 5679; /** IP address task runners server should listen on */ diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 5a1d6b54f1..6dec611010 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/imap", - "version": "0.7.0", + "version": "0.8.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 e1ae6beaa9..136d07a345 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.1.0", + "version": "1.2.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/package.json b/packages/@n8n/nodes-langchain/package.json index 11e2c61cf1..f086f2e82f 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.70.0", + "version": "1.71.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/storybook/package.json b/packages/@n8n/storybook/package.json index 7b2230a36b..6018fbd4b2 100644 --- a/packages/@n8n/storybook/package.json +++ b/packages/@n8n/storybook/package.json @@ -3,19 +3,19 @@ "private": true, "version": "0.0.1", "devDependencies": { - "@chromatic-com/storybook": "^2.0.2", - "@storybook/addon-a11y": "^8.3.5", - "@storybook/addon-actions": "^8.3.5", - "@storybook/addon-docs": "^8.3.5", - "@storybook/addon-essentials": "^8.3.5", - "@storybook/addon-interactions": "^8.3.5", - "@storybook/addon-links": "^8.3.5", - "@storybook/addon-themes": "^8.3.5", - "@storybook/blocks": "^8.3.5", - "@storybook/test": "^8.3.5", - "@storybook/vue3": "^8.3.5", - "@storybook/vue3-vite": "^8.3.5", - "chromatic": "^11.10.2", - "storybook": "^8.3.5" + "@chromatic-com/storybook": "^3.2.2", + "@storybook/addon-a11y": "^8.4.6", + "@storybook/addon-actions": "^8.4.6", + "@storybook/addon-docs": "^8.4.6", + "@storybook/addon-essentials": "^8.4.6", + "@storybook/addon-interactions": "^8.4.6", + "@storybook/addon-links": "^8.4.6", + "@storybook/addon-themes": "^8.4.6", + "@storybook/blocks": "^8.4.6", + "@storybook/test": "^8.4.6", + "@storybook/vue3": "^8.4.6", + "@storybook/vue3-vite": "^8.4.6", + "chromatic": "^11.20.0", + "storybook": "^8.4.6" } } diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 285d56840f..daec02c8e9 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.8.0", + "version": "1.9.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/task-runner/src/config/base-runner-config.ts b/packages/@n8n/task-runner/src/config/base-runner-config.ts index 723d3279bf..a1059adf4b 100644 --- a/packages/@n8n/task-runner/src/config/base-runner-config.ts +++ b/packages/@n8n/task-runner/src/config/base-runner-config.ts @@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config'; @Config class HealthcheckServerConfig { - @Env('N8N_RUNNERS_SERVER_ENABLED') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED') enabled: boolean = false; - @Env('N8N_RUNNERS_SERVER_HOST') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST') host: string = '127.0.0.1'; - @Env('N8N_RUNNERS_SERVER_PORT') + @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT') port: number = 5681; } @Config export class BaseRunnerConfig { - @Env('N8N_RUNNERS_N8N_URI') - n8nUri: string = '127.0.0.1:5679'; + @Env('N8N_RUNNERS_TASK_BROKER_URI') + taskBrokerUri: string = 'http://127.0.0.1:5679'; @Env('N8N_RUNNERS_GRANT_TOKEN') grantToken: string = ''; @@ -34,6 +34,9 @@ export class BaseRunnerConfig { @Env('N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT') idleTimeout: number = 0; + @Env('GENERIC_TIMEZONE') + timezone: string = 'America/New_York'; + @Nested healthcheckServer!: HealthcheckServerConfig; } diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 05e0b91f77..439de19eac 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import type { CodeExecutionMode, IDataObject } from 'n8n-workflow'; +import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow'; import fs from 'node:fs'; import { builtinModules } from 'node:module'; @@ -34,7 +34,7 @@ describe('JsTaskRunner', () => { ...defaultConfig.baseRunnerConfig, grantToken: 'grantToken', maxConcurrency: 1, - n8nUri: 'localhost', + taskBrokerUri: 'http://localhost', ...baseRunnerOpts, }, jsRunnerConfig: { @@ -311,10 +311,10 @@ describe('JsTaskRunner', () => { }); it("should not expose task runner's env variables even if no env state is received", async () => { - process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679'; + process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679'; const outcome = await execTaskWithParams({ task: newTaskWithSettings({ - code: 'return { val: $env.N8N_RUNNERS_N8N_URI }', + code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }', nodeMode: 'runOnceForAllItems', }), taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { @@ -326,6 +326,43 @@ describe('JsTaskRunner', () => { }); }); + describe('timezone', () => { + it('should use the specified timezone in the workflow', async () => { + const taskData = newDataRequestResponse(inputItems.map(wrapIntoJson), {}); + taskData.workflow.settings = { + timezone: 'Europe/Helsinki', + }; + + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $now.toSeconds() }', + nodeMode: 'runOnceForAllItems', + }), + taskData, + }); + + const helsinkiTimeNow = DateTime.now().setZone('Europe/Helsinki').toSeconds(); + expect(outcome.result[0].json.val).toBeCloseTo(helsinkiTimeNow, 1); + }); + + it('should use the default timezone', async () => { + setGlobalState({ + defaultTimezone: 'Europe/Helsinki', + }); + + const outcome = await execTaskWithParams({ + task: newTaskWithSettings({ + code: 'return { val: $now.toSeconds() }', + nodeMode: 'runOnceForAllItems', + }), + taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {}), + }); + + const helsinkiTimeNow = DateTime.now().setZone('Europe/Helsinki').toSeconds(); + expect(outcome.result[0].json.val).toBeCloseTo(helsinkiTimeNow, 1); + }); + }); + it('should allow access to Node.js Buffers', async () => { const outcomeAll = await execTaskWithParams({ task: newTaskWithSettings({ diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts new file mode 100644 index 0000000000..c633e95688 --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/task-runner.test.ts @@ -0,0 +1,89 @@ +import { WebSocket } from 'ws'; + +import { TaskRunner } from '@/task-runner'; + +class TestRunner extends TaskRunner {} + +jest.mock('ws'); + +describe('TestRunner', () => { + describe('constructor', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should correctly construct WebSocket URI with provided taskBrokerUri', () => { + const runner = new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'http://localhost:8080', + timezone: 'America/New_York', + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }); + + expect(WebSocket).toHaveBeenCalledWith( + `ws://localhost:8080/runners/_ws?id=${runner.id}`, + expect.objectContaining({ + headers: { + authorization: 'Bearer test-token', + }, + maxPayload: 1024, + }), + ); + }); + + it('should handle different taskBrokerUri formats correctly', () => { + const runner = new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'https://example.com:3000/path', + timezone: 'America/New_York', + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }); + + expect(WebSocket).toHaveBeenCalledWith( + `ws://example.com:3000/runners/_ws?id=${runner.id}`, + expect.objectContaining({ + headers: { + authorization: 'Bearer test-token', + }, + maxPayload: 1024, + }), + ); + }); + + it('should throw an error if taskBrokerUri is invalid', () => { + expect( + () => + new TestRunner({ + taskType: 'test-task', + maxConcurrency: 5, + idleTimeout: 60, + grantToken: 'test-token', + maxPayloadSize: 1024, + taskBrokerUri: 'not-a-valid-uri', + timezone: 'America/New_York', + healthcheckServer: { + enabled: false, + host: 'localhost', + port: 8081, + }, + }), + ).toThrowError(/Invalid URL/); + }); + }); +}); diff --git a/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/__tests__/built-ins-parser.test.ts b/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/__tests__/built-ins-parser.test.ts index 58d314dc19..dd866fc381 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/__tests__/built-ins-parser.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/__tests__/built-ins-parser.test.ts @@ -17,7 +17,7 @@ describe('BuiltInsParser', () => { const parseAndExpectOk = (code: string) => { const result = parser.parseUsedBuiltIns(code); if (!result.ok) { - fail(result.error); + throw result.error; } return result.result; @@ -151,6 +151,13 @@ describe('BuiltInsParser', () => { }); }); + describe('$item', () => { + it('should require all nodes and input when $item is used', () => { + const state = parseAndExpectOk('$item("0").$node["my node"].json["title"]'); + expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true })); + }); + }); + describe('ECMAScript syntax', () => { describe('ES2020', () => { it('should parse optional chaining', () => { diff --git a/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts b/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts index 9b9e4b34b2..7a5c7baf46 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts @@ -125,6 +125,11 @@ export class BuiltInsParser { private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => { if (node.name === '$env') { state.markEnvAsNeeded(); + } else if (node.name === '$item') { + // $item is legacy syntax that is basically an alias for WorkflowDataProxy + // and allows accessing any data. We need to support it for backwards + // compatibility, but we're not gonna implement any optimizations + state.markNeedsAllNodes(); } else if ( node.name === '$input' || node.name === '$json' || diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index 005cbfc840..f68779f38d 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -1,4 +1,4 @@ -import { ensureError } from 'n8n-workflow'; +import { ensureError, setGlobalState } from 'n8n-workflow'; import Container from 'typedi'; import { MainConfig } from './config/main-config'; @@ -44,6 +44,10 @@ function createSignalHandler(signal: string) { void (async function start() { const config = Container.get(MainConfig); + setGlobalState({ + defaultTimezone: config.baseRunnerConfig.timezone, + }); + if (config.sentryConfig.sentryDsn) { const { ErrorReporter } = await import('@/error-reporter'); errorReporter = new ErrorReporter(config.sentryConfig); diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index 1486af280d..f0af115b5a 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter { this.maxConcurrency = opts.maxConcurrency; this.idleTimeout = opts.idleTimeout; - const wsUrl = `ws://${opts.n8nUri}/runners/_ws?id=${this.id}`; + const { host: taskBrokerHost } = new URL(opts.taskBrokerUri); + + const wsUrl = `ws://${taskBrokerHost}/runners/_ws?id=${this.id}`; this.ws = new WebSocket(wsUrl, { headers: { authorization: `Bearer ${opts.grantToken}`, @@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter { ['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code) ) { console.error( - `Error: Failed to connect to n8n. Please ensure n8n is reachable at: ${opts.n8nUri}`, + `Error: Failed to connect to n8n task broker. Please ensure n8n task broker is reachable at: ${taskBrokerHost}`, ); process.exit(1); } else { - console.error(`Error: Failed to connect to n8n at ${opts.n8nUri}`); + console.error(`Error: Failed to connect to n8n task broker at ${taskBrokerHost}`); console.error('Details:', event.message || 'Unknown error'); } }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 413bc5d700..87f0c65122 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.70.0", + "version": "1.71.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/__tests__/workflow-runner.test.ts b/packages/cli/src/__tests__/workflow-runner.test.ts index 4774746ba7..683343b44c 100644 --- a/packages/cli/src/__tests__/workflow-runner.test.ts +++ b/packages/cli/src/__tests__/workflow-runner.test.ts @@ -1,4 +1,23 @@ -import { WorkflowHooks, type ExecutionError, type IWorkflowExecuteHooks } from 'n8n-workflow'; +import { mock } from 'jest-mock-extended'; +import { DirectedGraph, WorkflowExecute } from 'n8n-core'; +import * as core from 'n8n-core'; +import type { + IExecuteData, + INode, + IRun, + ITaskData, + IWaitingForExecution, + IWaitingForExecutionSource, + IWorkflowExecutionDataProcess, + StartNodeData, +} from 'n8n-workflow'; +import { + Workflow, + WorkflowHooks, + type ExecutionError, + type IWorkflowExecuteHooks, +} from 'n8n-workflow'; +import PCancelable from 'p-cancelable'; import Container from 'typedi'; import { ActiveExecutions } from '@/active-executions'; @@ -6,6 +25,7 @@ import config from '@/config'; import type { User } from '@/databases/entities/user'; import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; import { Telemetry } from '@/telemetry'; +import { PermissionChecker } from '@/user-management/permission-checker'; import { WorkflowRunner } from '@/workflow-runner'; import { mockInstance } from '@test/mocking'; import { createExecution } from '@test-integration/db/executions'; @@ -43,61 +63,138 @@ afterAll(() => { beforeEach(async () => { await testDb.truncate(['Workflow', 'SharedWorkflow']); + jest.clearAllMocks(); }); -test('processError should return early in Bull stalled edge case', async () => { - const workflow = await createWorkflow({}, owner); - const execution = await createExecution( - { - status: 'success', - finished: true, - }, - workflow, - ); - config.set('executions.mode', 'queue'); - await runner.processError( - new Error('test') as ExecutionError, - new Date(), - 'webhook', - execution.id, - new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), - ); - expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0); +describe('processError', () => { + test('processError should return early in Bull stalled edge case', async () => { + const workflow = await createWorkflow({}, owner); + const execution = await createExecution( + { + status: 'success', + finished: true, + }, + workflow, + ); + config.set('executions.mode', 'queue'); + await runner.processError( + new Error('test') as ExecutionError, + new Date(), + 'webhook', + execution.id, + new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), + ); + expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0); + }); + + test('processError should return early if the error is `ExecutionNotFoundError`', async () => { + const workflow = await createWorkflow({}, owner); + const execution = await createExecution({ status: 'success', finished: true }, workflow); + await runner.processError( + new ExecutionNotFoundError(execution.id), + new Date(), + 'webhook', + execution.id, + new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), + ); + expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0); + }); + + test('processError should process error', async () => { + const workflow = await createWorkflow({}, owner); + const execution = await createExecution( + { + status: 'success', + finished: true, + }, + workflow, + ); + await Container.get(ActiveExecutions).add( + { executionMode: 'webhook', workflowData: workflow }, + execution.id, + ); + config.set('executions.mode', 'regular'); + await runner.processError( + new Error('test') as ExecutionError, + new Date(), + 'webhook', + execution.id, + new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), + ); + expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1); + }); }); -test('processError should return early if the error is `ExecutionNotFoundError`', async () => { - const workflow = await createWorkflow({}, owner); - const execution = await createExecution({ status: 'success', finished: true }, workflow); - await runner.processError( - new ExecutionNotFoundError(execution.id), - new Date(), - 'webhook', - execution.id, - new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), - ); - expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0); -}); +describe('run', () => { + it('uses recreateNodeExecutionStack to create a partial execution if a triggerToStartFrom with data is sent', async () => { + // ARRANGE + const activeExecutions = Container.get(ActiveExecutions); + jest.spyOn(activeExecutions, 'add').mockResolvedValue('1'); + jest.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValueOnce(); + const permissionChecker = Container.get(PermissionChecker); + jest.spyOn(permissionChecker, 'check').mockResolvedValueOnce(); -test('processError should process error', async () => { - const workflow = await createWorkflow({}, owner); - const execution = await createExecution( - { - status: 'success', - finished: true, - }, - workflow, - ); - await Container.get(ActiveExecutions).add( - { executionMode: 'webhook', workflowData: workflow }, - execution.id, - ); - config.set('executions.mode', 'regular'); - await runner.processError( - new Error('test') as ExecutionError, - new Date(), - 'webhook', - execution.id, - new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), - ); - expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1); + jest.spyOn(WorkflowExecute.prototype, 'processRunExecutionData').mockReturnValueOnce( + new PCancelable(() => { + return mock(); + }), + ); + + jest.spyOn(Workflow.prototype, 'getNode').mockReturnValueOnce(mock()); + jest.spyOn(DirectedGraph, 'fromWorkflow').mockReturnValueOnce(new DirectedGraph()); + const recreateNodeExecutionStackSpy = jest + .spyOn(core, 'recreateNodeExecutionStack') + .mockReturnValueOnce({ + nodeExecutionStack: mock(), + waitingExecution: mock(), + waitingExecutionSource: mock(), + }); + + const data = mock({ + triggerToStartFrom: { name: 'trigger', data: mock() }, + + workflowData: { nodes: [] }, + executionData: undefined, + startNodes: [mock()], + destinationNode: undefined, + }); + + // ACT + await runner.run(data); + + // ASSERT + expect(recreateNodeExecutionStackSpy).toHaveBeenCalled(); + }); + + it('does not use recreateNodeExecutionStack to create a partial execution if a triggerToStartFrom without data is sent', async () => { + // ARRANGE + const activeExecutions = Container.get(ActiveExecutions); + jest.spyOn(activeExecutions, 'add').mockResolvedValue('1'); + jest.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValueOnce(); + const permissionChecker = Container.get(PermissionChecker); + jest.spyOn(permissionChecker, 'check').mockResolvedValueOnce(); + + jest.spyOn(WorkflowExecute.prototype, 'processRunExecutionData').mockReturnValueOnce( + new PCancelable(() => { + return mock(); + }), + ); + + const recreateNodeExecutionStackSpy = jest.spyOn(core, 'recreateNodeExecutionStack'); + + const data = mock({ + triggerToStartFrom: { name: 'trigger', data: undefined }, + + workflowData: { nodes: [] }, + executionData: undefined, + startNodes: [mock()], + destinationNode: undefined, + }); + + // ACT + await runner.run(data); + + // ASSERT + expect(recreateNodeExecutionStackSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index 54e67692ee..26aeca5432 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -86,13 +86,24 @@ export class MFAController { @Post('/disable', { rateLimit: true }) async disableMFA(req: MFA.Disable) { const { id: userId } = req.user; - const { mfaCode = null } = req.body; - if (typeof mfaCode !== 'string' || !mfaCode) { - throw new BadRequestError('Token is required to disable MFA feature'); + const { mfaCode, mfaRecoveryCode } = req.body; + + const mfaCodeDefined = mfaCode && typeof mfaCode === 'string'; + + const mfaRecoveryCodeDefined = mfaRecoveryCode && typeof mfaRecoveryCode === 'string'; + + if (!mfaCodeDefined === !mfaRecoveryCodeDefined) { + throw new BadRequestError( + 'Either MFA code or recovery code is required to disable MFA feature', + ); } - await this.mfaService.disableMfa(userId, mfaCode); + if (mfaCodeDefined) { + await this.mfaService.disableMfaWithMfaCode(userId, mfaCode); + } else if (mfaRecoveryCodeDefined) { + await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode); + } } @Post('/verify', { rateLimit: true }) diff --git a/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts b/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts index 894e741344..7ee46ca0d0 100644 --- a/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts +++ b/packages/cli/src/databases/migrations/common/1675940580449-PurgeInvalidWorkflowConnections.ts @@ -38,7 +38,7 @@ export class PurgeInvalidWorkflowConnections1675940580449 implements Irreversibl // It filters out all connections that are connected to a node that cannot receive input outputConnection.forEach((outputConnectionItem, outputConnectionItemIdx) => { - outputConnection[outputConnectionItemIdx] = outputConnectionItem.filter( + outputConnection[outputConnectionItemIdx] = (outputConnectionItem ?? []).filter( (outgoingConnections) => !nodesThatCannotReceiveInput.includes(outgoingConnections.node), ); diff --git a/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts b/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts new file mode 100644 index 0000000000..baa0ead870 --- /dev/null +++ b/packages/cli/src/errors/response-errors/invalid-mfa-recovery-code-error.ts @@ -0,0 +1,7 @@ +import { ForbiddenError } from './forbidden.error'; + +export class InvalidMfaRecoveryCodeError extends ForbiddenError { + constructor(hint?: string) { + super('Invalid MFA recovery code', hint); + } +} diff --git a/packages/cli/src/mfa/mfa.service.ts b/packages/cli/src/mfa/mfa.service.ts index afce8927f9..84433f5f18 100644 --- a/packages/cli/src/mfa/mfa.service.ts +++ b/packages/cli/src/mfa/mfa.service.ts @@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; +import { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error'; import { TOTPService } from './totp.service'; @@ -85,12 +86,27 @@ export class MfaService { return await this.authUserRepository.save(user); } - async disableMfa(userId: string, mfaCode: string) { + async disableMfaWithMfaCode(userId: string, mfaCode: string) { const isValidToken = await this.validateMfa(userId, mfaCode, undefined); + if (!isValidToken) { throw new InvalidMfaCodeError(); } + await this.disableMfaForUser(userId); + } + + async disableMfaWithRecoveryCode(userId: string, recoveryCode: string) { + const isValidToken = await this.validateMfa(userId, undefined, recoveryCode); + + if (!isValidToken) { + throw new InvalidMfaRecoveryCodeError(); + } + + await this.disableMfaForUser(userId); + } + + private async disableMfaForUser(userId: string) { await this.authUserRepository.update(userId, { mfaEnabled: false, mfaSecret: null, diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 72d6fcf135..7afb1e1bd3 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -318,7 +318,7 @@ export type LoginRequest = AuthlessRequest< export declare namespace MFA { type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>; type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>; - type Disable = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>; + type Disable = AuthenticatedRequest<{}, {}, { mfaCode?: string; mfaRecoveryCode?: string }, {}>; type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>; type ValidateRecoveryCode = AuthenticatedRequest< {}, diff --git a/packages/cli/src/runners/__tests__/task-runner-process.test.ts b/packages/cli/src/runners/__tests__/task-runner-process.test.ts index 447a57d3c7..85dbaa6930 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/runners/__tests__/task-runner-process.test.ts @@ -76,6 +76,7 @@ describe('TaskRunnerProcess', () => { 'N8N_VERSION', 'ENVIRONMENT', 'DEPLOYMENT_NAME', + 'GENERIC_TIMEZONE', ])('should propagate %s from env as is', async (envVar) => { jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken'); process.env[envVar] = 'custom value'; diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/runners/task-runner-process.ts index 01e351c0e4..d989107718 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/runners/task-runner-process.ts @@ -54,6 +54,7 @@ export class TaskRunnerProcess extends TypedEmitter { private readonly passthroughEnvVars = [ 'PATH', + 'GENERIC_TIMEZONE', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL', 'N8N_SENTRY_DSN', @@ -94,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter { const grantToken = await this.authService.createGrantToken(); - const n8nUri = `127.0.0.1:${this.runnerConfig.port}`; - this.process = this.startNode(grantToken, n8nUri); + const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`; + this.process = this.startNode(grantToken, taskBrokerUri); forwardToLogger(this.logger, this.process, '[Task Runner]: '); this.monitorProcess(this.process); } - startNode(grantToken: string, n8nUri: string) { + startNode(grantToken: string, taskBrokerUri: string) { const startScript = require.resolve('@n8n/task-runner/start'); return spawn('node', [startScript], { - env: this.getProcessEnvVars(grantToken, n8nUri), + env: this.getProcessEnvVars(grantToken, taskBrokerUri), }); } @@ -158,10 +159,10 @@ export class TaskRunnerProcess extends TypedEmitter { } } - private getProcessEnvVars(grantToken: string, n8nUri: string) { + private getProcessEnvVars(grantToken: string, taskBrokerUri: string) { const envVars: Record = { N8N_RUNNERS_GRANT_TOKEN: grantToken, - N8N_RUNNERS_N8N_URI: n8nUri, + N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri, N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(), N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(), ...this.getPassthroughEnvVars(), diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index fafad308ad..37113e4c40 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -119,7 +119,7 @@ export class InstanceRiskReporter implements RiskReporter { node: WorkflowEntity['nodes'][number]; workflow: WorkflowEntity; }) { - const childNodeNames = workflow.connections[node.name]?.main[0].map((i) => i.node); + const childNodeNames = workflow.connections[node.name]?.main[0]?.map((i) => i.node); if (!childNodeNames) return false; diff --git a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts index 3f8972ad9a..50f5bc2f12 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts @@ -1,6 +1,11 @@ import type * as express from 'express'; import { mock } from 'jest-mock-extended'; -import type { IWebhookData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow'; +import type { ITaskData } from 'n8n-workflow'; +import { + type IWebhookData, + type IWorkflowExecuteAdditionalData, + type Workflow, +} from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { generateNanoId } from '@/databases/utils/generators'; @@ -43,12 +48,16 @@ describe('TestWebhooks', () => { jest.useFakeTimers(); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('needsWebhook()', () => { - const args: Parameters = [ + const args: Parameters[0] = { userId, workflowEntity, - mock(), - ]; + additionalData: mock(), + }; test('if webhook is needed, should register then create webhook and return true', async () => { const workflow = mock(); @@ -56,7 +65,7 @@ describe('TestWebhooks', () => { jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow); jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); - const needsWebhook = await testWebhooks.needsWebhook(...args); + const needsWebhook = await testWebhooks.needsWebhook(args); const [registerOrder] = registrations.register.mock.invocationCallOrder; const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder; @@ -72,7 +81,7 @@ describe('TestWebhooks', () => { jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg)); registrations.getAllRegistrations.mockResolvedValue([]); - const needsWebhook = testWebhooks.needsWebhook(...args); + const needsWebhook = testWebhooks.needsWebhook(args); await expect(needsWebhook).rejects.toThrowError(msg); }); @@ -81,10 +90,55 @@ describe('TestWebhooks', () => { webhook.webhookDescription.restartWebhook = true; jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); - const result = await testWebhooks.needsWebhook(...args); + const result = await testWebhooks.needsWebhook(args); expect(result).toBe(false); }); + + test('returns false if a triggerToStartFrom with triggerData is given', async () => { + const workflow = mock(); + jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow); + jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); + + const needsWebhook = await testWebhooks.needsWebhook({ + ...args, + triggerToStartFrom: { + name: 'trigger', + data: mock(), + }, + }); + + expect(needsWebhook).toBe(false); + }); + + test('returns true, registers and then creates webhook if triggerToStartFrom is given with no triggerData', async () => { + // ARRANGE + const workflow = mock(); + const webhook2 = mock({ + node: 'trigger', + httpMethod, + path, + workflowId: workflowEntity.id, + userId, + }); + jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow); + jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook, webhook2]); + + // ACT + const needsWebhook = await testWebhooks.needsWebhook({ + ...args, + triggerToStartFrom: { name: 'trigger' }, + }); + + // ASSERT + const [registerOrder] = registrations.register.mock.invocationCallOrder; + const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder; + + expect(registerOrder).toBeLessThan(createOrder); + expect(registrations.register.mock.calls[0][0].webhook.node).toBe(webhook2.node); + expect(workflow.createWebhookIfNotExists.mock.calls[0][0].node).toBe(webhook2.node); + expect(needsWebhook).toBe(true); + }); }); describe('executeWebhook()', () => { diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index bf2fa6c9d8..2bdf94b312 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -23,6 +23,7 @@ import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrati import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import type { WorkflowRequest } from '@/workflows/workflow.request'; import type { IWebhookResponseCallbackData, @@ -218,25 +219,48 @@ export class TestWebhooks implements IWebhookManager { * Return whether activating a workflow requires listening for webhook calls. * For every webhook call to listen for, also activate the webhook. */ - async needsWebhook( - userId: string, - workflowEntity: IWorkflowDb, - additionalData: IWorkflowExecuteAdditionalData, - runData?: IRunData, - pushRef?: string, - destinationNode?: string, - ) { + async needsWebhook(options: { + userId: string; + workflowEntity: IWorkflowDb; + additionalData: IWorkflowExecuteAdditionalData; + runData?: IRunData; + pushRef?: string; + destinationNode?: string; + triggerToStartFrom?: WorkflowRequest.ManualRunPayload['triggerToStartFrom']; + }) { + const { + userId, + workflowEntity, + additionalData, + runData, + pushRef, + destinationNode, + triggerToStartFrom, + } = options; + if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity); const workflow = this.toWorkflow(workflowEntity); - const webhooks = WebhookHelpers.getWorkflowWebhooks( + let webhooks = WebhookHelpers.getWorkflowWebhooks( workflow, additionalData, destinationNode, true, ); + // If we have a preferred trigger with data, we don't have to listen for a + // webhook. + if (triggerToStartFrom?.data) { + return false; + } + + // If we have a preferred trigger without data we only want to listen for + // that trigger, not the other ones. + if (triggerToStartFrom) { + webhooks = webhooks.filter((w) => w.node === triggerToStartFrom.name); + } + if (!webhooks.some((w) => w.webhookDescription.restartWebhook !== true)) { return false; // no webhooks found to start a workflow } diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 6cade903d0..19ae201a8d 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -2,7 +2,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { InstanceSettings, WorkflowExecute } from 'n8n-core'; +import * as a from 'assert/strict'; +import { + DirectedGraph, + InstanceSettings, + WorkflowExecute, + filterDisabledNodes, + recreateNodeExecutionStack, +} from 'n8n-core'; import type { ExecutionError, IDeferredPromise, @@ -12,6 +19,7 @@ import type { WorkflowExecuteMode, WorkflowHooks, IWorkflowExecutionDataProcess, + IRunExecutionData, } from 'n8n-workflow'; import { ErrorReporterProxy as ErrorReporter, @@ -203,6 +211,7 @@ export class WorkflowRunner { } /** Run the workflow in current process */ + // eslint-disable-next-line complexity private async runMainProcess( executionId: string, data: IWorkflowExecutionDataProcess, @@ -286,12 +295,50 @@ export class WorkflowRunner { data.executionData, ); workflowExecution = workflowExecute.processRunExecutionData(workflow); + } else if (data.triggerToStartFrom?.data && data.startNodes && !data.destinationNode) { + this.logger.debug( + `Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`, + { executionId }, + ); + const startNodes = data.startNodes.map((data) => { + const node = workflow.getNode(data.name); + a.ok(node, `Could not find a node named "${data.name}" in the workflow.`); + return node; + }); + const runData = { [data.triggerToStartFrom.name]: [data.triggerToStartFrom.data] }; + + const { nodeExecutionStack, waitingExecution, waitingExecutionSource } = + recreateNodeExecutionStack( + filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)), + new Set(startNodes), + runData, + data.pinData ?? {}, + ); + const executionData: IRunExecutionData = { + resultData: { runData, pinData }, + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack, + waitingExecution, + waitingExecutionSource, + }, + }; + + const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData); + workflowExecution = workflowExecute.processRunExecutionData(workflow); } else if ( data.runData === undefined || data.startNodes === undefined || data.startNodes.length === 0 ) { // Full Execution + // TODO: When the old partial execution logic is removed this block can + // be removed and the previous one can be merged into + // `workflowExecute.runPartialWorkflow2`. + // Partial executions then require either a destination node from which + // everything else can be derived, or a triggerToStartFrom with + // triggerData. this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, { executionId, }); diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index d6ef8b4f93..1df4af2f76 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -95,6 +95,7 @@ export class WorkflowExecutionService { startNodes, destinationNode, dirtyNodeNames, + triggerToStartFrom, }: WorkflowRequest.ManualRunPayload, user: User, pushRef?: string, @@ -117,14 +118,15 @@ export class WorkflowExecutionService { ) { const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); - const needsWebhook = await this.testWebhooks.needsWebhook( - user.id, - workflowData, + const needsWebhook = await this.testWebhooks.needsWebhook({ + userId: user.id, + workflowEntity: workflowData, additionalData, runData, pushRef, destinationNode, - ); + triggerToStartFrom, + }); if (needsWebhook) return { waitingForWebhook: true }; } @@ -144,6 +146,7 @@ export class WorkflowExecutionService { userId: user.id, partialExecutionVersion: partialExecutionVersion ?? '0', dirtyNodeNames, + triggerToStartFrom, }; const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index abfb58026a..4098384abb 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -1,4 +1,11 @@ -import type { INode, IConnections, IWorkflowSettings, IRunData, StartNodeData } from 'n8n-workflow'; +import type { + INode, + IConnections, + IWorkflowSettings, + IRunData, + StartNodeData, + ITaskData, +} from 'n8n-workflow'; import type { IWorkflowDb } from '@/interfaces'; import type { AuthenticatedRequest, ListQuery } from '@/requests'; @@ -23,6 +30,10 @@ export declare namespace WorkflowRequest { startNodes?: StartNodeData[]; destinationNode?: string; dirtyNodeNames?: string[]; + triggerToStartFrom?: { + name: string; + data?: ITaskData; + }; }; type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 95c3334277..498be7abd5 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -184,7 +184,19 @@ describe('Disable MFA setup', () => { expect(dbUser.mfaRecoveryCodes.length).toBe(0); }); - test('POST /disable should fail if invalid mfaCode is given', async () => { + test('POST /disable should fail if invalid MFA recovery code is given', async () => { + const { user } = await createUserWithMfaEnabled(); + + await testServer + .authAgentFor(user) + .post('/mfa/disable') + .send({ + mfaRecoveryCode: 'invalid token', + }) + .expect(403); + }); + + test('POST /disable should fail if invalid MFA code is given', async () => { const { user } = await createUserWithMfaEnabled(); await testServer @@ -195,6 +207,12 @@ describe('Disable MFA setup', () => { }) .expect(403); }); + + test('POST /disable should fail if neither MFA code nor recovery code is sent', async () => { + const { user } = await createUserWithMfaEnabled(); + + await testServer.authAgentFor(user).post('/mfa/disable').send({ anotherParam: '' }).expect(400); + }); }); describe('Change password with MFA enabled', () => { diff --git a/packages/core/package.json b/packages/core/package.json index cedac2f2f5..d334e118f1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.70.0", + "version": "1.71.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/PartialExecutionUtils/DirectedGraph.ts b/packages/core/src/PartialExecutionUtils/DirectedGraph.ts index 481a73392d..e8695743a1 100644 --- a/packages/core/src/PartialExecutionUtils/DirectedGraph.ts +++ b/packages/core/src/PartialExecutionUtils/DirectedGraph.ts @@ -448,7 +448,7 @@ export class DirectedGraph { for (const [outputType, outputs] of Object.entries(iConnection)) { for (const [outputIndex, conns] of outputs.entries()) { - for (const conn of conns) { + for (const conn of conns ?? []) { // TODO: What's with the input type? const { node: toNodeName, type: _inputType, index: inputIndex } = conn; const to = workflow.getNode(toNodeName); diff --git a/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts index 9530ed2217..c8feb20e11 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts +++ b/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts @@ -42,6 +42,28 @@ describe('DirectedGraph', () => { ); }); + // ┌─────┐ ┌─────┐──► null + // │node1├───►│node2| ┌─────┐ + // └─────┘ └─────┘──►│node3| + // └─────┘ + // + test('linear workflow with null connections', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const node3 = createNodeData({ name: 'Node3' }); + + // ACT + const graph = new DirectedGraph() + .addNodes(node1, node2, node3) + .addConnections({ from: node1, to: node2 }, { from: node2, to: node3, outputIndex: 1 }); + + // ASSERT + expect(DirectedGraph.fromWorkflow(graph.toWorkflow({ ...defaultWorkflowParameter }))).toEqual( + graph, + ); + }); + describe('getChildren', () => { // ┌─────┐ ┌─────┐ ┌─────┐ // │node1├───►│node2├──►│node3│ diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 808bc17c9b..515f58a657 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -36,6 +36,7 @@ import type { CloseFunction, StartNodeData, NodeExecutionHint, + NodeInputConnections, } from 'n8n-workflow'; import { LoggerProxy as Logger, @@ -208,6 +209,9 @@ export class WorkflowExecute { // Get the data of the incoming connections incomingSourceData = { main: [] }; for (const connections of incomingNodeConnections.main) { + if (!connections) { + continue; + } for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) { connection = connections[inputIndex]; @@ -249,6 +253,9 @@ export class WorkflowExecute { incomingNodeConnections = workflow.connectionsByDestinationNode[destinationNode]; if (incomingNodeConnections !== undefined) { for (const connections of incomingNodeConnections.main) { + if (!connections) { + continue; + } for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) { connection = connections[inputIndex]; @@ -642,7 +649,7 @@ export class WorkflowExecute { } for (const connectionDataCheck of workflow.connectionsBySourceNode[parentNodeName].main[ outputIndexParent - ]) { + ] ?? []) { checkOutputNodes.push(connectionDataCheck.node); } } @@ -661,7 +668,7 @@ export class WorkflowExecute { ) { for (const inputData of workflow.connectionsByDestinationNode[connectionData.node].main[ inputIndex - ]) { + ] ?? []) { if (inputData.node === parentNodeName) { // Is the node we come from so its data will be available for sure continue; @@ -681,7 +688,7 @@ export class WorkflowExecute { if ( !this.incomingConnectionIsEmpty( this.runExecutionData.resultData.runData, - workflow.connectionsByDestinationNode[inputData.node].main[0], + workflow.connectionsByDestinationNode[inputData.node].main[0] ?? [], runIndex, ) ) { @@ -770,7 +777,7 @@ export class WorkflowExecute { } else if ( this.incomingConnectionIsEmpty( this.runExecutionData.resultData.runData, - workflow.connectionsByDestinationNode[nodeToAdd].main[0], + workflow.connectionsByDestinationNode[nodeToAdd].main[0] ?? [], runIndex, ) ) { @@ -1066,7 +1073,7 @@ export class WorkflowExecute { if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) { // Check if the node has incoming connections if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) { - let inputConnections: IConnection[][]; + let inputConnections: NodeInputConnections; let connectionIndex: number; // eslint-disable-next-line prefer-const @@ -1586,7 +1593,7 @@ export class WorkflowExecute { // Iterate over all the different connections of this output for (connectionData of workflow.connectionsBySourceNode[executionNode.name].main[ outputIndex - ]) { + ] ?? []) { if (!workflow.nodes.hasOwnProperty(connectionData.node)) { throw new ApplicationError('Destination node not found', { extra: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cfdae07dc6..9c141867de 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,3 +21,4 @@ export { BinaryData } from './BinaryData/types'; export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils'; export * from './ExecutionMetadata'; export * from './node-execution-context'; +export * from './PartialExecutionUtils'; diff --git a/packages/design-system/package.json b/packages/design-system/package.json index a20208feb4..9af2fa8204 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.60.0", + "version": "1.61.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { @@ -27,7 +27,7 @@ "@types/markdown-it-emoji": "^2.0.2", "@types/markdown-it-link-attributes": "^3.0.5", "@types/sanitize-html": "^2.11.0", - "@vitejs/plugin-vue": "^5.1.4", + "@vitejs/plugin-vue": "catalog:frontend", "@vitest/coverage-v8": "catalog:frontend", "autoprefixer": "^10.4.19", "postcss": "^8.4.38", diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts index a7d9936e08..fe57291225 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts @@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => { it('default slot should trigger first level', async () => { const { getByTestId, queryByTestId } = render(NavigationDropdown, { slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, - props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] }, + props: { menu: [{ id: 'first', title: 'first', route: { name: 'projects' } }] }, global: { plugins: [router], }, @@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => { props: { menu: [ { - id: 'aaa', - title: 'aaa', - submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }], + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }], }, ], }, @@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => { props: { menu: [ { - id: 'aaa', - title: 'aaa', - submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }], }, ], }, @@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => { props: { menu: [ { - id: 'aaa', - title: 'aaa', - submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }], }, ], }, @@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => { await userEvent.click(getByTestId('navigation-submenu-item')); expect(emitted('itemClick')).toStrictEqual([ - [{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }], + [{ active: true, index: 'nested', indexPath: ['-1', 'first', 'nested'] }], ]); - expect(emitted('select')).toStrictEqual([['bbb']]); + expect(emitted('select')).toStrictEqual([['nested']]); + }); + + it('should open first level on click', async () => { + const { getByTestId, getByText } = render(NavigationDropdown, { + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, + props: { + menu: [ + { + id: 'first', + title: 'first', + }, + ], + }, + }); + expect(getByText('first')).not.toBeVisible(); + await userEvent.click(getByTestId('test-trigger')); + expect(getByText('first')).toBeVisible(); + }); + + it('should toggle nested level on mouseenter / mouseleave', async () => { + const { getByTestId, getByText } = render(NavigationDropdown, { + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, + props: { + menu: [ + { + id: 'first', + title: 'first', + submenu: [{ id: 'nested', title: 'nested' }], + }, + ], + }, + }); + expect(getByText('first')).not.toBeVisible(); + await userEvent.click(getByTestId('test-trigger')); + expect(getByText('first')).toBeVisible(); + + expect(getByText('nested')).not.toBeVisible(); + await userEvent.hover(getByTestId('navigation-submenu')); + await waitFor(() => expect(getByText('nested')).toBeVisible()); + + await userEvent.pointer([ + { target: getByTestId('navigation-submenu') }, + { target: getByTestId('test-trigger') }, + ]); + await waitFor(() => expect(getByText('nested')).not.toBeVisible()); }); }); diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index 1329d9a9ed..ce728a44ba 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -29,7 +29,7 @@ defineProps<{ }>(); const menuRef = ref(null); -const menuIndex = ref('-1'); +const ROOT_MENU_INDEX = '-1'; const emit = defineEmits<{ itemClick: [item: MenuItemRegistered]; @@ -37,7 +37,18 @@ const emit = defineEmits<{ }>(); const close = () => { - menuRef.value?.close(menuIndex.value); + menuRef.value?.close(ROOT_MENU_INDEX); +}; + +const menuTrigger = ref<'click' | 'hover'>('click'); +const onOpen = (index: string) => { + if (index !== ROOT_MENU_INDEX) return; + menuTrigger.value = 'hover'; +}; + +const onClose = (index: string) => { + if (index !== ROOT_MENU_INDEX) return; + menuTrigger.value = 'click'; }; defineExpose({ @@ -50,14 +61,16 @@ defineExpose({ ref="menuRef" mode="horizontal" unique-opened - menu-trigger="click" + :menu-trigger="menuTrigger" :ellipsis="false" :class="$style.dropdown" @select="emit('select', $event)" @keyup.escape="close" + @open="onOpen" + @close="onClose" > - + {{ item.title }} + @@ -125,17 +145,25 @@ defineExpose({ } } +.nestedSubmenu { + :global(.el-menu) { + max-height: 450px; + overflow: auto; + } +} + .submenu { padding: 5px 0 !important; :global(.el-menu--horizontal .el-menu .el-menu-item), :global(.el-menu--horizontal .el-menu .el-sub-menu__title) { color: var(--color-text-dark); + background-color: var(--color-menu-background); } :global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover), :global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) { - background-color: var(--color-foreground-base); + background-color: var(--color-menu-hover-background); } :global(.el-popper) { diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 72963efcf5..a3fc653550 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -462,6 +462,10 @@ --color-configurable-node-name: var(--color-text-dark); --color-secondary-link: var(--prim-color-secondary-tint-200); --color-secondary-link-hover: var(--prim-color-secondary-tint-100); + + --color-menu-background: var(--prim-gray-740); + --color-menu-hover-background: var(--prim-gray-670); + --color-menu-active-background: var(--prim-gray-670); } body[data-theme='dark'] { diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 56d5142c87..87951534ee 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -533,6 +533,11 @@ --color-secondary-link: var(--color-secondary); --color-secondary-link-hover: var(--color-secondary-shade-1); + // Menu + --color-menu-background: var(--prim-gray-0); + --color-menu-hover-background: var(--prim-gray-120); + --color-menu-active-background: var(--prim-gray-120); + // Generated Color Shades from 50 to 950 // Not yet used in design system @each $color in ('neutral', 'success', 'warning', 'danger') { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 073440d07c..bccb5506b8 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,12 +1,13 @@ { "name": "n8n-editor-ui", - "version": "1.70.0", + "version": "1.71.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { "clean": "rimraf dist .turbo", "build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build", "typecheck": "vue-tsc --noEmit", + "typecheck:watch": "vue-tsc --watch --noEmit", "dev": "pnpm serve", "lint": "eslint src --ext .js,.ts,.vue --quiet", "lintfix": "eslint src --ext .js,.ts,.vue --fix", @@ -94,6 +95,7 @@ "@types/lodash-es": "^4.17.6", "@types/luxon": "^3.2.0", "@types/uuid": "catalog:", + "@vitejs/plugin-vue": "catalog:frontend", "@vitest/coverage-v8": "catalog:frontend", "miragejs": "^0.1.48", "unplugin-icons": "^0.19.0", diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index c48b1574ca..3bd5a4a82b 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -1,6 +1,7 @@