diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index b672cb127c914..f7a0f8f21aa5e 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [14.x, 16.x] + node-version: [16.x] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 3fa601b2d0211..068c97740fd5c 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -48,6 +48,10 @@ jobs: working-directory: packages/cli run: pnpm test:postgres:alt-schema + - name: Test Postgres (table prefix) + working-directory: packages/cli + run: pnpm test:postgres:with-table-prefix + - name: Notify Slack on failure uses: act10ns/slack@v2.0.0 if: failure() diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 281aa36e6d979..5895e8d82b9e3 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -111,7 +111,6 @@ jobs: smoke-test: name: E2E [Electron/Node 16] uses: ./.github/workflows/e2e-reusable.yml - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }} with: branch: ${{ github.event.pull_request.head.ref }} user: ${{ github.event.inputs.user || 'PR User' }} diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index fde545ff6d5c5..0064f7eabc9c3 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -49,6 +49,4 @@ jobs: push: true tags: | ${{ secrets.DOCKER_USERNAME }}/n8n:${{ steps.vars.outputs.tag }}${{ matrix.docker-context }} - ${{ secrets.DOCKER_USERNAME }}/n8n:latest${{ matrix.docker-context }} ghcr.io/${{ github.repository_owner }}/n8n:${{ steps.vars.outputs.tag }}${{ matrix.docker-context }} - ghcr.io/${{ github.repository_owner }}/n8n:latest${{ matrix.docker-context }} diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index c982d84e1fd1b..df2b04a058d74 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -47,6 +47,11 @@ on: CYPRESS_RECORD_KEY: description: 'Cypress record key.' required: true + outputs: + tests_passed: + description: 'True if all E2E tests passed, otherwise false' + value: ${{ jobs.check_testing_matrix.outputs.all_tests_passed }} + jobs: # single job that generates and outputs a common id @@ -109,7 +114,9 @@ jobs: strategy: fail-fast: false matrix: - containers: ${{ fromJSON(inputs.containers) }} + # If spec is not e2e/* then we run only one container to prevent + # running the same tests multiple times + containers: ${{ fromJSON( inputs.spec == 'e2e/*' && inputs.containers || '[1]' ) }} steps: - uses: actions/checkout@v3 with: @@ -135,9 +142,9 @@ jobs: install: false start: pnpm start wait-on: 'http://localhost:5678' - wait-on-timeout: 120 # + wait-on-timeout: 120 record: ${{ inputs.record }} - parallel: ${{ inputs.parallel }} + parallel: ${{ fromJSON( inputs.spec == 'e2e/*' && inputs.parallel || false ) }} # We have to provide custom ci-build-id key to make sure that this workflow could be run multiple times # in the same parent workflow ci-build-id: ${{ needs.prepare.outputs.uuid }} @@ -148,3 +155,22 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} E2E_TESTS: true COMMIT_INFO_MESSAGE: 🌳 ${{ inputs.branch }} 🖥️ ${{ inputs.run-env }} 🤖 ${{ inputs.user }} 🗃️ ${{ inputs.spec }} + + # Check if all tests passed and set the output variable + check_testing_matrix: + runs-on: ubuntu-latest + needs: [testing] + outputs: + all_tests_passed: ${{ steps.all_tests_passed.outputs.result }} + steps: + - name: Check all tests passed + id: all_tests_passed + run: | + success=true + for status in ${{ needs.testing.result }}; do + if [ $status != "success" ]; then + success=false + break + fi + done + echo "::set-output name=result::$success" diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml index e23df4429f1cc..648383750d1ab 100644 --- a/.github/workflows/e2e-tests-pr.yml +++ b/.github/workflows/e2e-tests-pr.yml @@ -1,4 +1,4 @@ -name: PR E2E (skip with label skip-e2e) +name: PR E2E on: pull_request_review: @@ -7,24 +7,10 @@ on: - 'master' jobs: - # We disable this for now because cancelling runs makes the Cypress Cloud tests to hang. - # cancel-previous-runs: - # runs-on: ubuntu-latest - # name: 'Cancel previous e2e test runs' - # strategy: - # matrix: - # node-version: [16.x] - - # steps: - # - name: 'Cancel previous runs' - # uses: styfle/cancel-workflow-action@0.9.0 - # with: - # access_token: ${{ github.token }} - run-e2e-tests: name: E2E [Electron/Node 16] uses: ./.github/workflows/e2e-reusable.yml - if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }} + if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'community') }} with: branch: ${{ github.event.pull_request.head.ref }} user: ${{ github.event.pull_request.user.login || 'PR User' }} @@ -32,3 +18,35 @@ jobs: run-env: base:16.18.1 secrets: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + + post-e2e-tests: + runs-on: ubuntu-latest + name: E2E [Electron/Node 16] - Checks + needs: [run-e2e-tests] + if: always() + steps: + - name: E2E success comment + if: ${{!contains(github.event.pull_request.labels.*.name, 'community') && needs.run-e2e-tests.outputs.tests_passed == 'true' }} + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + :white_check_mark: All Cypress E2E specs passed + token: ${{ secrets.GITHUB_TOKEN }} + + - name: E2E fail comment + if: needs.run-e2e-tests.result == 'failure' + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + :warning: Some Cypress E2E specs are failing, please fix them before merging + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Success job if community PR + if: ${{ contains(github.event.pull_request.labels.*.name, 'community') }} + run: exit 0 + + - name: Fail job if run-e2e-tests failed + if: needs.run-e2e-tests.result == 'failure' + run: exit 1 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7059280552ba3..9eadb513e08a3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -42,7 +42,6 @@ jobs: run-e2e-tests: name: E2E [Electron/Node 16] uses: ./.github/workflows/e2e-reusable.yml - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }} with: branch: ${{ github.event.inputs.branch || 'master' }} user: ${{ github.event.inputs.user || 'PR User' }} @@ -51,22 +50,10 @@ jobs: secrets: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - run-e2e-tests-node-14: - name: E2E [Electron/Node 14] - uses: ./.github/workflows/e2e-reusable.yml - if: ${{ github.event_name == 'schedule' }} - with: - branch: ${{ github.event.inputs.branch || 'master' }} - user: ${{ github.event.inputs.user || 'schedule' }} - spec: ${{ github.event.inputs.spec || 'e2e/*' }} - run-env: base:14.21.1 - secrets: - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - calls-success-url-notify: name: Calls success URL and notifies runs-on: ubuntu-latest - needs: [run-e2e-tests, run-e2e-tests-node-14] + needs: [run-e2e-tests] if: ${{ github.event.inputs.success-url != '' }} steps: - name: Notify Slack on failure diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index b8b03ab8e1997..ade0e521f2aa1 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -16,7 +16,7 @@ jobs: permissions: contents: write - timeout-minutes: 10 + timeout-minutes: 60 steps: - name: Checkout @@ -31,14 +31,16 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile + - name: Set release version in env + run: echo "RELEASE=$(node -e 'console.log(require("./package.json").version)')" >> $GITHUB_ENV + - name: Build run: pnpm build - name: Publish to NPM run: | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public - echo "RELEASE=$(node -e 'console.log(require("./package.json").version)')" >> $GITHUB_ENV + pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc - name: Create Release uses: ncipollo/release-action@v1 diff --git a/.github/workflows/release-push-to-channel.yml b/.github/workflows/release-push-to-channel.yml new file mode 100644 index 0000000000000..7e79cc27e89fd --- /dev/null +++ b/.github/workflows/release-push-to-channel.yml @@ -0,0 +1,42 @@ +name: 'Release: Push to Channel' + +on: + workflow_dispatch: + inputs: + version: + description: 'n8n Release version to push to a channel' + required: true + + release-channel: + description: 'Release channel' + required: true + type: choice + default: 'next' + options: + - next + - latest + +jobs: + release-to-npm: + name: Release to NPM + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16.x + - run: | + echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc + npm dist-tag add n8n@${{ github.event.inputs.version }} ${{ github.event.inputs.release-channel }} + + release-to-docker-hub: + name: Release to DockerHub + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - run: docker buildx imagetools create -t n8nio/n8n:${{ github.event.inputs.release-channel }} n8nio/n8n:${{ github.event.inputs.version }} diff --git a/.github/workflows/test-workflows.yml b/.github/workflows/test-workflows.yml index 2a26fcf9c2444..f6fc73083c680 100644 --- a/.github/workflows/test-workflows.yml +++ b/.github/workflows/test-workflows.yml @@ -28,7 +28,7 @@ jobs: - uses: pnpm/action-setup@v2.2.4 with: - version: 7.27.0 + version: 8.1.0 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 63647ccc1d01d..1853cad3e7e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,263 @@ +## [0.225.1](https://github.com/n8n-io/n8n/compare/n8n@0.225.0...n8n@0.225.1) (2023-04-20) + + +### Bug Fixes + +* **editor:** Clean up demo and template callouts from workflows page ([#6023](https://github.com/n8n-io/n8n/issues/6023)) ([6ec1c45](https://github.com/n8n-io/n8n/commit/6ec1c45355807e62f31a707bada823cdc73bc719)) +* **editor:** Fix memory leak in Node Detail View by correctly unsubscribing from event buses ([#6021](https://github.com/n8n-io/n8n/issues/6021)) ([1b9e047](https://github.com/n8n-io/n8n/commit/1b9e047ef5745f479e6693dca9696efbec32a7a6)) +* **editor:** SettingsSidebar should disconnect from push when navigating away ([#6025](https://github.com/n8n-io/n8n/issues/6025)) ([e9f8cfe](https://github.com/n8n-io/n8n/commit/e9f8cfe82182ee0d7c8c2394551791793cc71f47)) +* **Notion Node:** Update credential test to not require user permissions ([#6022](https://github.com/n8n-io/n8n/issues/6022)) ([6d02ae5](https://github.com/n8n-io/n8n/commit/6d02ae53cf1ec616abd47e434018bdf5e998f916)) + + + +## [0.224.2](https://github.com/n8n-io/n8n/compare/n8n@0.224.1...n8n@0.224.2) (2023-04-20) + + +### Bug Fixes + +* **core:** Fix paired item returning wrong data ([#5898](https://github.com/n8n-io/n8n/issues/5898)) ([2a45441](https://github.com/n8n-io/n8n/commit/2a45441d8aa1e9069af09eb28b9b26b0c4abf96e)) +* **core:** Make `getExecutionId` available on all nodes types ([#5990](https://github.com/n8n-io/n8n/issues/5990)) ([8373aab](https://github.com/n8n-io/n8n/commit/8373aab1fffc6f15e2a79462c97cd52cff277784)) +* **editor:** Fix memory leak in Node Detail View by correctly unsubscribing from event buses ([#6021](https://github.com/n8n-io/n8n/issues/6021)) ([d8fce5b](https://github.com/n8n-io/n8n/commit/d8fce5b1cbcded5dcb652b1b5bf252555343d4b1)) +* **editor:** Fix moving canvas on middle click preventing lasso selection ([#5996](https://github.com/n8n-io/n8n/issues/5996)) ([a7a5778](https://github.com/n8n-io/n8n/commit/a7a57782bbaadad342f592331b7b9d49aa0e62de)) +* **editor:** SettingsSidebar should disconnect from push when navigating away ([#6025](https://github.com/n8n-io/n8n/issues/6025)) ([b475c8f](https://github.com/n8n-io/n8n/commit/b475c8f26aea9c108a8ea29826619925516c372f)) +* **Google Sheets Trigger Node:** Return actual error message ([5e59141](https://github.com/n8n-io/n8n/commit/5e59141ec60566dbacea246402e57b13328a94b3)) +* **HTTP Request Node:** Fix itemIndex in HTTP Request errors ([#5991](https://github.com/n8n-io/n8n/issues/5991)) ([4a521a4](https://github.com/n8n-io/n8n/commit/4a521a416e3fb85ba58d4ca4ad420b485c220d96)) +* **Notion Node:** Update credential test to not require user permissions ([#6022](https://github.com/n8n-io/n8n/issues/6022)) ([14c9b5e](https://github.com/n8n-io/n8n/commit/14c9b5e6295a59b5ddbde0e07ba94250b50bcedf)) + + + +# [0.225.0](https://github.com/n8n-io/n8n/compare/n8n@0.224.0...n8n@0.225.0) (2023-04-19) + + +### Bug Fixes + +* **core:** Fix broken API permissions in public API ([#5978](https://github.com/n8n-io/n8n/issues/5978)) ([49d838f](https://github.com/n8n-io/n8n/commit/49d838f628a124f3497165437a384e78d8a8ff63)) +* **core:** Fix paired item returning wrong data ([#5898](https://github.com/n8n-io/n8n/issues/5898)) ([b13b7d7](https://github.com/n8n-io/n8n/commit/b13b7d73e7857fe9a264d9400adfa337907f659a)) +* **core:** Improve SAML connection test result views ([#5981](https://github.com/n8n-io/n8n/issues/5981)) ([4c994fa](https://github.com/n8n-io/n8n/commit/4c994faec1ed6173d99f5b01efd9678e54e7eb49)) +* **core:** Make `getExecutionId` available on all nodes types ([#5990](https://github.com/n8n-io/n8n/issues/5990)) ([c42820e](https://github.com/n8n-io/n8n/commit/c42820e82efe7365b5d7344bb3f474ba420ea7c9)) +* **core:** Skip SAML onboarding for users with first- and lastname ([#5966](https://github.com/n8n-io/n8n/issues/5966)) ([8474cd3](https://github.com/n8n-io/n8n/commit/8474cd386ddfca9e9078b45af65af9299d63eb85)) +* **editor:** Add padding to prepend input ([#5874](https://github.com/n8n-io/n8n/issues/5874)) ([cd89489](https://github.com/n8n-io/n8n/commit/cd894893aafe1fc25e0e556a9651ab458b50ae99)) +* **editor:** Cleanup demo/video experiment ([#5974](https://github.com/n8n-io/n8n/issues/5974)) ([c171365](https://github.com/n8n-io/n8n/commit/c171365d2a613ea1fb9b08c22c54be29d1c8ade7)) +* **editor:** Enterprise features missing with UM ([#5995](https://github.com/n8n-io/n8n/issues/5995)) ([f9a810a](https://github.com/n8n-io/n8n/commit/f9a810aaf7fd56beba1342016a96922e8b332951)) +* **editor:** Fix moving canvas on middle click preventing lasso selection ([#5996](https://github.com/n8n-io/n8n/issues/5996)) ([3c2a569](https://github.com/n8n-io/n8n/commit/3c2a56928b46425822795cf1594133a538f47c21)) +* **editor:** Make sure to redirect to blank canvas after personalisation modal ([#5980](https://github.com/n8n-io/n8n/issues/5980)) ([7c474d3](https://github.com/n8n-io/n8n/commit/7c474d3c92ecca8e44e8eea76ada69aa7e8f5987)) +* **editor:** Only treat as CTRL pressed by default on touch devices for MouseEvent ([#5968](https://github.com/n8n-io/n8n/issues/5968)) ([536d810](https://github.com/n8n-io/n8n/commit/536d8109b02d1a0f771055c36ff0f45dae08281e)) +* **editor:** Fix n8n-checkbox alignment ([#6004](https://github.com/n8n-io/n8n/issues/6004)) ([f544826](https://github.com/n8n-io/n8n/commit/f5448269ee9277f19b0943035d23ad0df1dcde67)) +* **Code Node:** Handle user code returning `null` and `undefined` ([#5989](https://github.com/n8n-io/n8n/issues/5989)) ([a3664de](https://github.com/n8n-io/n8n/commit/a3664de3556f9f8159ed310a289ec12a4cd2c5c5)) +* **Github Trigger Node:** Remove content_reference event ([#5830](https://github.com/n8n-io/n8n/issues/5830)) ([d288a91](https://github.com/n8n-io/n8n/commit/d288a918f17dad2d0e32cf2d66f94037c77679b3)) +* **Google Sheets Trigger Node:** Return actual error message ([ba5b4eb](https://github.com/n8n-io/n8n/commit/ba5b4eb42fa1b609ddbe726d3e8655c1f9d28a2e)) +* **HTTP Request Node:** Fix itemIndex in HTTP Request errors ([#5991](https://github.com/n8n-io/n8n/issues/5991)) ([b351c62](https://github.com/n8n-io/n8n/commit/b351c6265938a908f90237c834012cc19cf70dc3)) +* **NocoDB Node:** Fix for updating or deleting rows with not default primary keys ([ee7f863](https://github.com/n8n-io/n8n/commit/ee7f86394eaf7aceee5521a4178c80a3c05cc27d)) +* **OpenAI Node:** Update models to only show those supported ([#5805](https://github.com/n8n-io/n8n/issues/5805)) ([29959be](https://github.com/n8n-io/n8n/commit/29959be6883d48c0385f333b3d9798a8c7c91c43)) +* **OpenAI Node:** Update OpenAI Text Moderate input placeholder text ([#5823](https://github.com/n8n-io/n8n/issues/5823)) ([6b9909b](https://github.com/n8n-io/n8n/commit/6b9909bd80f4b04ec877fe7ee9b8a2619392d220)) + + +### Features + +* **core:** Add variables feature ([#5602](https://github.com/n8n-io/n8n/issues/5602)) ([1bb9871](https://github.com/n8n-io/n8n/commit/1bb987140af8e835770a0ca45403e274a793f22c)) +* **core:** Add versionControl feature flag ([#6000](https://github.com/n8n-io/n8n/issues/6000)) ([33299ca](https://github.com/n8n-io/n8n/commit/33299ca61aaec94714431a58286da4bb2cf829c1)) +* **core:** Support for google service account in HTTP node ([0b48088](https://github.com/n8n-io/n8n/commit/0b48088296a7f826be3664f10c847b9dca753732)) +* **editor:** Add Ask AI preview ([#5916](https://github.com/n8n-io/n8n/issues/5916)) ([f8f8374](https://github.com/n8n-io/n8n/commit/f8f8374506c3d0c2ad7cea73bb461b3f64a81be1)) +* **GitLab Node:** Add Additional parameters for File List ([#5621](https://github.com/n8n-io/n8n/issues/5621)) ([3810039](https://github.com/n8n-io/n8n/commit/3810039da032ecbd038255316b3d8fa5ce5ef2df)) +* **MySQL Node:** Overhaul ([0a53c95](https://github.com/n8n-io/n8n/commit/0a53c957c4d69270e10058cdd384e47c8e3c987e)) + + + +## [0.224.1](https://github.com/n8n-io/n8n/compare/n8n@0.224.0...n8n@0.224.1) (2023-04-14) + + +### Bug Fixes + +* **core:** Fix broken API permissions in public API ([#5978](https://github.com/n8n-io/n8n/issues/5978)) ([b76ab31](https://github.com/n8n-io/n8n/commit/b76ab318f8919138391850738de99e592aa020a7)) +* **editor:** Only treat as CTRL pressed by default on touch devices for MouseEvent ([#5968](https://github.com/n8n-io/n8n/issues/5968)) ([471be3b](https://github.com/n8n-io/n8n/commit/471be3b4a89e9967524a5d06212153bbf2b56537)) + + + +# [0.224.0](https://github.com/n8n-io/n8n/compare/n8n@0.223.0...n8n@0.224.0) (2023-04-12) + + +### Bug Fixes + +* **Code Node:** Update vm2 to address CVE-2023-29017 ([#5947](https://github.com/n8n-io/n8n/issues/5947)) ([f0eba0a](https://github.com/n8n-io/n8n/commit/f0eba0a2f3be584363163abe2e30e8a57c9632f9)) +* **core:** App should not crash with a custom rest endpoint ([#5911](https://github.com/n8n-io/n8n/issues/5911)) ([2881ee9](https://github.com/n8n-io/n8n/commit/2881ee9ecc0e3258cf025ad7f1f571be4f21d320)), closes [#5880](https://github.com/n8n-io/n8n/issues/5880) +* **core:** Do not execute `workflowExecuteBefore` hook when resuming executions from a waiting state ([#5727](https://github.com/n8n-io/n8n/issues/5727)) ([6689451](https://github.com/n8n-io/n8n/commit/6689451e8c939bb2714c42ada83acdc0a9af62b7)) +* **core:** Fix issue where sub workflows would display as running forever after failure to start ([#5905](https://github.com/n8n-io/n8n/issues/5905)) ([3e382ef](https://github.com/n8n-io/n8n/commit/3e382ef85e966419dc71744b772d80c648583c5c)) +* **core:** Update xml2js to address CVE-2023-0842 ([#5948](https://github.com/n8n-io/n8n/issues/5948)) ([3085ed9](https://github.com/n8n-io/n8n/commit/3085ed9beee603cdb496fc7fb39357f15e0710d0)) +* **editor:** Drop mergeDeep in favor of lodash merge ([#5943](https://github.com/n8n-io/n8n/issues/5943)) ([0570514](https://github.com/n8n-io/n8n/commit/0570514b789c9fa020e96533b7c65bf45614c4d0)) +* **HTTP Request Node:** Show detailed error message in the UI again ([#5959](https://github.com/n8n-io/n8n/issues/5959)) ([e79679c](https://github.com/n8n-io/n8n/commit/e79679c023d127458227d904dbdb4824a755b956)) + + +### Features + +* Create TOTP node ([#5901](https://github.com/n8n-io/n8n/issues/5901)) ([6cf74e4](https://github.com/n8n-io/n8n/commit/6cf74e412a87ccb255efea950cb458712554391d)) +* **editor:** Add user activation survey ([#5677](https://github.com/n8n-io/n8n/issues/5677)) ([725393d](https://github.com/n8n-io/n8n/commit/725393dae625285ed91a7e4662eec1a425cf53f1)) +* **editor:** SAML login disables Invite button ([#5922](https://github.com/n8n-io/n8n/issues/5922)) ([3fdc441](https://github.com/n8n-io/n8n/commit/3fdc4413c20f1fd345a5864d9a237b30e20813f0)) +* **editor:** SAML paywall state ([#5906](https://github.com/n8n-io/n8n/issues/5906)) ([d40e86a](https://github.com/n8n-io/n8n/commit/d40e86aabc8c66a17b04cfe669ac27b4b281762a)) + + + +## [0.222.2](https://github.com/n8n-io/n8n/compare/n8n@0.222.1...n8n@0.222.2) (2023-04-11) + + +### Bug Fixes + +* **Code Node:** Update vm2 to address CVE-2023-29017 ([#5947](https://github.com/n8n-io/n8n/issues/5947)) ([fc1fb28](https://github.com/n8n-io/n8n/commit/fc1fb2863f6df697ca4a098054f9638cf2bc97bc)) +* **core:** Update xml2js to address CVE-2023-0842 ([#5948](https://github.com/n8n-io/n8n/issues/5948)) ([e903d61](https://github.com/n8n-io/n8n/commit/e903d6107112fca64b54dec76019720c9df6a66a)) + + + +## [0.221.3](https://github.com/n8n-io/n8n/compare/n8n@0.221.2...n8n@0.221.3) (2023-04-11) + + +### Bug Fixes + +* **Code Node:** Update vm2 to address CVE-2023-29017 ([#5947](https://github.com/n8n-io/n8n/issues/5947)) ([4127e3f](https://github.com/n8n-io/n8n/commit/4127e3ff9d1d1ef8b76c557bf3b4799ab7aba7f7)) +* **core:** Update xml2js to address CVE-2023-0842 ([#5948](https://github.com/n8n-io/n8n/issues/5948)) ([cf7a4b6](https://github.com/n8n-io/n8n/commit/cf7a4b65d3d828fbacd027de9c2bf2481883efa7)) + + + +# [0.223.0](https://github.com/n8n-io/n8n/compare/n8n@0.222.1...n8n@0.223.0) (2023-04-05) + + +### Bug Fixes + +* Add droppable state for booleans when mapping ([#5838](https://github.com/n8n-io/n8n/issues/5838)) ([e3884ce](https://github.com/n8n-io/n8n/commit/e3884ce378e488905735fbfdb986aa26f1cf952b)) +* **AWS SNS Node:** Fix an issue with messages failing to send if they contain certain characters ([#5807](https://github.com/n8n-io/n8n/issues/5807)) ([32c4eef](https://github.com/n8n-io/n8n/commit/32c4eef574a14ed599554382496a99a8240be74b)) +* **Compare Datasets Node:** Fuzzy compare not comparing keys missing in one of inputs ([d1945d9](https://github.com/n8n-io/n8n/commit/d1945d9b72fc11e7201e22a7ae0399acf2ffd5f1)) +* **Compare Datasets Node:** Support for dot notation in skip fields ([83e25c0](https://github.com/n8n-io/n8n/commit/83e25c066a845fc95c3474ae93f36993ca7ce699)) +* **core:** `augmentObject` should clone Buffer/Uint8Array instead of wrapping them in a proxy ([#5902](https://github.com/n8n-io/n8n/issues/5902)) ([a721734](https://github.com/n8n-io/n8n/commit/a72173414d9d31ab1824f87713709818955b8956)) +* **core:** `augmentObject` should use existing property descriptors whenever possible ([#5872](https://github.com/n8n-io/n8n/issues/5872)) ([6a1b7c3](https://github.com/n8n-io/n8n/commit/6a1b7c306bc9b7c469c5299af6beaf5af568b6b6)) +* **core:** Deactivate active workflows during import ([#5840](https://github.com/n8n-io/n8n/issues/5840)) ([fa5bc81](https://github.com/n8n-io/n8n/commit/fa5bc814b04273cff817d4a94b1d1ec6685807e0)) +* **core:** Do not mark duplicates as circular references in `jsonStringify` ([#5789](https://github.com/n8n-io/n8n/issues/5789)) ([18efaf3](https://github.com/n8n-io/n8n/commit/18efaf397a6bab8bd5dba881bbdfeceef8dbafb0)) +* **core:** Do not user `util.types.isProxy` for tracking of augmented objects ([#5836](https://github.com/n8n-io/n8n/issues/5836)) ([aacbb54](https://github.com/n8n-io/n8n/commit/aacbb54bef0743a1c5c5e2467dd7e00e50e325de)) +* **core:** Fix curl import error when no data ([085660d](https://github.com/n8n-io/n8n/commit/085660d7d7faf475b695724cabb6387c46adcc5f)) +* **core:** Fix the issue of nodes not loading when run via npx ([#5888](https://github.com/n8n-io/n8n/issues/5888)) ([e47190b](https://github.com/n8n-io/n8n/commit/e47190b5607dfd1284a1d2c3b8f678e8032627de)) +* **core:** Handle Date and RegExp correctly in jsonStringify ([#5812](https://github.com/n8n-io/n8n/issues/5812)) ([4f91525](https://github.com/n8n-io/n8n/commit/4f91525022716e7c1745185fae6fc2582a4252fb)) +* **core:** Handle Date and RegExp objects in AugmentObject ([#5809](https://github.com/n8n-io/n8n/issues/5809)) ([6c35ffa](https://github.com/n8n-io/n8n/commit/6c35ffa82c45434dadee0354b75a901d3f3d6e98)) +* **core:** Improve axios error handling in nodes ([#5891](https://github.com/n8n-io/n8n/issues/5891)) ([a260c05](https://github.com/n8n-io/n8n/commit/a260c05fa859c0bdd90f9abdecac59fd35978c55)) +* **core:** Password reset should pass in the correct values to external hooks ([#5842](https://github.com/n8n-io/n8n/issues/5842)) ([5bcab8f](https://github.com/n8n-io/n8n/commit/5bcab8fcbea546cd57ef728131f9e16cc57e675d)) +* **core:** Prevent augmentObject from creating infinitely deep proxies ([#5893](https://github.com/n8n-io/n8n/issues/5893)) ([31cd04c](https://github.com/n8n-io/n8n/commit/31cd04c4769b92c7f19ae8babf5df2deddef1fb3)), closes [#5848](https://github.com/n8n-io/n8n/issues/5848) +* **core:** Service account private key as a password field ([739b9b0](https://github.com/n8n-io/n8n/commit/739b9b07f0e364f98d3c2d0ce8911cd4f53e8455)) +* **core:** Update lock file ([#5801](https://github.com/n8n-io/n8n/issues/5801)) ([06d7a46](https://github.com/n8n-io/n8n/commit/06d7a46bdcd2173835ede762aff3c37b21f0a530)) +* **core:** Use table-prefixes in queries in import commands ([#5887](https://github.com/n8n-io/n8n/issues/5887)) ([ddbfcc7](https://github.com/n8n-io/n8n/commit/ddbfcc7d93cc3645fc3bb0f0de059ac76adaa475)) +* **core:** Waiting workflows not stopping ([#5811](https://github.com/n8n-io/n8n/issues/5811)) ([744c3fd](https://github.com/n8n-io/n8n/commit/744c3fd21130b6ee3c722df3fab096b169fd0ff8)) +* **Date & Time Node:** Add info box at top of date and time explaining expressions ([b7a20dd](https://github.com/n8n-io/n8n/commit/b7a20dd3a2e69a8e4e8ba76c63a6b1f4c26b6a87)) +* **Date & Time Node:** Convert luxon DateTime object to ISO ([7710652](https://github.com/n8n-io/n8n/commit/77106520c8942c746bb1ddffcddcde68a7059805)) +* **editor:** Add $if, $min, $max to root expression autocomplete ([#5858](https://github.com/n8n-io/n8n/issues/5858)) ([a13866e](https://github.com/n8n-io/n8n/commit/a13866e233430ec6aa9fcaa5f3861b3a4470b458)) +* **editor:** Curb overeager item access linting ([#5865](https://github.com/n8n-io/n8n/issues/5865)) ([3ae6933](https://github.com/n8n-io/n8n/commit/3ae69337eeb1f8a4d698f2099bb190c49cc5f8fd)) +* **editor:** Disable Grammarly in expression editors ([#5826](https://github.com/n8n-io/n8n/issues/5826)) ([ddc8f30](https://github.com/n8n-io/n8n/commit/ddc8f30e6d410f7453395f17754b8ee9a546d9b7)) +* **editor:** Disable password reset on desktop with no user management ([#5853](https://github.com/n8n-io/n8n/issues/5853)) ([96533a9](https://github.com/n8n-io/n8n/commit/96533a995c1e7ac653d3f135f954619b098bb609)) +* **editor:** Fix connection lost hover text not showing ([#5828](https://github.com/n8n-io/n8n/issues/5828)) ([b69129b](https://github.com/n8n-io/n8n/commit/b69129bd78689bd56c3a9b07c2e30f58735347d1)) +* **editor:** Fix focused state in Code node editor ([#5869](https://github.com/n8n-io/n8n/issues/5869)) ([48446f5](https://github.com/n8n-io/n8n/commit/48446f5d674c335716c86e30079eb35c75e32b66)) +* **editor:** Fix issue preventing execution preview loading when in an iframe ([#5817](https://github.com/n8n-io/n8n/issues/5817)) ([d86e693](https://github.com/n8n-io/n8n/commit/d86e693019db1fa034d43f8e7e18df09f785b2e1)) +* **editor:** Fix loading executions in long execution list ([#5843](https://github.com/n8n-io/n8n/issues/5843)) ([5c9343c](https://github.com/n8n-io/n8n/commit/5c9343c7c0febdeb3dfa449d6b18d744d909724a)) +* **editor:** Fix mapping with special characters ([#5837](https://github.com/n8n-io/n8n/issues/5837)) ([f8f584c](https://github.com/n8n-io/n8n/commit/f8f584c136da8ad8b17f82f6f4e95f0d69014b40)) +* **editor:** Prevent error from showing-up when duplicating unsaved workflow ([#5833](https://github.com/n8n-io/n8n/issues/5833)) ([0b0024d](https://github.com/n8n-io/n8n/commit/0b0024d7222ac1f6f7872b26eceefab93a17ef22)) +* **editor:** Prevent NDV schema view pagination ([#5844](https://github.com/n8n-io/n8n/issues/5844)) ([1eba478](https://github.com/n8n-io/n8n/commit/1eba4788f26d0f5472fa4156b317d8b14d19b927)) +* **editor:** Show correct status on canceled executions ([#5813](https://github.com/n8n-io/n8n/issues/5813)) ([d0788ee](https://github.com/n8n-io/n8n/commit/d0788ee8e150167a65561552494046d8e506f93c)) +* **editor:** Support backspacing with modifier key ([#5845](https://github.com/n8n-io/n8n/issues/5845)) ([11692c5](https://github.com/n8n-io/n8n/commit/11692c55f381f17a7a137262d85dfd6c7fda7ad5)) +* **Gmail Node:** Gmail luxon object support, fix for timestamp ([2b9ca0d](https://github.com/n8n-io/n8n/commit/2b9ca0d240b403a5f12b115956bbc11672f3a04a)) +* **Google Sheets Node:** Fix insertOrUpdate cell update with object ([0625e2e](https://github.com/n8n-io/n8n/commit/0625e2e6bc67092848f719f8fede87af0f3df891)) +* **HTML Extract Node:** Support for dot notation in JSON property ([0da3b96](https://github.com/n8n-io/n8n/commit/0da3b96cfc943bf8036a48df946873fb32f3f5d9)) +* **HTTP Request Node:** Detect mime-type from streaming responses ([#5896](https://github.com/n8n-io/n8n/issues/5896)) ([69efde7](https://github.com/n8n-io/n8n/commit/69efde7a094b0bf3e3ca04b456ba3a792838a0b9)) +* **HTTP Request Node:** Fix AWS credentials to stop removing url params for STS ([#5790](https://github.com/n8n-io/n8n/issues/5790)) ([a1306c6](https://github.com/n8n-io/n8n/commit/a1306c690398828ed9acb72af0161c4ff827b217)) +* **HTTP Request Node:** Refresh token properly on never fail option ([#5861](https://github.com/n8n-io/n8n/issues/5861)) ([33c67f4](https://github.com/n8n-io/n8n/commit/33c67f45ba959b90c8bebbe0b27b2a7c4152a116)) +* **HTTP Request Node:** Support for dot notation in JSON body ([b29cf9a](https://github.com/n8n-io/n8n/commit/b29cf9a2492a444cb1dd72e74c9ed1d8722bbc5a)) +* **HubSpot Trigger Node:** Developer API key is required for webhooks ([e11a30a](https://github.com/n8n-io/n8n/commit/e11a30a640700d2bc53919422cb8ddbf66aafddd)) +* **LinkedIn Node:** Update the version of the API ([#5720](https://github.com/n8n-io/n8n/issues/5720)) ([18d2e7c](https://github.com/n8n-io/n8n/commit/18d2e7cd57745f0969b0df383572b3874fe65f2c)) +* **Redis Node:** Fix issue with hash set not working as expected ([#5832](https://github.com/n8n-io/n8n/issues/5832)) ([db25441](https://github.com/n8n-io/n8n/commit/db2544146f646ec9a2c38787bc94eafc1edb1228)) +* **Set Node:** Convert string to number ([b408550](https://github.com/n8n-io/n8n/commit/b408550e9f486351198f0ce5c10895c42df45835)) + + +### Features + +* **core:** Convert eventBus controller to decorator style and improve permissions ([#5779](https://github.com/n8n-io/n8n/issues/5779)) ([f15f4bd](https://github.com/n8n-io/n8n/commit/f15f4bdcf204fa43a652022babf03e577602b2b5)) +* **core:** Prevent non owners password reset when saml is enabled ([#5788](https://github.com/n8n-io/n8n/issues/5788)) ([2216455](https://github.com/n8n-io/n8n/commit/221645576087e4cd828b34ea33e874e1bff5f34a)) +* **core:** Read ephemeral license from environment and clean up ee flags ([#5808](https://github.com/n8n-io/n8n/issues/5808)) ([83aef17](https://github.com/n8n-io/n8n/commit/83aef1712070c29fea5d0522c95b1208af4cd2e4)) +* **editor:** Allow `tab` to accept completion ([#5855](https://github.com/n8n-io/n8n/issues/5855)) ([1b8c35a](https://github.com/n8n-io/n8n/commit/1b8c35ab87ce7ea24d00d13faddba9daf9f2ab39)) +* **editor:** Enable saving workflow when node details view is open ([#5856](https://github.com/n8n-io/n8n/issues/5856)) ([0a59002](https://github.com/n8n-io/n8n/commit/0a59002ef878ff8836d3ca63956f7a444d329d0b)) +* **editor:** SSO onboarding ([#5756](https://github.com/n8n-io/n8n/issues/5756)) ([04f8600](https://github.com/n8n-io/n8n/commit/04f8600bbd220204b5e5a90f22c3dc9c137afb54)) +* **editor:** SSO setup ([#5736](https://github.com/n8n-io/n8n/issues/5736)) ([f4e5949](https://github.com/n8n-io/n8n/commit/f4e59499fc0168295c5df20b1525c7ecea4ea15b)), closes [#5899](https://github.com/n8n-io/n8n/issues/5899) +* **Filter Node:** Show discarded items ([f7f9d91](https://github.com/n8n-io/n8n/commit/f7f9d915b174d5c17efa918032741d4fa6da85e9)) +* **HTTP Request Node:** Follow redirects by default ([#5895](https://github.com/n8n-io/n8n/issues/5895)) ([f7e610b](https://github.com/n8n-io/n8n/commit/f7e610b15c4699880edffd7f10f223e820052784)) +* **Postgres Node:** Overhaul node ([07dc0e4](https://github.com/n8n-io/n8n/commit/07dc0e4b4075f1fac98d5685a99a38187bca741b)) +* **ServiceNow Node:** Add support for work notes when updating an incident ([#5791](https://github.com/n8n-io/n8n/issues/5791)) ([1409f5d](https://github.com/n8n-io/n8n/commit/1409f5d65262b9783e690408f5dabd074c709f22)) +* **SSH Node:** Hide the private key within the ssh credential ([#5871](https://github.com/n8n-io/n8n/issues/5871)) ([d877361](https://github.com/n8n-io/n8n/commit/d87736103d09042d2f74e74b57be429f2ca3550d)) + + + +## [0.222.1](https://github.com/n8n-io/n8n/compare/n8n@0.222.0...n8n@0.222.1) (2023-04-04) + + +### Bug Fixes + +* **AWS SNS Node:** Fix an issue with messages failing to send if they contain certain characters ([#5807](https://github.com/n8n-io/n8n/issues/5807)) ([f0954b9](https://github.com/n8n-io/n8n/commit/f0954b94e164f0ff3e809849731137cf479670a4)) +* **core:** `augmentObject` should clone Buffer/Uint8Array instead of wrapping them in a proxy ([#5902](https://github.com/n8n-io/n8n/issues/5902)) ([a877b02](https://github.com/n8n-io/n8n/commit/a877b025b8c93a31822e7ccb5b935dca00595439)) +* **core:** `augmentObject` should use existing property descriptors whenever possible ([#5872](https://github.com/n8n-io/n8n/issues/5872)) ([b1ee8f4](https://github.com/n8n-io/n8n/commit/b1ee8f4d991ffc5017894f588e9bd21f652f23c6)) +* **core:** Fix the issue of nodes not loading when run via npx ([#5888](https://github.com/n8n-io/n8n/issues/5888)) ([163446c](https://github.com/n8n-io/n8n/commit/163446c674d07c060eaa0d7ec54a087a4cb671d5)) +* **core:** Improve axios error handling in nodes ([#5891](https://github.com/n8n-io/n8n/issues/5891)) ([f0a51a0](https://github.com/n8n-io/n8n/commit/f0a51a0b7671945b84e18774483dc7079f559845)) +* **core:** Password reset should pass in the correct values to external hooks ([#5842](https://github.com/n8n-io/n8n/issues/5842)) ([3bf267c](https://github.com/n8n-io/n8n/commit/3bf267c14757fa67dcfa0edf0b4db74ab1b7415c)) +* **core:** Prevent augmentObject from creating infinitely deep proxies ([#5893](https://github.com/n8n-io/n8n/issues/5893)) ([6906b00](https://github.com/n8n-io/n8n/commit/6906b00b0e734dbe59d1a3a91f07ec1007166b72)), closes [#5848](https://github.com/n8n-io/n8n/issues/5848) +* **core:** Use table-prefixes in queries in import commands ([#5887](https://github.com/n8n-io/n8n/issues/5887)) ([de58fb9](https://github.com/n8n-io/n8n/commit/de58fb9860d37a39a1e8963e304b8fc87f234bf6)) +* **editor:** Fix focused state in Code node editor ([#5869](https://github.com/n8n-io/n8n/issues/5869)) ([3be37e2](https://github.com/n8n-io/n8n/commit/3be37e25a52983b43b4eed3847922e99f2bf07bc)) +* **editor:** Fix loading executions in long execution list ([#5843](https://github.com/n8n-io/n8n/issues/5843)) ([d5d9f58](https://github.com/n8n-io/n8n/commit/d5d9f58f1777040c63a2a0aa3c0d7bf632dc7b26)) +* **editor:** Show correct status on canceled executions ([#5813](https://github.com/n8n-io/n8n/issues/5813)) ([00181cd](https://github.com/n8n-io/n8n/commit/00181cd803f5a0cca50b5fc1ab84d0a26dd618ae)) +* **Gmail Node:** Gmail luxon object support, fix for timestamp ([695fabb](https://github.com/n8n-io/n8n/commit/695fabb28465d01caa85aefb1e873f88720ce304)) +* **HTTP Request Node:** Detect mime-type from streaming responses ([#5896](https://github.com/n8n-io/n8n/issues/5896)) ([0be1292](https://github.com/n8n-io/n8n/commit/0be129254e96c822dceddbfffaf36e0e6b2ef5e8)) +* **HubSpot Trigger Node:** Developer API key is required for webhooks ([918c79c](https://github.com/n8n-io/n8n/commit/918c79c137f781764f11ad3a33ead337efce681a)) +* **Set Node:** Convert string to number ([72eea0d](https://github.com/n8n-io/n8n/commit/72eea0dfb9e679bde95996d99684e23d081db5a7)) + + + +# [0.222.0](https://github.com/n8n-io/n8n/compare/n8n@0.221.2...n8n@0.222.0) (2023-03-30) + + +### Bug Fixes + +* **core:** Assign properties.success earlier to set executionStatus correctly ([#5773](https://github.com/n8n-io/n8n/issues/5773)) ([d33a1ac](https://github.com/n8n-io/n8n/commit/d33a1ac1e9a13985ff84f9271f75ebf368339b6d)) +* **core:** Do not mark duplicates as circular references in `jsonStringify` ([#5789](https://github.com/n8n-io/n8n/issues/5789)) ([f5183c6](https://github.com/n8n-io/n8n/commit/f5183c640109fb4c9552d6b2786f8ebc0e35ca4c)) +* **core:** Do not user `util.types.isProxy` for tracking of augmented objects ([#5836](https://github.com/n8n-io/n8n/issues/5836)) ([3e413f2](https://github.com/n8n-io/n8n/commit/3e413f2f80d6fa349dc6f6ea1b49027ae163df80)) +* **core:** Ensure that all non-lazy-loaded community nodes get post-processed correctly ([#5782](https://github.com/n8n-io/n8n/issues/5782)) ([30aeeb7](https://github.com/n8n-io/n8n/commit/30aeeb70b43ff3916ad79abbe49512a27e50d01d)) +* **core:** Force-upgrade `decode-uri-component` to address CVE-2022-38900 ([#5734](https://github.com/n8n-io/n8n/issues/5734)) ([8dd7f6e](https://github.com/n8n-io/n8n/commit/8dd7f6e1d4ac29b450a0af17d545ffd17038b005)) +* **core:** Force-upgrade `http-cache-semantics` to address CVE-2022-25881 ([#5733](https://github.com/n8n-io/n8n/issues/5733)) ([f7401fb](https://github.com/n8n-io/n8n/commit/f7401fb6133b9bf18f3825304c478d767e69fe27)) +* **core:** Handle Date and RegExp correctly in jsonStringify ([#5812](https://github.com/n8n-io/n8n/issues/5812)) ([753cfb8](https://github.com/n8n-io/n8n/commit/753cfb8b08ff68cc30e6e30959fd0900a44dae21)) +* **core:** Handle Date and RegExp objects in AugmentObject ([#5809](https://github.com/n8n-io/n8n/issues/5809)) ([e6d4e72](https://github.com/n8n-io/n8n/commit/e6d4e729a063cdbbf648c2815c3e55ddddef2b58)) +* **core:** Improve axios error handling in nodes ([#5699](https://github.com/n8n-io/n8n/issues/5699)) ([33d9784](https://github.com/n8n-io/n8n/commit/33d97843194c1cddfd27356c279c0f7a8c2674d3)) +* **core:** Improve community nodes loading ([#5608](https://github.com/n8n-io/n8n/issues/5608)) ([161de11](https://github.com/n8n-io/n8n/commit/161de110cedf2d5d8bc345349d4364ea202e2abd)) +* **core:** Initialize queue in the webhook server as well ([#5766](https://github.com/n8n-io/n8n/issues/5766)) ([e67ad29](https://github.com/n8n-io/n8n/commit/e67ad2962589b445592641a588024c02b4d99d3f)) +* **core:** Persist CurrentAuthenticationMethod setting change ([#5762](https://github.com/n8n-io/n8n/issues/5762)) ([4498c60](https://github.com/n8n-io/n8n/commit/4498c6013dc5b4646b1e3fdba3adef42bfc87952)) +* **core:** Remove circular refs from Code and push msg ([#5741](https://github.com/n8n-io/n8n/issues/5741)) ([b6d8a0f](https://github.com/n8n-io/n8n/commit/b6d8a0f98526bfc98a3d9a722dafce4a53e715ec)) +* **core:** Require Auth on icons and nodes/credentials types static files ([#5745](https://github.com/n8n-io/n8n/issues/5745)) ([5dda3f2](https://github.com/n8n-io/n8n/commit/5dda3f2c61b107ec24557c4bf7de284234e406ab)) +* **core:** Return SAML service provider urls with config ([#5759](https://github.com/n8n-io/n8n/issues/5759)) ([ac18c0b](https://github.com/n8n-io/n8n/commit/ac18c0b9ebb3a5a736fa72985ce5ae2cdab3b270)) +* **core:** Service account private key as a password field ([2b28470](https://github.com/n8n-io/n8n/commit/2b28470fb98a5810ce0d20fe995e2864230005d3)) +* **core:** Upgrade `luxon` to address CVE-2023-22467 ([#5731](https://github.com/n8n-io/n8n/issues/5731)) ([469ce32](https://github.com/n8n-io/n8n/commit/469ce32957ac5e4d342db17a2f680ca65c21d44f)) +* **core:** Upgrade `simple-git` to address CVE-2022-25912 ([#5730](https://github.com/n8n-io/n8n/issues/5730)) ([4a4e2be](https://github.com/n8n-io/n8n/commit/4a4e2be96c0ce096d100e08823aa6b256719c267)) +* **core:** Upgrade `sqlite3` to address CVE-2022-43441 ([#5732](https://github.com/n8n-io/n8n/issues/5732)) ([fd81c74](https://github.com/n8n-io/n8n/commit/fd81c742519882b04f98d25ca41b3fac16dbea8b)) +* **core:** Upgrade convict to address CVE-2023-0163 ([#5729](https://github.com/n8n-io/n8n/issues/5729)) ([564bc03](https://github.com/n8n-io/n8n/commit/564bc03d3fab59e4fe7fa904d5deeeb16da85af9)) +* **core:** Waiting workflows not stopping ([#5811](https://github.com/n8n-io/n8n/issues/5811)) ([8f50bb6](https://github.com/n8n-io/n8n/commit/8f50bb6ed13688d8a81a171c1f1fb9e85847f138)) +* **editor:** Fix connection lost hover text not showing ([#5828](https://github.com/n8n-io/n8n/issues/5828)) ([a2f4a05](https://github.com/n8n-io/n8n/commit/a2f4a05af7f20e007ef16357d71af6a0dbade55c)) +* **editor:** Fix issue preventing execution preview loading when in an iframe ([#5817](https://github.com/n8n-io/n8n/issues/5817)) ([d19a973](https://github.com/n8n-io/n8n/commit/d19a9732b7da188017e2141b3deed7ea004c04a6)) +* **editor:** Use credentials when fetching node and credential types ([#5760](https://github.com/n8n-io/n8n/issues/5760)) ([d3a34ab](https://github.com/n8n-io/n8n/commit/d3a34ab71bd7fc494fb90cda1aa9827a55c5ed69)) +* **Google Sheets Node:** Fix insertOrUpdate cell update with object ([1797cda](https://github.com/n8n-io/n8n/commit/1797cdab5b9e7f23c9f62ce7c6c34c1c2c26b07e)) +* **HTTP Request Node:** Add streaming to binary response ([#5701](https://github.com/n8n-io/n8n/issues/5701)) ([199a91b](https://github.com/n8n-io/n8n/commit/199a91b3981d40b7181f00702c938b9fa58d1ece)), closes [#5663](https://github.com/n8n-io/n8n/issues/5663) +* **HTTP Request Node:** Fix AWS credentials to automatically deconstruct the url ([#5751](https://github.com/n8n-io/n8n/issues/5751)) ([d30b892](https://github.com/n8n-io/n8n/commit/d30b89239587562974cc87ae2e29fe57acddf79e)) +* **HTTP Request Node:** Fix AWS credentials to stop removing url params for STS ([#5790](https://github.com/n8n-io/n8n/issues/5790)) ([2c25959](https://github.com/n8n-io/n8n/commit/2c25959e595a48d9ae55c462bd47e014d8c43598)) +* **Split In Batches Node:** Roll back changes in v1 and create v2 ([#5747](https://github.com/n8n-io/n8n/issues/5747)) ([135b0d3](https://github.com/n8n-io/n8n/commit/135b0d3e27705b07fb1e9c39a47ac4b70c1bc25d)) +* Update Posthog no-capture ([#5693](https://github.com/n8n-io/n8n/issues/5693)) ([a732374](https://github.com/n8n-io/n8n/commit/a732374f24354e0c1f36247f4476e743b0fc78e5)) + + +### Features + +* Add test overrides ([#5642](https://github.com/n8n-io/n8n/issues/5642)) ([696e43a](https://github.com/n8n-io/n8n/commit/696e43a919334d982188cc1f86d3e1b76da6a362)) +* **core:** Improve ldap/saml toggle and tests ([#5771](https://github.com/n8n-io/n8n/issues/5771)) ([47ee357](https://github.com/n8n-io/n8n/commit/47ee357059bb6d87607165d3c24ce0c99cf8bfc9)) +* **core:** Limit user invites when SAML is enabled ([#5761](https://github.com/n8n-io/n8n/issues/5761)) ([57748b7](https://github.com/n8n-io/n8n/commit/57748b71e5cd1399ccaedb9a115b821b34cf55e5)) +* **core:** Make OAuth2 error handling consistent with success handling ([#5555](https://github.com/n8n-io/n8n/issues/5555)) ([40aacf9](https://github.com/n8n-io/n8n/commit/40aacf9279c00c4e3c27669fc38a0ca196a788a4)) +* **editor:** Fix ResourceLocator dropdown style ([#5714](https://github.com/n8n-io/n8n/issues/5714)) ([02810a9](https://github.com/n8n-io/n8n/commit/02810a9ba3e792a2ec8966a2ca3bf7394740bf24)) +* Execution custom data saving and filtering ([#5496](https://github.com/n8n-io/n8n/issues/5496)) ([d78a41d](https://github.com/n8n-io/n8n/commit/d78a41db5420ff6711c30899aa2d71a85049374c)), closes [#5739](https://github.com/n8n-io/n8n/issues/5739) +* **Filter Node:** New node ([cc9fe7b](https://github.com/n8n-io/n8n/commit/cc9fe7b91ffc4ea72c25e1242e0e477112cb283e)) + + + ## [0.221.2](https://github.com/n8n-io/n8n/compare/n8n@0.221.1...n8n@0.221.2) (2023-03-24) diff --git a/CHECKLIST.yml b/CHECKLIST.yml index 7da0dcb08cbb1..a1c3fa8407508 100644 --- a/CHECKLIST.yml +++ b/CHECKLIST.yml @@ -1,48 +1,50 @@ paths: - "packages/**": + 'packages/**': - If fixing bug, added test to cover scenario. - If addressing forum or Github issue, added link to description. - "packages/**/*.ts": + 'packages/**/*.ts': - Added unit tests to cover new or updated functionality. - "**/*.vue": + '**/*.vue': - Used composition API for all new components. - Added component or unit tests to cover functionality. - + # cli - "packages/cli/src/databases/migrations/**": - - Requested review from at least two engineers on migration. + 'packages/cli/src/databases/migrations/**': + - Requested review from at least two engineers on migration. - Avoided irreversible data migrations. - Avoided deleting or updating data keys. - Wrote 'down' migration if possible. - "n8n/packages/cli/src/api/**": + 'n8n/packages/cli/src/api/**': - Added integration tests for new endpoints. # editor ui - "packages/editor-ui/**/*.vue": + 'packages/editor-ui/**/*.vue': - Added E2E if adding new features. - Used design system tokens (colors, spacings...) where possible. - "packages/editor-ui/src/mixins/restApi.ts": + 'packages/editor-ui/src/mixins/restApi.ts': - Avoided adding new methods. Only deleted from here. - "packages/editor-ui/src/mixins/**": + 'packages/editor-ui/src/mixins/**': - Avoided adding new mixins (use composables instead). Only removed code from here. - "packages/editor-ui/src/views/NodeView.vue": - - Avoided adding code here. Only refactored to make it smaller. - "packages/editor-ui/src/hooks/**": + 'packages/editor-ui/src/views/NodeView.vue': + - Avoided adding code here. Only refactored to make it smaller. + 'packages/editor-ui/src/hooks/**': - Avoided adding new hooks. Only refactored to move hooks to relevant store instead. # nodes-base - "packages/nodes-base/nodes/**": + 'packages/nodes-base/nodes/**': - Added workflow tests for nodes if possible. + 'packages/nodes-base/package.json': + - Avoided adding dependencies for nodes if not absolutely necessary. # design-system - "packages/design-system/**/*.vue": + 'packages/design-system/**/*.vue': - Used design system tokens (colors, spacings...) where possible. - Updated Storybook with new component or updated functionality. # e2e - "cypress/e2e/**": + 'cypress/e2e/**': - Avoided chaining commands more than two or three times (to avoid flakiness because only last one will be retried). - Spoofed endpoints that are not critical for the test (to avoid flakiness). - Picked most efficient path to start the test (for example skipped account setup and starting at /workflow/new for a canvas test). - Avoided adding waits on time (use request intercepts instead). - - Ensured each spec does not depend on any another spec to pass. \ No newline at end of file + - Ensured each spec does not depend on any another spec to pass. diff --git a/README.md b/README.md index 980a0c665dbe4..7408ef67e4d1f 100644 --- a/README.md +++ b/README.md @@ -88,5 +88,7 @@ n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) and the [**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE_EE.md). +Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) + Additional information about the license model can be found in the [docs](https://docs.n8n.io/reference/license/). diff --git a/cypress/e2e/10-settings-log-streaming.cy.ts b/cypress/e2e/10-settings-log-streaming.cy.ts index e67f47045d8ef..0126667a699b5 100644 --- a/cypress/e2e/10-settings-log-streaming.cy.ts +++ b/cypress/e2e/10-settings-log-streaming.cy.ts @@ -26,7 +26,7 @@ describe('Log Streaming Settings', () => { }); it('should show the licensed view when the feature is enabled', () => { - cy.enableFeature('logStreaming'); + cy.enableFeature('feat:logStreaming'); cy.visit('/settings/log-streaming'); settingsLogStreamingPage.getters.getActionBoxLicensed().should('be.visible'); settingsLogStreamingPage.getters.getAddFirstDestinationButton().should('be.visible'); diff --git a/cypress/e2e/11-inline-expression-editor.cy.ts b/cypress/e2e/11-inline-expression-editor.cy.ts index de5594a4f429a..88fea311d92cb 100644 --- a/cypress/e2e/11-inline-expression-editor.cy.ts +++ b/cypress/e2e/11-inline-expression-editor.cy.ts @@ -69,6 +69,6 @@ describe('Inline expression editor', () => { WorkflowPage.getters.inlineExpressionEditorInput().clear(); WorkflowPage.getters.inlineExpressionEditorInput().type('{{'); WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]'); - WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^getAll$/); + WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^get$/); }); }); diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index 8a3ea4a7c8162..c278231017f9d 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -1,3 +1,9 @@ +import { + HTTP_REQUEST_NODE_NAME, + MANUAL_TRIGGER_NODE_NAME, + PIPEDRIVE_NODE_NAME, + SET_NODE_NAME, +} from '../constants'; import { WorkflowPage, NDV } from '../pages'; const workflowPage = new WorkflowPage(); @@ -44,7 +50,7 @@ describe('Data pinning', () => { }); }); - it('Should be be able to set pinned data', () => { + it('Should be able to set pinned data', () => { workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true }); ndv.getters.container().should('be.visible'); ndv.getters.pinDataButton().should('not.exist'); @@ -67,4 +73,35 @@ describe('Data pinning', () => { ndv.getters.outputTableHeaders().first().should('include.text', 'test'); ndv.getters.outputTbodyCell(1, 0).should('include.text', 1); }); + + it('Should be able to reference paired items in a node located before pinned data', () => { + workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true); + ndv.actions.setPinnedData([{ http: 123 }]); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true); + ndv.actions.setPinnedData(Array(3).fill({ pipedrive: 123 })); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true, true); + setExpressionOnStringValueInSet(`{{ $('${HTTP_REQUEST_NODE_NAME}').item`); + + const output = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]'; + + cy.get('div').contains(output).should('be.visible'); + }); }); + +function setExpressionOnStringValueInSet(expression: string) { + cy.get('button').contains('Execute node').click(); + cy.get('input[placeholder="Add Value"]').click(); + cy.get('span').contains('String').click(); + + ndv.getters.nthParam(3).contains('Expression').invoke('show').click(); + + ndv.getters + .inlineExpressionEditorInput() + .clear() + .type(expression, { parseSpecialCharSequences: false }); +} diff --git a/cypress/e2e/14-data-transformation-expressions.cy.ts b/cypress/e2e/14-data-transformation-expressions.cy.ts index 9e64043fcda3a..cb08d51e5b1e8 100644 --- a/cypress/e2e/14-data-transformation-expressions.cy.ts +++ b/cypress/e2e/14-data-transformation-expressions.cy.ts @@ -12,11 +12,12 @@ describe('Data transformation expressions', () => { beforeEach(() => { wf.actions.visit(); - cy.window() - // @ts-ignore - .then( - (win) => win.onBeforeUnloadNodeView && win.removeEventListener('beforeunload', win.onBeforeUnloadNodeView), - ); + cy.window().then( + (win) => { + // @ts-ignore + win.preventNodeViewBeforeUnload = true; + }, + ); }); it('$json + native string methods', () => { diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 73e628216e013..8c4d6f9cdbe05 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -17,11 +17,12 @@ describe('Data mapping', () => { beforeEach(() => { workflowPage.actions.visit(); - cy.window() - // @ts-ignore - .then( - (win) => win.onBeforeUnloadNodeView && win.removeEventListener('beforeunload', win.onBeforeUnloadNodeView), - ); + cy.window().then( + (win) => { + // @ts-ignore + win.preventNodeViewBeforeUnload = true; + }, + ); }); it('maps expressions from table header', () => { @@ -56,7 +57,7 @@ describe('Data mapping', () => { ndv.actions.mapDataFromHeader(2, 'value'); ndv.getters .inlineExpressionEditorInput() - .should('have.text', '{{ $json.timestamp }} {{ $json["Readable date"] }}'); + .should('have.text', "{{ $json.timestamp }} {{ $json['Readable date'] }}"); }); it('maps expressions from table json, and resolves value based on hover', () => { @@ -193,7 +194,7 @@ describe('Data mapping', () => { ndv.actions.mapToParameter('value'); ndv.getters .inlineExpressionEditorInput() - .should('have.text', `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }}`); + .should('have.text', `{{ $node['${SCHEDULE_TRIGGER_NODE_NAME}'].json.input[0].count }}`); ndv.getters.parameterExpressionPreview('value').should('not.exist'); ndv.actions.switchInputMode('Table'); @@ -202,9 +203,9 @@ describe('Data mapping', () => { .inlineExpressionEditorInput() .should( 'have.text', - `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }} {{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input }}`, + `{{ $node['${SCHEDULE_TRIGGER_NODE_NAME}'].json.input[0].count }} {{ $node['${SCHEDULE_TRIGGER_NODE_NAME}'].json.input }}`, ); - ndv.getters.parameterExpressionPreview('value').should('not.exist'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '[empty]'); ndv.actions.selectInputNode('Set'); @@ -259,7 +260,7 @@ describe('Data mapping', () => { ndv.getters .parameterInput('fieldName') .find('input') - .should('have.value', 'input[0]["hello.world"]["my count"]'); + .should('have.value', "input[0]['hello.world']['my count']"); }); it('maps expressions to updated fields correctly', () => { @@ -291,4 +292,31 @@ describe('Data mapping', () => { .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}'); ndv.getters.parameterExpressionPreview('value').should('include.text', '0 [object Object]'); }); + + it('shows you can drop to inputs, including booleans', () => { + cy.fixture('Test_workflow_3.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + + workflowPage.actions.openNode('Set'); + ndv.actions.clearParameterInput('value'); + cy.get('body').type('{esc}'); + + ndv.getters.parameterInput('keepOnlySet').find('input[type="checkbox"]').should('exist'); + ndv.getters.parameterInput('keepOnlySet').find('input[type="text"]').should('not.exist'); + ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown().realMouseMove(100, 100); + cy.wait(50); + + ndv.getters.parameterInput('keepOnlySet').find('input[type="checkbox"]').should('not.exist'); + ndv.getters.parameterInput('keepOnlySet').find('input[type="text"]') + .should('exist') + .invoke('css', 'border') + .then((border) => expect(border).to.include('1.5px dashed rgb(90, 76, 194)')); + + ndv.getters.parameterInput('value').find('input[type="text"]') + .should('exist') + .invoke('css', 'border') + .then((border) => expect(border).to.include('1.5px dashed rgb(90, 76, 194)')); + }); + }); diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 7da46167ec59d..d2bceaf22faa1 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -99,11 +99,12 @@ describe('Webhook Trigger node', async () => { beforeEach(() => { workflowPage.actions.visit(); - cy.window() - // @ts-ignore - .then( - (win) => win.onBeforeUnloadNodeView && win.removeEventListener('beforeunload', win.onBeforeUnloadNodeView), - ); + cy.window().then( + (win) => { + // @ts-ignore + win.preventNodeViewBeforeUnload = true; + }, + ); }); it('should listen for a GET request', () => { diff --git a/cypress/e2e/22-user-activation-modal.cy.ts b/cypress/e2e/22-user-activation-modal.cy.ts new file mode 100644 index 0000000000000..6b8f23be2fcf5 --- /dev/null +++ b/cypress/e2e/22-user-activation-modal.cy.ts @@ -0,0 +1,65 @@ +import { WorkflowPage, NDV, UserActivationSurveyModal } from '../pages'; +import SettingsWithActivationModalEnabled from '../fixtures/Settings_user_activation_modal_enabled.json'; +import { v4 as uuid } from 'uuid'; + +const workflowPage = new WorkflowPage(); +const ndv = new NDV(); +const userActivationSurveyModal = new UserActivationSurveyModal(); + +const BASE_WEBHOOK_URL = 'http://localhost:5678/webhook'; + +describe('User activation survey', () => { + it('Should show activation survey', () => { + cy.resetAll(); + + cy.skipSetup(); + + cy.intercept('GET', '/rest/settings', (req) => { + req.reply(SettingsWithActivationModalEnabled); + }); + + const path = uuid(); + const method = 'GET'; + + workflowPage.actions.addInitialNodeToCanvas('Webhook'); + workflowPage.actions.openNode('Webhook'); + + //input http method + cy.getByTestId('parameter-input-httpMethod').click(); + cy.getByTestId('parameter-input-httpMethod') + .find('.el-select-dropdown') + .find('.option-headline') + .contains(method) + .click(); + + //input path method + cy.getByTestId('parameter-input-path') + .find('.parameter-input') + .find('input') + .clear() + .type(path); + + ndv.actions.close(); + + workflowPage.actions.saveWorkflowOnButtonClick(); + + workflowPage.actions.activateWorkflow(); + + cy.intercept('GET', '/rest/workflows').as('getWorkflows'); + cy.intercept('GET', '/rest/credentials').as('getCredentials'); + cy.intercept('GET', '/rest/active').as('getActive'); + + cy.request(method, `${BASE_WEBHOOK_URL}/${path}`).then((response) => { + expect(response.status).to.eq(200); + cy.visit('/'); + cy.reload(); + + cy.wait(['@getWorkflows', '@getCredentials', '@getActive']); + userActivationSurveyModal.getters.modalContainer().should('be.visible'); + userActivationSurveyModal.getters.feedbackInput().should('be.visible'); + userActivationSurveyModal.getters.feedbackInput().type('testing'); + userActivationSurveyModal.getters.feedbackInput().should('have.value', 'testing'); + userActivationSurveyModal.getters.sendFeedbackButton().click(); + }); + }); +}); diff --git a/cypress/e2e/23-variables.cy.ts b/cypress/e2e/23-variables.cy.ts new file mode 100644 index 0000000000000..8dc16bb8e967d --- /dev/null +++ b/cypress/e2e/23-variables.cy.ts @@ -0,0 +1,126 @@ +import { VariablesPage } from '../pages/variables'; +import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants'; +import { randFirstName, randLastName } from '@ngneat/falso'; + +const variablesPage = new VariablesPage(); + +const email = DEFAULT_USER_EMAIL; +const password = DEFAULT_USER_PASSWORD; +const firstName = randFirstName(); +const lastName = randLastName(); + +describe('Variables', () => { + before(() => { + cy.resetAll(); + cy.setup({ email, firstName, lastName, password }); + }); + + it('should show the unlicensed action box when the feature is disabled', () => { + cy.signin({ email, password }); + cy.visit(variablesPage.url); + + variablesPage.getters.unavailableResourcesList().should('be.visible'); + variablesPage.getters.resourcesList().should('not.exist'); + }); + + describe('licensed', () => { + before(() => { + cy.enableFeature('feat:variables'); + }); + + beforeEach(() => { + cy.signin({ email, password }); + cy.visit(variablesPage.url); + }); + + it('should show the licensed action box when the feature is enabled', () => { + variablesPage.getters.emptyResourcesList().should('be.visible'); + variablesPage.getters.createVariableButton().should('be.visible'); + }); + + it('should create a new variable using empty state row', () => { + const key = 'ENV_VAR'; + const value = 'value'; + + variablesPage.actions.createVariableFromEmptyState(key, value); + variablesPage.getters.variableRow(key).should('contain', value).should('be.visible'); + variablesPage.getters.variablesRows().should('have.length', 1); + }); + + it('should create a new variable using pre-existing state', () => { + const key = 'ENV_VAR_NEW'; + const value = 'value2'; + + variablesPage.actions.createVariable(key, value); + variablesPage.getters.variableRow(key).should('contain', value).should('be.visible'); + variablesPage.getters.variablesRows().should('have.length', 2); + + const otherKey = 'ENV_EXAMPLE'; + const otherValue = 'value3'; + + variablesPage.actions.createVariable(otherKey, otherValue); + variablesPage.getters + .variableRow(otherKey) + .should('contain', otherValue) + .should('be.visible'); + variablesPage.getters.variablesRows().should('have.length', 3); + }); + + it('should get validation errors and cancel variable creation', () => { + const key = 'ENV_VAR_NEW$'; + const value = 'value3'; + + variablesPage.getters.createVariableButton().click(); + const editingRow = variablesPage.getters.variablesEditableRows().eq(0); + variablesPage.actions.setRowValue(editingRow, 'key', key); + variablesPage.actions.setRowValue(editingRow, 'value', value); + editingRow.should('contain', 'This field may contain only letters'); + variablesPage.getters.editableRowSaveButton(editingRow).should('be.disabled'); + variablesPage.actions.cancelRowEditing(editingRow); + + variablesPage.getters.variablesRows().should('have.length', 3); + }); + + it('should edit a variable', () => { + const key = 'ENV_VAR_NEW'; + const newValue = 'value4'; + + variablesPage.actions.editRow(key); + const editingRow = variablesPage.getters.variablesEditableRows().eq(0); + variablesPage.actions.setRowValue(editingRow, 'value', newValue); + variablesPage.actions.saveRowEditing(editingRow); + + variablesPage.getters.variableRow(key).should('contain', newValue).should('be.visible'); + variablesPage.getters.variablesRows().should('have.length', 3); + }); + + it('should delete a variable', () => { + const key = 'TO_DELETE'; + const value = 'xxx'; + + variablesPage.actions.createVariable(key, value); + variablesPage.actions.deleteVariable(key); + }); + + it('should search for a variable', () => { + // One Result + variablesPage.getters.searchBar().type('NEW'); + variablesPage.getters.variablesRows().should('have.length', 1); + variablesPage.getters.variableRow('NEW').should('contain.text', 'ENV_VAR_NEW'); + + // Multiple Results + variablesPage.getters.searchBar().clear().type('ENV_VAR'); + variablesPage.getters.variablesRows().should('have.length', 2); + + // All Results + variablesPage.getters.searchBar().clear().type('ENV'); + variablesPage.getters.variablesRows().should('have.length', 3); + + // No Results + variablesPage.getters.searchBar().clear().type('Some non-existent variable'); + variablesPage.getters.variablesRows().should('not.exist'); + + cy.contains('No variables found').should('be.visible'); + }); + }); +}); diff --git a/cypress/e2e/24-ndv-paired-item.cy.ts b/cypress/e2e/24-ndv-paired-item.cy.ts new file mode 100644 index 0000000000000..05f5dd8581a71 --- /dev/null +++ b/cypress/e2e/24-ndv-paired-item.cy.ts @@ -0,0 +1,305 @@ +import { WorkflowPage, NDV } from '../pages'; +import { v4 as uuid } from 'uuid'; + +const workflowPage = new WorkflowPage(); +const ndv = new NDV(); + +describe('NDV', () => { + before(() => { + cy.resetAll(); + cy.skipSetup(); + + }); + beforeEach(() => { + workflowPage.actions.visit(); + workflowPage.actions.renameWorkflow(uuid()); + workflowPage.actions.saveWorkflowOnButtonClick(); + }); + + it('maps paired input and output items', () => { + cy.fixture('Test_workflow_5.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + workflowPage.actions.zoomToFit(); + + workflowPage.actions.executeWorkflow(); + + workflowPage.actions.openNode('Item Lists'); + + ndv.getters.inputPanel().contains('6 items').should('exist'); + ndv.getters.outputPanel().contains('6 items').should('exist'); + + ndv.actions.switchInputMode('Table'); + ndv.actions.switchOutputMode('Table'); + + // input to output + ndv.getters.inputTableRow(1) + .should('exist') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.inputTableRow(1) + .realHover(); + ndv.getters.outputTableRow(4) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.inputTableRow(2) + .realHover(); + ndv.getters.outputTableRow(2) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.inputTableRow(3) + .realHover(); + ndv.getters.outputTableRow(6) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + // output to input + ndv.getters.outputTableRow(1) + .realHover(); + ndv.getters.inputTableRow(4) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.outputTableRow(4) + .realHover(); + ndv.getters.inputTableRow(1) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.outputTableRow(2) + .realHover(); + ndv.getters.inputTableRow(2) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.outputTableRow(6) + .realHover(); + ndv.getters.inputTableRow(3) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.outputTableRow(1) + .realHover(); + ndv.getters.inputTableRow(4) + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + }); + + it('maps paired input and output items based on selected input node', () => { + cy.fixture('Test_workflow_5.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + workflowPage.actions.openNode('Set2'); + + ndv.getters.inputPanel().contains('6 items').should('exist'); + ndv.getters.outputRunSelector() + .should('exist') + .should('include.text', '2 of 2 (6 items)'); + + ndv.actions.switchInputMode('Table'); + ndv.actions.switchOutputMode('Table'); + + ndv.getters.backToCanvas().realHover(); // reset to default hover + ndv.getters.outputHoveringItem().should('not.exist'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1111'); + + ndv.actions.selectInputNode('Set1'); + ndv.getters.backToCanvas().realHover(); // reset to default hover + + ndv.getters.inputTableRow(1) + .should('have.text', '1000') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + ndv.getters.inputTableRow(1).realHover(); + cy.wait(50); + ndv.getters.outputHoveringItem().should('have.text', '1000'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1000'); + + ndv.actions.selectInputNode('Item Lists'); + ndv.actions.changeOutputRunSelector('1 of 2 (6 items)'); + ndv.getters.backToCanvas().realHover(); // reset to default hover + + ndv.getters.inputTableRow(1) + .should('have.text', '1111') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + ndv.getters.inputTableRow(1).realHover(); + cy.wait(50); + ndv.getters.outputHoveringItem().should('have.text', '1111'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1111'); + }); + + it('maps paired input and output items based on selected run', () => { + cy.fixture('Test_workflow_5.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + workflowPage.actions.openNode('Set3'); + + ndv.getters.inputRunSelector() + .should('exist') + .find('input') + .should('include.value', '2 of 2 (6 items)'); + ndv.getters.outputRunSelector() + .should('exist') + .find('input') + .should('include.value', '2 of 2 (6 items)'); + + ndv.actions.switchInputMode('Table'); + ndv.actions.switchOutputMode('Table'); + + ndv.actions.changeOutputRunSelector('1 of 2 (6 items)'); + ndv.getters.inputRunSelector().find('input') + .should('include.value', '1 of 2 (6 items)'); + ndv.getters.outputRunSelector().find('input') + .should('include.value', '1 of 2 (6 items)'); + + ndv.getters.inputTableRow(1) + .should('have.text', '1111') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + ndv.getters.outputTableRow(1) + .should('have.text', '1111') + .realHover(); + + ndv.getters.outputTableRow(3) + .should('have.text', '4444') + .realHover(); + ndv.getters.inputTableRow(3) + .should('have.text', '4444') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.actions.changeOutputRunSelector('2 of 2 (6 items)'); + cy.wait(50); + + ndv.getters.inputTableRow(1) + .should('have.text', '1000') + .realHover(); + ndv.getters.outputTableRow(1) + .should('have.text', '1000') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.outputTableRow(3) + .should('have.text', '2000') + .realHover(); + ndv.getters.inputTableRow(3) + .should('have.text', '2000') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + }); + + it('resolves expression with default item when input node is not parent, while still pairing items', () => { + cy.fixture('Test_workflow_5.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + workflowPage.actions.openNode('Set2'); + + ndv.getters.inputPanel().contains('6 items').should('exist'); + ndv.getters.outputRunSelector() + .should('exist') + .should('include.text', '2 of 2 (6 items)'); + + ndv.actions.switchInputMode('Table'); + ndv.actions.switchOutputMode('Table'); + + ndv.getters.backToCanvas().realHover(); // reset to default hover + ndv.getters.inputTableRow(1) + .should('have.text', '1111') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + ndv.getters.inputTableRow(1).realHover(); + ndv.getters.outputHoveringItem().should('not.exist'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1111'); + + ndv.actions.selectInputNode('Code1'); + ndv.getters.inputTableRow(1).realHover(); + ndv.getters.inputTableRow(1) + .should('have.text', '1000') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + ndv.getters.outputTableRow(1) + .should('have.text', '1000'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1000'); + + ndv.actions.selectInputNode('Code'); + + ndv.getters.inputTableRow(1).realHover(); + ndv.getters.inputTableRow(1) + .should('have.text', '6666') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + ndv.getters.outputHoveringItem().should('not.exist'); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1000'); + + ndv.actions.selectInputNode('When clicking'); + + ndv.getters.inputTableRow(1).realHover(); + ndv.getters.inputTableRow(1).should('have.text', "This is an item, but it's empty.").realHover(); + ndv.getters.outputHoveringItem().should('have.length', 6); + ndv.getters.parameterExpressionPreview('value').should('include.text', '1000'); + }); + + it('can pair items between input and output across branches and runs', () => { + cy.fixture('Test_workflow_5.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + workflowPage.actions.openNode('IF'); + + ndv.actions.switchInputMode('Table'); + ndv.actions.switchOutputMode('Table'); + + ndv.actions.switchOutputBranch('False Branch (2 items)'); + ndv.getters.outputTableRow(1) + .should('have.text', '8888') + .realHover(); + ndv.getters.inputTableRow(5) + .should('have.text', '8888') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.getters.outputTableRow(2) + .should('have.text', '9999') + .realHover(); + ndv.getters.inputTableRow(6) + .should('have.text', '9999') + .invoke('attr', 'data-test-id') + .should('equal', 'hovering-item'); + + ndv.actions.close(); + workflowPage.actions.openNode('Set5'); + ndv.getters.outputTableRow(1) + .should('have.text', '8888') + .realHover(); + ndv.getters.inputHoveringItem().should('not.exist'); + + ndv.getters.inputTableRow(1) + .should('have.text', '1111') + .realHover(); + ndv.getters.outputHoveringItem().should('not.exist'); + + ndv.actions.switchIntputBranch('False Branch'); + ndv.getters.inputTableRow(1) + .should('have.text', '8888') + .realHover(); + ndv.getters.outputHoveringItem().should('have.text', '8888'); + + ndv.actions.changeOutputRunSelector('1 of 2 (4 items)') + ndv.getters.outputTableRow(1) + .should('have.text', '1111') + .realHover(); + // todo there's a bug here need to fix ADO-534 + // ndv.getters.outputHoveringItem().should('not.exist'); + }); +}); diff --git a/cypress/e2e/25-stickies.cy.ts b/cypress/e2e/25-stickies.cy.ts new file mode 100644 index 0000000000000..0746fddc0326a --- /dev/null +++ b/cypress/e2e/25-stickies.cy.ts @@ -0,0 +1,262 @@ +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; + +const workflowPage = new WorkflowPageClass(); + +function checkStickiesStyle( top: number, left: number, height: number, width: number, zIndex?: number) { + workflowPage.getters.stickies().should(($el) => { + expect($el).to.have.css('top', `${top}px`); + expect($el).to.have.css('left', `${left}px`); + expect($el).to.have.css('height', `${height}px`); + expect($el).to.have.css('width', `${width}px`); + if (zIndex) { + expect($el).to.have.css('z-index', `${zIndex}`); + } + }); +} + +describe('Canvas Actions', () => { + beforeEach(() => { + cy.resetAll(); + cy.skipSetup(); + workflowPage.actions.visit(); + + cy.window().then( + (win) => { + // @ts-ignore + win.preventNodeViewBeforeUnload = true; + }, + ); + }); + + + it('adds sticky to canvas with default text and position', () => { + workflowPage.getters.addStickyButton().should('not.be.visible'); + + addDefaultSticky() + workflowPage.getters.stickies().eq(0) + .should('have.text', 'I’m a note\nDouble click to edit me. Guide\n') + .find('a').contains('Guide').should('have.attr', 'href'); + }); + + it('drags sticky around to top left corner', () => { + // used to caliberate move sticky function + addDefaultSticky(); + moveSticky({ top: 0, left: 0 }); + }); + + it('drags sticky around and position/size are saved correctly', () => { + addDefaultSticky(); + moveSticky({ top: 500, left: 500 }); + + workflowPage.actions.saveWorkflowOnButtonClick(); + cy.wait('@createWorkflow'); + + cy.reload(); + cy.waitForLoad(); + + stickyShouldBePositionedCorrectly({ top: 500, left: 500 }); + }); + + it('deletes sticky', () => { + workflowPage.actions.addSticky(); + workflowPage.getters.stickies().should('have.length', 1) + + workflowPage.actions.deleteSticky(); + + workflowPage.getters.stickies().should('have.length', 0) + }); + + it('edits sticky and updates content as markdown', () => { + workflowPage.actions.addSticky(); + + workflowPage.getters.stickies() + .should('have.text', 'I’m a note\nDouble click to edit me. Guide\n') + + workflowPage.getters.stickies().dblclick(); + workflowPage.actions.editSticky('# hello world \n ## text text'); + workflowPage.getters.stickies().find('h1').should('have.text', 'hello world'); + workflowPage.getters.stickies().find('h2').should('have.text', 'text text'); + }); + + it('expands/shrinks sticky from the right edge', () => { + addDefaultSticky(); + + moveSticky({ top: 200, left: 200 }); + + dragRightEdge({ left: 200, top: 200, height: 160, width: 240 }, 100); + dragRightEdge({ left: 200, top: 200, height: 160, width: 240 }, -50); + }); + + it('expands/shrinks sticky from the left edge', () => { + addDefaultSticky(); + + moveSticky({ left: 600, top: 200 }); + cy.drag('[data-test-id="sticky"] [data-dir="left"]', [100, 100]); + checkStickiesStyle(140, 510, 160, 150); + + cy.drag('[data-test-id="sticky"] [data-dir="left"]', [-50, -50]); + checkStickiesStyle(140, 466, 160, 194); + }); + + it('expands/shrinks sticky from the top edge', () => { + workflowPage.actions.addSticky(); + cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button + checkStickiesStyle(360, 620, 160, 240); + + cy.drag('[data-test-id="sticky"] [data-dir="top"]', [100, 100]); + checkStickiesStyle(440, 620, 80, 240); + + cy.drag('[data-test-id="sticky"] [data-dir="top"]', [-50, -50]); + checkStickiesStyle(384, 620, 136, 240); + }); + + it('expands/shrinks sticky from the bottom edge', () => { + workflowPage.actions.addSticky(); + cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button + checkStickiesStyle(360, 620, 160, 240); + + cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [100, 100]); + checkStickiesStyle(360, 620, 254, 240); + + cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [-50, -50]); + checkStickiesStyle(360, 620, 198, 240); + }); + + it('expands/shrinks sticky from the bottom right edge', () => { + workflowPage.actions.addSticky(); + cy.drag('[data-test-id="sticky"]', [-100, -100]); // move away from canvas button + checkStickiesStyle(160, 420, 160, 240); + + cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [100, 100]); + checkStickiesStyle(160, 420, 254, 346); + + cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [-50, -50]); + checkStickiesStyle(160, 420, 198, 302); + }); + + it('expands/shrinks sticky from the top right edge', () => { + addDefaultSticky(); + + cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [100, 100]); + checkStickiesStyle(420, 400, 80, 346); + + cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [-50, -50]); + checkStickiesStyle(364, 400, 136, 302); + }); + + it('expands/shrinks sticky from the top left edge, and reach min height/width', () => { + addDefaultSticky(); + + cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [100, 100]); + checkStickiesStyle(420, 490, 80, 150); + + cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]); + checkStickiesStyle(264, 346, 236, 294); + }); + + it('sets sticky behind node', () => { + workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); + addDefaultSticky(); + + cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]); + checkStickiesStyle(184, 256, 316, 384, -121); + + workflowPage.getters.canvasNodes().eq(0) + .should(($el) => { + expect($el).to.have.css('z-index', 'auto'); + }); + + workflowPage.actions.addSticky(); + workflowPage.getters.stickies().eq(0) + .should(($el) => { + expect($el).to.have.css('z-index', '-121'); + }); + workflowPage.getters.stickies().eq(1) + .should(($el) => { + expect($el).to.have.css('z-index', '-38'); + }); + + cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-200, -200], { index: 1 }); + workflowPage.getters.stickies().eq(0) + .should(($el) => { + expect($el).to.have.css('z-index', '-121'); + }); + + workflowPage.getters.stickies().eq(1) + .should(($el) => { + expect($el).to.have.css('z-index', '-158'); + }); + + }); +}); + +type Position = { + top: number; + left: number; +}; + +type BoundingBox = { + height: number; + width: number; + top: number; + left: number; +} + +function dragRightEdge(curr: BoundingBox, move: number) { + workflowPage.getters.stickies().first().then(($el) => { + const { left, top, height, width } = curr; + cy.drag(`[data-test-id="sticky"] [data-dir="right"]`, [left + width + move, 0], { abs: true }); + stickyShouldBePositionedCorrectly({ top, left }); + stickyShouldHaveCorrectSize([height, width * 1.5 + move]); + }); +} + +function shouldHaveOneSticky() { + workflowPage.getters.stickies().should('have.length', 1); +} + +function shouldBeInDefaultLocation() { + workflowPage.getters.stickies().eq(0).should(($el) => { + expect($el).to.have.css('height', '160px'); + expect($el).to.have.css('width', '240px'); + }) +} + +function shouldHaveDefaultSize() { + workflowPage.getters.stickies().should(($el) => { + expect($el).to.have.css('height', '160px'); + expect($el).to.have.css('width', '240px'); + }) +} + +function addDefaultSticky() { + workflowPage.actions.addSticky(); + shouldHaveOneSticky(); + shouldHaveDefaultSize(); + shouldBeInDefaultLocation(); +} + +function stickyShouldBePositionedCorrectly(position: Position) { + const yOffset = -60; + const xOffset = -180; + workflowPage.getters.stickies() + .should(($el) => { + expect($el).to.have.css('top', `${yOffset + position.top}px`); + expect($el).to.have.css('left', `${xOffset + position.left}px`); + }); +} + +function stickyShouldHaveCorrectSize(size: [number, number]) { + const yOffset = 0; + const xOffset = 0; + workflowPage.getters.stickies() + .should(($el) => { + expect($el).to.have.css('height', `${yOffset + size[0]}px`); + expect($el).to.have.css('width', `${xOffset + size[1]}px`); + }); +} + +function moveSticky(target: Position) { + cy.drag('[data-test-id="sticky"]', [target.left, target.top], { abs: true }); + stickyShouldBePositionedCorrectly(target); +} diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 0057afd93c839..0ad0306cb6b93 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -58,8 +58,8 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.getCreatorItem('On app event').click(); nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image'); - nodeCreatorFeature.getters.getCreatorItem('Results in other categories (1)').should('exist'); - nodeCreatorFeature.getters.creatorItem().should('have.length', 2); + nodeCreatorFeature.getters.getCategoryItem('Results in other categories').should('exist'); + nodeCreatorFeature.getters.creatorItem().should('have.length', 1); nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image123123'); nodeCreatorFeature.getters.creatorItem().should('have.length', 0); @@ -101,7 +101,7 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'FTP'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('file'); // Navigate to rename action which should be the 4th item - nodeCreatorFeature.getters.searchBar().find('input').type('{downarrow} {downarrow} {downarrow} {rightarrow}'); + nodeCreatorFeature.getters.searchBar().find('input').type('{uparrow}{uparrow}{rightarrow}'); NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename'); }) @@ -127,9 +127,107 @@ describe('Node Creator', () => { }) nodeCreatorFeature.getters.searchBar().find('input').clear().type(doubleActionNode); nodeCreatorFeature.getters.getCreatorItem(doubleActionNode).click(); - nodeCreatorFeature.getters.creatorItem().should('have.length', 2); + nodeCreatorFeature.getters.creatorItem().should('have.length', 4); }) + it('should have "Actions" section collapsed when opening actions view from Trigger root view', () => { + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('ActiveCampaign'); + nodeCreatorFeature.getters.getCreatorItem('ActiveCampaign').click(); + nodeCreatorFeature.getters.getCategoryItem('Actions').should('exist'); + nodeCreatorFeature.getters.getCategoryItem('Triggers').should('exist'); + + nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('not.have.attr', 'data-category-collapsed'); + nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('have.attr', 'data-category-collapsed', 'true'); + nodeCreatorFeature.getters.getCategoryItem('Actions').click() + nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('not.have.attr', 'data-category-collapsed'); + }); + + it('should have "Triggers" section collapsed when opening actions view from Regular root view', () => { + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.getCreatorItem('Manually').click(); + + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); + nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + + nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('not.have.attr', 'data-category-collapsed'); + nodeCreatorFeature.getters.getCategoryItem('Actions').click() + nodeCreatorFeature.getters.getCategoryItem('Actions').parent().should('have.attr', 'data-category-collapsed'); + nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('have.attr', 'data-category-collapsed'); + nodeCreatorFeature.getters.getCategoryItem('Triggers').click() + nodeCreatorFeature.getters.getCategoryItem('Triggers').parent().should('not.have.attr', 'data-category-collapsed'); + }); + + it('should show callout and two suggested nodes if node has no trigger actions', () => { + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)'); + nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); + + cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible'); + nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible'); + nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible'); + }); + + it('should show intro callout if user has not made a production execution', () => { + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)'); + nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); + + cy.getByTestId('actions-panel-activation-callout').should('be.visible'); + nodeCreatorFeature.getters.activeSubcategory().find('button').click(); + nodeCreatorFeature.getters.searchBar().find('input').clear() + + nodeCreatorFeature.getters.getCreatorItem('On a schedule').click(); + + // Setup 1s interval execution + cy.getByTestId('parameter-input-field').click(); + cy.getByTestId('parameter-input-field') + .find('.el-select-dropdown') + .find('.option-headline') + .contains('Seconds') + .click(); + cy.getByTestId('parameter-input-secondsInterval').clear().type('1'); + + NDVModal.actions.close(); + + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)'); + nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); + nodeCreatorFeature.getters.getCreatorItem('Get All People').click(); + NDVModal.actions.close(); + + WorkflowPage.actions.saveWorkflowOnButtonClick(); + WorkflowPage.actions.activateWorkflow(); + WorkflowPage.getters.activatorSwitch().should('have.class', 'is-checked'); + + // Wait for schedule 1s execution to mark user as having made a production execution + cy.wait(1500); + cy.reload() + + // Action callout should not be visible after user has made a production execution + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)'); + nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); + + cy.getByTestId('actions-panel-activation-callout').should('not.exist'); + }); + + it('should show Trigger and Actions sections during search', () => { + nodeCreatorFeature.actions.openNodeCreator(); + + nodeCreatorFeature.getters.searchBar().find('input').clear().type('Customer Datastore (n8n training)'); + nodeCreatorFeature.getters.getCreatorItem('Customer Datastore (n8n training)').click(); + + nodeCreatorFeature.getters.searchBar().find('input').clear().type('Non existent action name'); + + nodeCreatorFeature.getters.getCategoryItem('Triggers').should('be.visible'); + nodeCreatorFeature.getters.getCategoryItem('Actions').should('be.visible'); + cy.getByTestId('actions-panel-no-triggers-callout').should('be.visible'); + nodeCreatorFeature.getters.getCreatorItem('On a Schedule').should('be.visible'); + nodeCreatorFeature.getters.getCreatorItem('On a Webhook call').should('be.visible'); + }); + describe('should correctly append manual trigger for regular actions', () => { // For these sources, manual node should be added const sourcesWithAppend = [ @@ -152,6 +250,7 @@ describe('Node Creator', () => { source.handler() nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + nodeCreatorFeature.getters.getCategoryItem('Actions').click(); nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); NDVModal.actions.close(); WorkflowPage.getters.canvasNodes().should('have.length', 2); @@ -162,12 +261,14 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.canvasAddButton().click(); nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + nodeCreatorFeature.getters.getCategoryItem('Actions').click(); nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); NDVModal.actions.close(); WorkflowPage.actions.deleteNode('When clicking "Execute Workflow"') WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click() nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + nodeCreatorFeature.getters.getCategoryItem('Actions').click(); nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); NDVModal.actions.close(); WorkflowPage.getters.canvasNodes().should('have.length', 2); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 2c5c770a726b2..749fb4434e506 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -15,6 +15,7 @@ describe('NDV', () => { workflowPage.actions.renameWorkflow(uuid()); workflowPage.actions.saveWorkflowOnButtonClick(); }); + it('should show up when double clicked on a node and close when Back to canvas clicked', () => { workflowPage.actions.addInitialNodeToCanvas('Manual'); workflowPage.getters.canvasNodes().first().dblclick(); @@ -90,15 +91,25 @@ describe('NDV', () => { }); }); + it('should save workflow using keyboard shortcut from NDV', () => { + workflowPage.actions.addNodeToCanvas('Manual'); + workflowPage.actions.addNodeToCanvas('Set', true, true); + ndv.getters.container().should('be.visible'); + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + workflowPage.getters.isWorkflowSaved(); + }) + describe('test output schema view', () => { const schemaKeys = ['id', 'name', 'email', 'notes', 'country', 'created', 'objectValue', 'prop1', 'prop2']; - beforeEach(() => { + function setupSchemaWorkflow() { cy.createFixtureWorkflow('Test_workflow_schema_test.json', `NDV test schema view ${uuid()}`); workflowPage.actions.zoomToFit(); workflowPage.actions.openNode('Set'); ndv.actions.execute(); - }); + } + it('should switch to output schema view and validate it', () => { + setupSchemaWorkflow() ndv.getters.outputDisplayMode().children().should('have.length', 3); ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table'); ndv.getters.outputDisplayMode().contains('Schema').click(); @@ -109,11 +120,13 @@ describe('NDV', () => { }); }); it('should preserve schema view after execution', () => { + setupSchemaWorkflow() ndv.getters.outputDisplayMode().contains('Schema').click(); ndv.actions.execute(); ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Schema'); }) it('should collapse and expand nested schema object', () => { + setupSchemaWorkflow() const expandedObjectProps = ['prop1', 'prop2'];; const getObjectValueItem = () => ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').filter(':contains("objectValue")'); ndv.getters.outputDisplayMode().contains('Schema').click(); @@ -126,5 +139,92 @@ describe('NDV', () => { ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('not.be.visible'); }); }) - }) + it('should not display pagination for schema', () => { + setupSchemaWorkflow() + ndv.getters.backToCanvas().click(); + workflowPage.getters.canvasNodeByName('Set').click(); + workflowPage.actions.addNodeToCanvas('Customer Datastore (n8n training)', true, true, 'Get All People'); + ndv.actions.execute(); + ndv.getters.outputPanel().contains('25 items').should('exist'); + ndv.getters.outputPanel().find('[class*=_pagination]').should('exist'); + ndv.getters.outputDisplayMode().contains('Schema').click(); + ndv.getters.outputPanel().find('[class*=_pagination]').should('not.exist'); + ndv.getters.outputDisplayMode().contains('JSON').click(); + ndv.getters.outputPanel().find('[class*=_pagination]').should('exist'); + }) + it('should display large schema', () => { + cy.createFixtureWorkflow('Test_workflow_schema_test_pinned_data.json', `NDV test schema view ${uuid()}`); + workflowPage.actions.zoomToFit(); + workflowPage.actions.openNode('Set'); + + ndv.getters.outputPanel().contains('20 items').should('exist'); + ndv.getters.outputPanel().find('[class*=_pagination]').should('exist'); + ndv.getters.outputDisplayMode().contains('Schema').click(); + ndv.getters.outputPanel().find('[class*=_pagination]').should('not.exist'); + ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item] [data-test-id=run-data-schema-item]').should('have.length', 20); + }) + }); + + it('can link and unlink run selectors between input and output', () => { + cy.createFixtureWorkflow('Test_workflow_5.json', 'Test'); + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + workflowPage.actions.openNode('Set3'); + + ndv.getters.inputRunSelector() + .should('exist') + .find('input') + .should('include.value', '2 of 2 (6 items)'); + ndv.getters.outputRunSelector() + .should('exist') + .find('input') + .should('include.value', '2 of 2 (6 items)'); + + ndv.actions.switchInputMode('Table'); + ndv.actions.switchOutputMode('Table'); + + ndv.actions.changeOutputRunSelector('1 of 2 (6 items)'); + ndv.getters.inputRunSelector() + .find('input') + .should('include.value', '1 of 2 (6 items)'); + ndv.getters.inputTbodyCell(1, 0).should('have.text', '1111'); + ndv.getters.outputTbodyCell(1, 0).should('have.text', '1111'); + + ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip + ndv.actions.changeInputRunSelector('2 of 2 (6 items)'); + ndv.getters.outputRunSelector() + .find('input') + .should('include.value', '2 of 2 (6 items)'); + + // unlink + ndv.actions.toggleOutputRunLinking(); + ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip + ndv.actions.changeOutputRunSelector('1 of 2 (6 items)'); + ndv.getters.inputRunSelector() + .should('exist') + .find('input') + .should('include.value', '2 of 2 (6 items)'); + + // link again + ndv.actions.toggleOutputRunLinking(); + ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip + ndv.getters.inputRunSelector() + .find('input') + .should('include.value', '1 of 2 (6 items)'); + + // unlink again + ndv.actions.toggleInputRunLinking(); + ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip + ndv.actions.changeInputRunSelector('2 of 2 (6 items)'); + ndv.getters.outputRunSelector() + .find('input') + .should('include.value', '1 of 2 (6 items)'); + + // link again + ndv.actions.toggleInputRunLinking(); + ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip + ndv.getters.outputRunSelector() + .find('input') + .should('include.value', '2 of 2 (6 items)'); + }); }); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index cfce3cc9b0c35..baa38e9f7a205 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -190,37 +190,48 @@ describe('Workflow Actions', () => { cy.url().should('include', '/workflow/new'); }); - it('should duplicate workflow', () => { - // Stub window.open so new tab is not getting opened - cy.window().then((win) => { - cy.stub(win, 'open').as('open'); + describe('duplicate workflow', () => { + function duplicateWorkflow() { + WorkflowPage.getters.workflowMenu().should('be.visible'); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemDuplicate().click(); + WorkflowPage.getters.duplicateWorkflowModal().should('be.visible'); + WorkflowPage.getters.duplicateWorkflowModal().find('input').first().should('be.visible'); + WorkflowPage.getters.duplicateWorkflowModal().find('input').first().type('{selectall}'); + WorkflowPage.getters + .duplicateWorkflowModal() + .find('input') + .first() + .type(DUPLICATE_WORKFLOW_NAME); + WorkflowPage.getters + .duplicateWorkflowModal() + .find('.el-select__tags input') + .type(DUPLICATE_WORKFLOW_TAG); + WorkflowPage.getters.duplicateWorkflowModal().find('.el-select__tags input').type('{enter}'); + WorkflowPage.getters.duplicateWorkflowModal().find('.el-select__tags input').type('{enter}'); + WorkflowPage.getters + .duplicateWorkflowModal() + .find('button') + .contains('Duplicate') + .should('be.visible'); + WorkflowPage.getters.duplicateWorkflowModal().find('button').contains('Duplicate').click(); + WorkflowPage.getters.errorToast().should('not.exist'); + } + + beforeEach(() => { + // Stub window.open so new tab is not getting opened + cy.window().then((win) => { + cy.stub(win, 'open').as('open'); + }); + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); }); - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.saveWorkflowOnButtonClick(); - WorkflowPage.getters.workflowMenu().should('be.visible'); - WorkflowPage.getters.workflowMenu().click(); - WorkflowPage.getters.workflowMenuItemDuplicate().click(); - WorkflowPage.getters.duplicateWorkflowModal().should('be.visible'); - WorkflowPage.getters.duplicateWorkflowModal().find('input').first().should('be.visible'); - WorkflowPage.getters.duplicateWorkflowModal().find('input').first().type('{selectall}'); - WorkflowPage.getters - .duplicateWorkflowModal() - .find('input') - .first() - .type(DUPLICATE_WORKFLOW_NAME); - WorkflowPage.getters - .duplicateWorkflowModal() - .find('.el-select__tags input') - .type(DUPLICATE_WORKFLOW_TAG); - WorkflowPage.getters.duplicateWorkflowModal().find('.el-select__tags input').type('{enter}'); - WorkflowPage.getters.duplicateWorkflowModal().find('.el-select__tags input').type('{enter}'); - WorkflowPage.getters - .duplicateWorkflowModal() - .find('button') - .contains('Duplicate') - .should('be.visible'); - WorkflowPage.getters.duplicateWorkflowModal().find('button').contains('Duplicate').click(); - WorkflowPage.getters.errorToast().should('not.exist'); + it('should duplicate unsaved workflow', () => { + duplicateWorkflow(); + }); + it('should duplicate saved workflow', () => { + WorkflowPage.actions.saveWorkflowOnButtonClick(); + duplicateWorkflow(); + }); }); }); diff --git a/cypress/e2e/9-expression-editor-modal.cy.ts b/cypress/e2e/9-expression-editor-modal.cy.ts index 957c0505f5505..dd4e01128b134 100644 --- a/cypress/e2e/9-expression-editor-modal.cy.ts +++ b/cypress/e2e/9-expression-editor-modal.cy.ts @@ -60,6 +60,6 @@ describe('Expression editor modal', () => { it('should resolve $parameter[]', () => { WorkflowPage.getters.expressionModalInput().clear(); WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]'); - WorkflowPage.getters.expressionModalOutput().contains(/^getAll$/); + WorkflowPage.getters.expressionModalOutput().contains(/^get$/); }); }); diff --git a/cypress/fixtures/Settings_user_activation_modal_enabled.json b/cypress/fixtures/Settings_user_activation_modal_enabled.json new file mode 100644 index 0000000000000..8ce6a5fca67e2 --- /dev/null +++ b/cypress/fixtures/Settings_user_activation_modal_enabled.json @@ -0,0 +1,90 @@ +{ + "data": { + "endpointWebhook": "webhook", + "endpointWebhookTest": "webhook-test", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": false, + "executionTimeout": -1, + "maxExecutionTimeout": 3600, + "workflowCallerPolicyDefaultOption": "workflowsFromSameOwner", + "timezone": "America/New_York", + "urlBaseWebhook": "http://localhost:5678/", + "urlBaseEditor": "http://localhost:5678", + "versionCli": "0.221.2", + "oauthCallbackUrls": { + "oauth1": "http://localhost:5678/rest/oauth1-credential/callback", + "oauth2": "http://localhost:5678/rest/oauth2-credential/callback" + }, + "versionNotifications": { + "enabled": true, + "endpoint": "https://api.n8n.io/api/versions/", + "infoUrl": "https://docs.n8n.io/release-notes/" + }, + "instanceId": "c229842c6d1e217486d04caf7223758e08385156ca87a58286c850760c7161f4", + "telemetry": { + "enabled": true + }, + "posthog": { + "enabled": false, + "apiHost": "https://ph.n8n.io", + "apiKey": "phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo", + "autocapture": false, + "disableSessionRecording": true, + "debug": false + }, + "personalizationSurveyEnabled": false, + "userActivationSurveyEnabled": true, + "defaultLocale": "en", + "userManagement": { + "enabled": true, + "showSetupOnFirstLoad": false, + "smtpSetup": false + }, + "sso": { + "saml": { + "loginEnabled": false, + "loginLabel": "" + }, + "ldap": { + "loginEnabled": false, + "loginLabel": "" + } + }, + "publicApi": { + "enabled": false, + "latestVersion": 1, + "path": "api", + "swaggerUi": { + "enabled": true + } + }, + "workflowTagsDisabled": false, + "logLevel": "info", + "hiringBannerEnabled": true, + "templates": { + "enabled": true, + "host": "https://api.n8n.io/api/" + }, + "onboardingCallPromptEnabled": true, + "executionMode": "regular", + "pushBackend": "sse", + "communityNodesEnabled": true, + "deployment": { + "type": "default" + }, + "isNpmAvailable": false, + "allowedModules": {}, + "enterprise": { + "sharing": true, + "ldap": true, + "saml": false, + "logStreaming": false, + "advancedExecutionFilters": false + }, + "hideUsagePage": false, + "license": { + "environment": "production" + } + } +} diff --git a/cypress/fixtures/Test_workflow_5.json b/cypress/fixtures/Test_workflow_5.json new file mode 100644 index 0000000000000..6b87fc33b70a1 --- /dev/null +++ b/cypress/fixtures/Test_workflow_5.json @@ -0,0 +1,292 @@ +{ + "meta": { + "instanceId": "8147b3a74cd161276e0f3bfc17369a724afab0d377593fada8be82d34c0c6a95" + }, + "nodes": [ + { + "parameters": { + "jsCode": "return [\n {\n id: 6666\n },\n {\n id: 3333\n },\n {\n id: 9999\n },\n {\n id: 1111\n },\n {\n id: 4444\n },\n {\n id: 8888\n },\n]" + }, + "id": "5f023c7c-67ca-47a0-8a90-8227fcf29b9c", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + -520, + 580 + ] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "id", + "value": "={{ $json.id }}" + } + ] + }, + "options": {} + }, + "id": "bd454282-9dd7-465f-9b9a-654a0c8532ec", + "name": "Set2", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [ + -40, + 780 + ] + }, + { + "parameters": {}, + "id": "ef63cdc5-50bc-4525-9873-7e7f7589a60e", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -740, + 580 + ] + }, + { + "parameters": { + "operation": "sort", + "sortFieldsUi": { + "sortField": [ + { + "fieldName": "id" + } + ] + }, + "options": {} + }, + "id": "555a150c-d735-4331-b628-c1f1cfed2da1", + "name": "Item Lists", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 2, + "position": [ + -280, + 580 + ] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "id", + "value": "={{ $json.id }}" + } + ] + }, + "options": {} + }, + "id": "02372cb6-aac8-45c3-8600-f699901289ac", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [ + -60, + 580 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "00d73944-218c-4896-af68-3f2855a922d1", + "name": "Set1", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [ + -280, + 780 + ] + }, + { + "parameters": { + "conditions": { + "number": [ + { + "value1": "={{ $json.id }}", + "operation": "smallerEqual", + "value2": 6666 + } + ] + } + }, + "id": "211a7bef-32d1-4928-9cef-3a45f2e61379", + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 160, + 580 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "dcbd4745-832f-43d8-8a3c-dd80e8ca2777", + "name": "Set3", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [ + 140, + 780 + ] + }, + { + "parameters": { + "jsCode": "return [\n {\n id: 1000\n },\n {\n id: 300\n },\n {\n id: 2000\n },\n {\n id: 100\n },\n {\n id: 400\n },\n {\n id: 1300\n },\n]" + }, + "id": "ec9c8f16-f3c8-4054-a6e9-4f1ebcdebb71", + "name": "Code1", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [ + -520, + 780 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "42e89478-a53a-4d10-b20c-1dc5d5f953d5", + "name": "Set4", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [ + 460, + 460 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "5085eb1c-0345-4b9d-856a-2955279f2c5d", + "name": "Set5", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [ + 460, + 660 + ] + } + ], + "connections": { + "Code": { + "main": [ + [ + { + "node": "Item Lists", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set2": { + "main": [ + [ + { + "node": "Set3", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + }, + { + "node": "Code1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + }, + { + "node": "Set2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set1": { + "main": [ + [ + { + "node": "Set2", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "Set4", + "type": "main", + "index": 0 + }, + { + "node": "Set5", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Set5", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code1": { + "main": [ + [ + { + "node": "Set1", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/cypress/fixtures/Test_workflow_schema_test_pinned_data.json b/cypress/fixtures/Test_workflow_schema_test_pinned_data.json new file mode 100644 index 0000000000000..5233a17848a26 --- /dev/null +++ b/cypress/fixtures/Test_workflow_schema_test_pinned_data.json @@ -0,0 +1,574 @@ +{ + "name": "My workflow", + "nodes": [ + { + "parameters": { + "operation": "getAllPeople", + "limit": 10 + }, + "id": "441afcbf-a678-4463-bc89-7e0b6693af5c", + "name": "Customer Datastore (n8n training)", + "type": "n8n-nodes-base.n8nTrainingCustomerDatastore", + "typeVersion": 1, + "position": [ + 720, + 440 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "objectValue.prop1", + "value": 123 + } + ], + "string": [ + { + "name": "objectValue.prop2", + "value": "someText" + } + ] + }, + "options": { + "dotNotation": true + } + }, + "id": "44094a05-b3b7-49bf-bfbf-a711e6ba45d8", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 1080, + 440 + ] + }, + { + "parameters": {}, + "id": "3dc7cf26-ff25-4437-b9fd-0e8b127ebec9", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 500, + 440 + ] + } + ], + "pinData": { + "Set": [ + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + }, + { + "json": { + "key0": 0, + "key1": 1, + "key2": 2, + "key3": 3, + "key4": 4, + "key5": 5, + "key6": 6, + "key7": 7, + "key8": 8, + "key9": 9, + "key10": 10, + "key11": 11, + "key12": 12, + "key13": 13, + "key14": 14, + "key15": 15, + "key16": 16, + "key17": 17, + "key18": 18, + "key19": 19 + } + } + ] + }, + "connections": { + "Customer Datastore (n8n training)": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Customer Datastore (n8n training)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "", + "meta": { + "instanceId": "363581be2c2581d1b11e189456a090887e137f8393a4b5cb85641b1ee4fae479" + }, + "tags": [] +} diff --git a/cypress/pages/features/node-creator.ts b/cypress/pages/features/node-creator.ts index 8ebe6db702d6e..6686de25ff1fe 100644 --- a/cypress/pages/features/node-creator.ts +++ b/cypress/pages/features/node-creator.ts @@ -7,6 +7,7 @@ export class NodeCreator extends BasePage { plusButton: () => cy.getByTestId('node-creator-plus-button'), canvasAddButton: () => cy.getByTestId('canvas-add-button'), searchBar: () => cy.getByTestId('search-bar'), + getCategoryItem: (label: string) => cy.get(`[data-keyboard-nav-id="${label}"]`), getCreatorItem: (label: string) => this.getters.creatorItem().contains(label).parents('[data-test-id="item-iterator-item"]'), getNthCreatorItem: (n: number) => this.getters.creatorItem().eq(n), @@ -15,10 +16,11 @@ export class NodeCreator extends BasePage { selectedTab: () => this.getters.nodeCreatorTabs().find('.is-active'), categorizedItems: () => cy.getByTestId('categorized-items'), creatorItem: () => cy.getByTestId('item-iterator-item'), + categoryItem: () => cy.getByTestId('node-creator-category-item'), communityNodeTooltip: () => cy.getByTestId('node-item-community-tooltip'), - noResults: () => cy.getByTestId('categorized-no-results'), + noResults: () => cy.getByTestId('node-creator-no-results'), nodeItemName: () => cy.getByTestId('node-creator-item-name'), - activeSubcategory: () => cy.getByTestId('categorized-items-subcategory'), + activeSubcategory: () => cy.getByTestId('nodes-list-header'), expandedCategories: () => this.getters.creatorItem().find('>div').filter('.active').invoke('text'), }; diff --git a/cypress/pages/modals/index.ts b/cypress/pages/modals/index.ts index 3d1981d027ad2..358ba0cf7800d 100644 --- a/cypress/pages/modals/index.ts +++ b/cypress/pages/modals/index.ts @@ -1,3 +1,5 @@ export * from './credentials-modal'; export * from './message-box'; export * from './workflow-sharing-modal'; +export * from './user-activation-survey-modal'; + diff --git a/cypress/pages/modals/user-activation-survey-modal.ts b/cypress/pages/modals/user-activation-survey-modal.ts new file mode 100644 index 0000000000000..d47f987887aeb --- /dev/null +++ b/cypress/pages/modals/user-activation-survey-modal.ts @@ -0,0 +1,9 @@ +import { BasePage } from './../base'; + +export class UserActivationSurveyModal extends BasePage { + getters = { + modalContainer: () => cy.getByTestId('userActivationSurvey-modal').last(), + feedbackInput: () => cy.getByTestId('activation-feedback-input').find('textarea'), + sendFeedbackButton: () => cy.getByTestId('send-activation-feedback-button'), + }; +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 5a45f2df2d2ee..cf787a4a3f095 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -18,9 +18,9 @@ export class NDV extends BasePage { outputDisplayMode: () => this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(), pinDataButton: () => cy.getByTestId('ndv-pin-data'), editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'), - pinnedDataEditor: () => this.getters.outputPanel().find('.monaco-editor[role=code]'), + pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller'), runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'), - savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').contains('Save'), + savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'), outputTableRows: () => this.getters.outputDataContainer().find('table tr'), outputTableHeaders: () => this.getters.outputDataContainer().find('table thead th'), outputTableRow: (row: number) => this.getters.outputTableRows().eq(row), @@ -45,6 +45,12 @@ export class NDV extends BasePage { executePrevious: () => cy.getByTestId('execute-previous-node'), httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'), nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n), + inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'), + outputRunSelector: () => this.getters.outputPanel().findChildByTestId('run-selector'), + outputHoveringItem: () => this.getters.outputPanel().findChildByTestId('hovering-item'), + inputHoveringItem: () => this.getters.inputPanel().findChildByTestId('hovering-item'), + outputBranches: () => this.getters.outputPanel().findChildByTestId('branches'), + inputBranches: () => this.getters.inputPanel().findChildByTestId('branches'), }; actions = { @@ -71,8 +77,7 @@ export class NDV extends BasePage { this.getters.editPinnedDataButton().click(); this.getters.pinnedDataEditor().click(); - this.getters.pinnedDataEditor().type(`{selectall}{backspace}`); - this.getters.pinnedDataEditor().type(JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}')); + this.getters.pinnedDataEditor().type(`{selectall}{backspace}${JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}')}`); this.actions.savePinnedData(); }, @@ -119,5 +124,29 @@ export class NDV extends BasePage { this.actions.editPinnedData(); this.actions.savePinnedData(); }, + changeInputRunSelector: (runName: string) => { + this.getters.inputRunSelector().click(); + cy.get('.el-select-dropdown:visible .el-select-dropdown__item') + .contains(runName) + .click(); + }, + changeOutputRunSelector: (runName: string) => { + this.getters.outputRunSelector().click(); + cy.get('.el-select-dropdown:visible .el-select-dropdown__item') + .contains(runName) + .click(); + }, + toggleOutputRunLinking: () => { + this.getters.outputRunSelector().find('button').click(); + }, + toggleInputRunLinking: () => { + this.getters.inputRunSelector().find('button').click(); + }, + switchOutputBranch: (name: string) => { + this.getters.outputBranches().get('span').contains(name).click(); + }, + switchIntputBranch: (name: string) => { + this.getters.inputBranches().get('span').contains(name).click(); + }, }; } diff --git a/cypress/pages/variables.ts b/cypress/pages/variables.ts new file mode 100644 index 0000000000000..721d874351cfe --- /dev/null +++ b/cypress/pages/variables.ts @@ -0,0 +1,71 @@ +import { BasePage } from './base'; +import Chainable = Cypress.Chainable; + +export class VariablesPage extends BasePage { + url = '/variables'; + getters = { + unavailableResourcesList: () => cy.getByTestId('unavailable-resources-list'), + emptyResourcesList: () => cy.getByTestId('empty-resources-list'), + resourcesList: () => cy.getByTestId('resources-list'), + goToUpgrade: () => cy.getByTestId('go-to-upgrade'), + actionBox: () => cy.getByTestId('action-box'), + emptyResourcesListNewVariableButton: () => this.getters.emptyResourcesList().find('button'), + searchBar: () => cy.getByTestId('resources-list-search').find('input'), + createVariableButton: () => cy.getByTestId('resources-list-add'), + variablesRows: () => cy.getByTestId('variables-row'), + variablesEditableRows: () => + cy.getByTestId('variables-row').filter((index, row) => !!row.querySelector('input')), + variableRow: (key: string) => + this.getters.variablesRows().contains(key).parents('[data-test-id="variables-row"]'), + editableRowCancelButton: (row: Chainable>) => + row.getByTestId('variable-row-cancel-button'), + editableRowSaveButton: (row: Chainable>) => + row.getByTestId('variable-row-save-button'), + }; + + actions = { + createVariable: (key: string, value: string) => { + this.getters.createVariableButton().click(); + + const editingRow = this.getters.variablesEditableRows().eq(0); + this.actions.setRowValue(editingRow, 'key', key); + this.actions.setRowValue(editingRow, 'value', value); + this.actions.saveRowEditing(editingRow); + }, + deleteVariable: (key: string) => { + const row = this.getters.variableRow(key); + row.within(() => { + cy.getByTestId('variable-row-delete-button').click(); + }); + + const modal = cy.get('[role="dialog"]'); + modal.should('be.visible'); + modal.get('.btn--confirm').click(); + }, + createVariableFromEmptyState: (key: string, value: string) => { + this.getters.emptyResourcesListNewVariableButton().click(); + + const editingRow = this.getters.variablesEditableRows().eq(0); + this.actions.setRowValue(editingRow, 'key', key); + this.actions.setRowValue(editingRow, 'value', value); + this.actions.saveRowEditing(editingRow); + }, + editRow: (key: string) => { + const row = this.getters.variableRow(key); + row.within(() => { + cy.getByTestId('variable-row-edit-button').click(); + }); + }, + setRowValue: (row: Chainable>, field: 'key' | 'value', value: string) => { + row.within(() => { + cy.getByTestId(`variable-row-${field}-input`).type('{selectAll}{del}').type(value); + }); + }, + cancelRowEditing: (row: Chainable>) => { + this.getters.editableRowCancelButton(row).click(); + }, + saveRowEditing: (row: Chainable>) => { + this.getters.editableRowSaveButton(row).click(); + }, + }; +} diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 4ad4b753a9161..8b6d195105248 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -106,6 +106,8 @@ export class WorkflowPage extends BasePage { cy.get( `.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`, ), + addStickyButton: () => cy.getByTestId('add-sticky-button'), + stickies: () => cy.getByTestId('sticky'), editorTabButton: () => cy.getByTestId('radio-button-workflow'), }; actions = { @@ -138,7 +140,8 @@ export class WorkflowPage extends BasePage { if(action) { cy.contains(action).click() } else { - cy.getByTestId('item-iterator-item').eq(1).click() + // Select the first action + cy.get('[data-keyboard-nav-type="action"]').eq(0).click() } } }) @@ -167,11 +170,13 @@ export class WorkflowPage extends BasePage { this.getters.shareButton().click(); }, saveWorkflowOnButtonClick: () => { + cy.intercept('POST', '/rest/workflows').as('createWorkflow'); this.getters.saveButton().should('contain', 'Save'); this.getters.saveButton().click(); this.getters.saveButton().should('contain', 'Saved'); }, saveWorkflowUsingKeyboardShortcut: () => { + cy.intercept('POST', '/rest/workflows').as('createWorkflow'); cy.get('body').type('{meta}', { release: false }).type('s'); }, deleteNode: (name: string) => { @@ -257,6 +262,24 @@ export class WorkflowPage extends BasePage { .first() .click({ force: true }); }, + addSticky: () => { + this.getters.nodeCreatorPlusButton().realHover(); + this.getters.addStickyButton().click(); + }, + deleteSticky: () => { + this.getters.stickies().eq(0) + .realHover() + .find('[data-test-id="delete-sticky"]') + .click(); + }, + editSticky: (content: string) => { + this.getters.stickies() + .dblclick() + .find('textarea') + .clear() + .type(content) + .type('{esc}'); + }, turnOnManualExecutionSaving: () => { this.getters.workflowMenu().click(); this.getters.workflowMenuItemSettings().click(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index e096dc0aaa7cf..132745510cbfa 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -232,18 +232,19 @@ Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => }); }); -Cypress.Commands.add('drag', (selector, pos) => { +Cypress.Commands.add('drag', (selector, pos, options) => { + const index = options?.index || 0; const [xDiff, yDiff] = pos; - const element = cy.get(selector); + const element = cy.get(selector).eq(index); element.should('exist'); - const originalLocation = Cypress.$(selector)[0].getBoundingClientRect(); + const originalLocation = Cypress.$(selector)[index].getBoundingClientRect(); element.trigger('mousedown'); element.trigger('mousemove', { which: 1, - pageX: originalLocation.right + xDiff, - pageY: originalLocation.top + yDiff, + pageX: options?.abs? xDiff: originalLocation.right + xDiff, + pageY: options?.abs? yDiff: originalLocation.top + yDiff, force: true, }); element.trigger('mouseup', { force: true }); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index b42db7e7171a8..7665602dafcb4 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -47,7 +47,7 @@ declare global { grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; paste(pastePayload: string): void; - drag(selector: string, target: [number, number]): void; + drag(selector: string, target: [number, number], options?: {abs?: true, index?: number}): void; draganddrop(draggableSelector: string, droppableSelector: string): void; } } diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000000000..61b0e504a304f --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": false, + "sourceMap": false + } +} diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index f6cc050711f58..6d1cb30900eb0 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -3,14 +3,13 @@ ARG NODE_VERSION=16 # 1. Create an image to build n8n FROM n8nio/base:${NODE_VERSION} as builder -COPY turbo.json package.json .npmrc pnpm-lock.yaml pnpm-workspace.yaml jest.config.js tsconfig.json ./ -COPY scripts ./scripts -COPY packages ./packages -COPY patches ./patches +COPY --chown=node:node turbo.json package.json .npmrc pnpm-lock.yaml pnpm-workspace.yaml jest.config.js tsconfig.json ./ +COPY --chown=node:node scripts ./scripts +COPY --chown=node:node packages ./packages +COPY --chown=node:node patches ./patches RUN apk add --update libc6-compat jq RUN corepack enable && corepack prepare --activate -RUN chown -R node:node . USER node RUN pnpm install --frozen-lockfile diff --git a/package.json b/package.json index 4a3c4ada51a8f..8a48933648286 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "n8n", - "version": "0.221.2", + "version": "0.225.0", "private": true, "homepage": "https://n8n.io", "engines": { "node": ">=16.9", - "pnpm": ">=7.18" + "pnpm": ">=8.1" }, - "packageManager": "pnpm@7.27.0", + "packageManager": "pnpm@8.1.0", "scripts": { "preinstall": "node scripts/block-npm-install.js", "build": "turbo run build", @@ -47,7 +47,7 @@ "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "jest-mock": "^29.5.0", - "jest-mock-extended": "^3.0.3", + "jest-mock-extended": "^3.0.4", "nock": "^13.2.9", "node-fetch": "^2.6.7", "p-limit": "^3.1.0", @@ -56,9 +56,10 @@ "run-script-os": "^1.0.7", "start-server-and-test": "^1.14.0", "supertest": "^6.3.3", - "ts-jest": "^29.0.5", + "ts-jest": "^29.1.0", "tsc-watch": "^6.0.0", - "turbo": "1.7.4" + "turbo": "1.8.8", + "typescript": "*" }, "pnpm": { "onlyBuiltDependencies": [ @@ -75,14 +76,18 @@ "http-cache-semantics": "4.1.1", "jsonwebtoken": "9.0.0", "prettier": "^2.8.3", + "tslib": "^2.5.0", "ts-node": "^10.9.1", - "typescript": "^4.9.5", + "typescript": "^5.0.3", + "xml2js": "^0.5.0", "cpy@8>globby": "^11.1.0", "qqjs>globby": "^11.1.0" }, "patchedDependencies": { "element-ui@2.15.12": "patches/element-ui@2.15.12.patch", - "typedi@0.10.0": "patches/typedi@0.10.0.patch" + "typedi@0.10.0": "patches/typedi@0.10.0.patch", + "@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch", + "@typescript-eslint/eslint-plugin@5.59.0": "patches/@typescript-eslint__eslint-plugin@5.59.0.patch" } } } diff --git a/packages/@n8n_io/eslint-config/base.js b/packages/@n8n_io/eslint-config/base.js index cadc748b5cc78..6c89ea6e52298 100644 --- a/packages/@n8n_io/eslint-config/base.js +++ b/packages/@n8n_io/eslint-config/base.js @@ -35,6 +35,12 @@ const config = (module.exports = { * https://github.com/ivov/eslint-plugin-n8n-local-rules */ 'eslint-plugin-n8n-local-rules', + + /** https://github.com/sweepline/eslint-plugin-unused-imports */ + 'unused-imports', + + /** https://github.com/sindresorhus/eslint-plugin-unicorn */ + 'eslint-plugin-unicorn', ], extends: [ @@ -194,6 +200,11 @@ const config = (module.exports = { */ '@typescript-eslint/consistent-type-assertions': 'error', + /** + * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/consistent-type-imports.md + */ + '@typescript-eslint/consistent-type-imports': 'error', + /** * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/member-delimiter-style.md */ @@ -343,6 +354,8 @@ const config = (module.exports = { 'n8n-local-rules/no-interpolation-in-regular-string': 'error', + 'n8n-local-rules/no-unused-param-in-catch-clause': 'error', + // ****************************************************************** // overrides to base ruleset // ****************************************************************** @@ -395,6 +408,18 @@ const config = (module.exports = { }, ], + /** + * https://www.typescriptlang.org/docs/handbook/enums.html#const-enums + */ + 'no-restricted-syntax': [ + 'error', + { + selector: 'TSEnumDeclaration:not([const=true])', + message: + 'Do not declare raw enums as it leads to runtime overhead. Use const enum instead. See https://www.typescriptlang.org/docs/handbook/enums.html#const-enums', + }, + ], + // ---------------------------------- // import // ---------------------------------- @@ -403,6 +428,21 @@ const config = (module.exports = { * https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/prefer-default-export.md */ 'import/prefer-default-export': 'off', + + // ---------------------------------- + // no-unused-imports + // ---------------------------------- + + /** + * https://github.com/sweepline/eslint-plugin-unused-imports/blob/master/docs/rules/no-unused-imports.md + */ + 'unused-imports/no-unused-imports': process.env.NODE_ENV === 'development' ? 'warn' : 'error', + + /** https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/no-unnecessary-await.md */ + 'unicorn/no-unnecessary-await': 'error', + + /** https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/no-useless-promise-resolve-reject.md */ + 'unicorn/no-useless-promise-resolve-reject': 'error', }, overrides: [ diff --git a/packages/@n8n_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index 9b4677c1c0a33..3d085b7731426 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -140,6 +140,36 @@ module.exports = { }, }, + 'no-unused-param-in-catch-clause': { + meta: { + type: 'problem', + docs: { + description: 'Unused param in catch clause must be omitted.', + recommended: 'error', + }, + messages: { + removeUnusedParam: 'Remove unused param in catch clause', + }, + fixable: 'code', + }, + create(context) { + return { + CatchClause(node) { + if (node.param?.name?.startsWith('_')) { + const start = node.range[0] + 'catch '.length; + const end = node.param.range[1] + '()'.length; + + context.report({ + messageId: 'removeUnusedParam', + node, + fix: (fixer) => fixer.removeRange([start, end]), + }); + } + }, + }; + }, + }, + 'no-interpolation-in-regular-string': { meta: { type: 'problem', diff --git a/packages/@n8n_io/eslint-config/package.json b/packages/@n8n_io/eslint-config/package.json index 0e2c058c49393..34e0abc03ae35 100644 --- a/packages/@n8n_io/eslint-config/package.json +++ b/packages/@n8n_io/eslint-config/package.json @@ -3,18 +3,20 @@ "private": true, "version": "0.0.1", "devDependencies": { - "@types/eslint": "~8.4", - "@typescript-eslint/eslint-plugin": "~5.45", - "@typescript-eslint/parser": "~5.45", + "@types/eslint": "~8.37", + "@typescript-eslint/eslint-plugin": "~5.59", + "@typescript-eslint/parser": "~5.59", "@vue/eslint-config-typescript": "~8.0", - "eslint": "~8.28", + "eslint": "~8.39", "eslint-config-airbnb-typescript": "~17.0", - "eslint-config-prettier": "~8.5", + "eslint-config-prettier": "~8.8", "eslint-import-resolver-typescript": "~3.5", "eslint-plugin-diff": "~2.0", - "eslint-plugin-import": "~2.26", + "eslint-plugin-import": "~2.27", "eslint-plugin-n8n-local-rules": "~1.0", "eslint-plugin-prettier": "~4.2", + "eslint-plugin-unicorn": "~46.0", + "eslint-plugin-unused-imports": "~2.0", "eslint-plugin-vue": "~7.17" }, "scripts": { diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index 3da2d993a3ca1..2a24787f80e76 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -16,8 +16,6 @@ module.exports = { ], rules: { - '@typescript-eslint/consistent-type-imports': 'error', - // TODO: Remove this 'import/no-cycle': 'warn', 'import/order': 'off', diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 624ddacb42db0..f260be6fa2fb5 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,31 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 0.226.0 + +### What changed? + +The `extractDomain` and `isDomain` are now also matching localhost, domains without protocol and domains with query parameters. +The `extractUrl` and `isUrl` are additionally also matching localhost and domains with query parameters. + +### When is action necessary? + +If you're using the `extractDomain` or `isDomain` functions and expect them to not match localhost, domains without protocol and domains with query parameters. + +## 0.223.0 + +### What changed? + +The minimum Node.js version required for n8n is now v16. + +### When is action necessary? + +If you're using n8n via npm or PM2 or if you're contributing to n8n. + +### How to upgrade: + +Update the Node.js version to v16 or above. + ## 0.214.0 ### What changed? diff --git a/packages/cli/README.md b/packages/cli/README.md index 6641c7d12adb9..a47326922694f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -149,4 +149,6 @@ You can also find breaking changes here: [Breaking Changes](./BREAKING-CHANGES.m n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). +Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) + Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). diff --git a/packages/cli/bin/n8n b/packages/cli/bin/n8n index 0591b1ef5fad4..92c38b416b048 100755 --- a/packages/cli/bin/n8n +++ b/packages/cli/bin/n8n @@ -21,10 +21,10 @@ if (process.argv.length === 2) { const nodeVersion = process.versions.node; const nodeVersionMajor = require('semver').major(nodeVersion); -if (![14, 16, 18].includes(nodeVersionMajor)) { +if (![16, 18].includes(nodeVersionMajor)) { console.log(` Your Node.js version (${nodeVersion}) is currently not supported by n8n. - Please use Node.js v14, v16 (recommended), or v18 instead! + Please use Node.js v16 (recommended), or v18 instead! `); process.exit(1); } diff --git a/packages/cli/package.json b/packages/cli/package.json index c32045589e542..25bc5e2217acc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.221.2", + "version": "0.225.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -36,9 +36,10 @@ "swagger": "swagger-cli", "test": "pnpm test:sqlite", "test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest", - "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb jest", + "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb jest --no-coverage", "test:postgres:alt-schema": "DB_POSTGRESDB_SCHEMA=alt_schema pnpm test:postgres", - "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb jest", + "test:postgres:with-table-prefix": "DB_TABLE_PREFIX=xyz_ pnpm test:postgres", + "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb jest --no-coverage", "watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\"", "typeorm": "ts-node -T ../../node_modules/typeorm/cli.js" }, @@ -54,7 +55,7 @@ "workflow" ], "engines": { - "node": ">=14.0.0" + "node": ">=16.9" }, "files": [ "bin", @@ -96,6 +97,7 @@ "@types/replacestream": "^4.0.1", "@types/send": "^0.17.1", "@types/shelljs": "^0.8.11", + "@types/sshpk": "^1.17.1", "@types/superagent": "4.1.13", "@types/swagger-ui-express": "^4.1.3", "@types/syslog-client": "^1.1.2", @@ -114,7 +116,7 @@ "tsconfig-paths": "^4.1.2" }, "dependencies": { - "@n8n_io/license-sdk": "^1.8.0", + "@n8n_io/license-sdk": "~2.3.0", "@oclif/command": "^1.8.16", "@oclif/core": "^1.16.4", "@oclif/errors": "^1.3.6", @@ -141,13 +143,13 @@ "curlconverter": "^3.0.0", "dotenv": "^8.0.0", "express": "^4.18.2", - "express-handlebars": "^7.0.2", "express-async-errors": "^3.1.1", + "express-handlebars": "^7.0.2", "express-openapi-validator": "^4.13.6", "express-prom-bundle": "^6.6.0", "fast-glob": "^3.2.5", "flatted": "^3.2.4", - "google-timezones-json": "^1.0.2", + "google-timezones-json": "^1.1.0", "handlebars": "4.7.7", "inquirer": "^7.0.1", "ioredis": "^5.2.4", @@ -198,6 +200,7 @@ "source-map-support": "^0.5.21", "sqlite3": "^5.1.6", "sse-channel": "^4.0.0", + "sshpk": "^1.17.0", "swagger-ui-express": "^4.3.0", "syslog-client": "^1.1.1", "typedi": "^0.10.0", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index cde2ff3fdd029..55eae9376a520 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -55,6 +55,8 @@ export abstract class AbstractServer { protected endpointWebhookWaiting: string; + protected instanceId = ''; + abstract configure(): Promise; constructor() { diff --git a/packages/cli/src/CommunityNodes/helpers.ts b/packages/cli/src/CommunityNodes/helpers.ts index 6cbe4134c4f75..328a3f6a32732 100644 --- a/packages/cli/src/CommunityNodes/helpers.ts +++ b/packages/cli/src/CommunityNodes/helpers.ts @@ -216,7 +216,7 @@ export function removePackageFromMissingList(packageName: string): void { ); config.set('nodes.packagesMissing', packageFailedToLoad.join(' ')); - } catch (_error) { + } catch { // Do nothing } } diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 4fd8295ce1fa4..cfc86bfd263af 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -224,7 +224,7 @@ export class CredentialsHelper extends ICredentialsHelper { node: INode, defaultTimezone: string, ): string { - if (parameterValue.charAt(0) !== '=') { + if (typeof parameterValue !== 'string' || parameterValue.charAt(0) !== '=') { return parameterValue; } @@ -542,10 +542,10 @@ export class CredentialsHelper extends ICredentialsHelper { ): Promise { const credentialTestFunction = this.getCredentialTestFunction(credentialType); if (credentialTestFunction === undefined) { - return Promise.resolve({ + return { status: 'Error', message: 'No testing function found for this credential.', - }); + }; } if (credentialsDecrypted.data) { diff --git a/packages/cli/src/CurlConverterHelper.ts b/packages/cli/src/CurlConverterHelper.ts index d59b3f5375390..06da059030549 100644 --- a/packages/cli/src/CurlConverterHelper.ts +++ b/packages/cli/src/CurlConverterHelper.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import curlconverter from 'curlconverter'; import get from 'lodash.get'; +import type { IDataObject } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; interface CurlJson { @@ -79,7 +80,7 @@ type HttpNodeHeaders = Pick; -enum ContentTypes { +const enum ContentTypes { applicationJson = 'application/json', applicationFormUrlEncoded = 'application/x-www-form-urlencoded', applicationMultipart = 'multipart/form-data', @@ -195,8 +196,7 @@ const extractQueries = (queries: CurlJson['queries'] = {}): HttpNodeQueries => { }; const extractJson = (body: CurlJson['data']) => - //@ts-ignore - jsonParse<{ [key: string]: string }>(Object.keys(body)[0]); + jsonParse<{ [key: string]: string }>(Object.keys(body as IDataObject)[0]); const jsonBodyToNodeParameters = (body: CurlJson['data'] = {}): Parameter[] | [] => { const data = extractJson(body); @@ -410,22 +410,24 @@ export const toHttpNodeParameters = (curlCommand: string): HttpNodeParameters => sendBody: true, }); - const json = extractJson(curlJson.data); + if (curlJson.data) { + const json = extractJson(curlJson.data); - if (jsonHasNestedObjects(json)) { - // json body - Object.assign(httpNodeParameters, { - specifyBody: 'json', - jsonBody: JSON.stringify(json), - }); - } else { - // key-value body - Object.assign(httpNodeParameters, { - specifyBody: 'keypair', - bodyParameters: { - parameters: jsonBodyToNodeParameters(curlJson.data), - }, - }); + if (jsonHasNestedObjects(json)) { + // json body + Object.assign(httpNodeParameters, { + specifyBody: 'json', + jsonBody: JSON.stringify(json), + }); + } else { + // key-value body + Object.assign(httpNodeParameters, { + specifyBody: 'keypair', + bodyParameters: { + parameters: jsonBodyToNodeParameters(curlJson.data), + }, + }); + } } } else if (isFormUrlEncodedRequest(curlJson)) { Object.assign(httpNodeParameters, { diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 28b760fd2e618..85cee05551553 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -3,14 +3,8 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable no-case-declarations */ /* eslint-disable @typescript-eslint/naming-convention */ -import type { - DataSourceOptions as ConnectionOptions, - EntityManager, - EntityTarget, - LoggerOptions, - ObjectLiteral, - Repository, -} from 'typeorm'; +import { Container } from 'typedi'; +import type { DataSourceOptions as ConnectionOptions, EntityManager, LoggerOptions } from 'typeorm'; import { DataSource as Connection } from 'typeorm'; import type { TlsOptions } from 'tls'; import type { DatabaseType, IDatabaseCollections } from '@/Interfaces'; @@ -25,11 +19,32 @@ import { getPostgresConnectionOptions, getSqliteConnectionOptions, } from '@db/config'; +import { + AuthIdentityRepository, + AuthProviderSyncHistoryRepository, + CredentialsRepository, + EventDestinationsRepository, + ExecutionMetadataRepository, + ExecutionRepository, + InstalledNodesRepository, + InstalledPackagesRepository, + RoleRepository, + SettingsRepository, + SharedCredentialsRepository, + SharedWorkflowRepository, + TagRepository, + UserRepository, + VariablesRepository, + WebhookRepository, + WorkflowRepository, + WorkflowStatisticsRepository, + WorkflowTagMappingRepository, +} from '@db/repositories'; export let isInitialized = false; export const collections = {} as IDatabaseCollections; -export let connection: Connection; +let connection: Connection; export const getConnection = () => connection!; @@ -37,12 +52,6 @@ export async function transaction(fn: (entityManager: EntityManager) => Promi return connection.transaction(fn); } -export function linkRepository( - entityClass: EntityTarget, -): Repository { - return connection.getRepository(entityClass); -} - export function getConnectionOptions(dbType: DatabaseType): ConnectionOptions { switch (dbType) { case 'postgresdb': @@ -114,6 +123,7 @@ export async function init( }); connection = new Connection(connectionOptions); + Container.set(Connection, connection); await connection.initialize(); if (dbType === 'postgresdb') { @@ -148,30 +158,32 @@ export async function init( if (migrations.length === 0) { await connection.destroy(); connection = new Connection(connectionOptions); + Container.set(Connection, connection); await connection.initialize(); } } else { await connection.runMigrations({ transaction: 'each' }); } - collections.Credentials = linkRepository(entities.CredentialsEntity); - collections.Execution = linkRepository(entities.ExecutionEntity); - collections.Workflow = linkRepository(entities.WorkflowEntity); - collections.Webhook = linkRepository(entities.WebhookEntity); - collections.Tag = linkRepository(entities.TagEntity); - collections.Role = linkRepository(entities.Role); - collections.User = linkRepository(entities.User); - collections.AuthIdentity = linkRepository(entities.AuthIdentity); - collections.AuthProviderSyncHistory = linkRepository(entities.AuthProviderSyncHistory); - collections.SharedCredentials = linkRepository(entities.SharedCredentials); - collections.SharedWorkflow = linkRepository(entities.SharedWorkflow); - collections.Settings = linkRepository(entities.Settings); - collections.InstalledPackages = linkRepository(entities.InstalledPackages); - collections.InstalledNodes = linkRepository(entities.InstalledNodes); - collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics); - collections.ExecutionMetadata = linkRepository(entities.ExecutionMetadata); - - collections.EventDestinations = linkRepository(entities.EventDestinations); + collections.AuthIdentity = Container.get(AuthIdentityRepository); + collections.AuthProviderSyncHistory = Container.get(AuthProviderSyncHistoryRepository); + collections.Credentials = Container.get(CredentialsRepository); + collections.EventDestinations = Container.get(EventDestinationsRepository); + collections.Execution = Container.get(ExecutionRepository); + collections.ExecutionMetadata = Container.get(ExecutionMetadataRepository); + collections.InstalledNodes = Container.get(InstalledNodesRepository); + collections.InstalledPackages = Container.get(InstalledPackagesRepository); + collections.Role = Container.get(RoleRepository); + collections.Settings = Container.get(SettingsRepository); + collections.SharedCredentials = Container.get(SharedCredentialsRepository); + collections.SharedWorkflow = Container.get(SharedWorkflowRepository); + collections.Tag = Container.get(TagRepository); + collections.User = Container.get(UserRepository); + collections.Variables = Container.get(VariablesRepository); + collections.Webhook = Container.get(WebhookRepository); + collections.Workflow = Container.get(WorkflowRepository); + collections.WorkflowStatistics = Container.get(WorkflowStatisticsRepository); + collections.WorkflowTagMapping = Container.get(WorkflowTagMappingRepository); isInitialized = true; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 02f15ea5182e3..63f0a6b6beea0 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import type { Application } from 'express'; import type { ExecutionError, @@ -13,7 +12,6 @@ import type { IRunData, IRunExecutionData, ITaskData, - ITelemetrySettings, ITelemetryTrackProperties, IWorkflowBase, CredentialLoadingDetails, @@ -23,7 +21,6 @@ import type { ExecutionStatus, IExecutionsSummary, FeatureFlags, - WorkflowSettings, } from 'n8n-workflow'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -31,25 +28,36 @@ import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import type { WorkflowExecute } from 'n8n-core'; import type PCancelable from 'p-cancelable'; -import type { FindOperator, Repository } from 'typeorm'; +import type { FindOperator } from 'typeorm'; import type { ChildProcess } from 'child_process'; -import type { AuthIdentity, AuthProviderType } from '@db/entities/AuthIdentity'; -import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; -import type { InstalledNodes } from '@db/entities/InstalledNodes'; -import type { InstalledPackages } from '@db/entities/InstalledPackages'; +import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { Role } from '@db/entities/Role'; -import type { Settings } from '@db/entities/Settings'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; -import type { WebhookEntity } from '@db/entities/WebhookEntity'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; -import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity'; -import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata'; +import type { + AuthIdentityRepository, + AuthProviderSyncHistoryRepository, + CredentialsRepository, + EventDestinationsRepository, + ExecutionMetadataRepository, + ExecutionRepository, + InstalledNodesRepository, + InstalledPackagesRepository, + RoleRepository, + SettingsRepository, + SharedCredentialsRepository, + SharedWorkflowRepository, + TagRepository, + UserRepository, + VariablesRepository, + WebhookRepository, + WorkflowRepository, + WorkflowStatisticsRepository, + WorkflowTagMappingRepository, +} from '@db/repositories'; export interface IActivationError { time: number; @@ -73,25 +81,29 @@ export interface ICredentialsOverwrite { [key: string]: ICredentialDataDecryptedObject; } +/* eslint-disable @typescript-eslint/naming-convention */ export interface IDatabaseCollections { - AuthIdentity: Repository; - AuthProviderSyncHistory: Repository; - Credentials: Repository; - Execution: Repository; - Workflow: Repository; - Webhook: Repository; - Tag: Repository; - Role: Repository; - User: Repository; - SharedCredentials: Repository; - SharedWorkflow: Repository; - Settings: Repository; - InstalledPackages: Repository; - InstalledNodes: Repository; - WorkflowStatistics: Repository; - EventDestinations: Repository; - ExecutionMetadata: Repository; -} + AuthIdentity: AuthIdentityRepository; + AuthProviderSyncHistory: AuthProviderSyncHistoryRepository; + Credentials: CredentialsRepository; + EventDestinations: EventDestinationsRepository; + Execution: ExecutionRepository; + ExecutionMetadata: ExecutionMetadataRepository; + InstalledNodes: InstalledNodesRepository; + InstalledPackages: InstalledPackagesRepository; + Role: RoleRepository; + Settings: SettingsRepository; + SharedCredentials: SharedCredentialsRepository; + SharedWorkflow: SharedWorkflowRepository; + Tag: TagRepository; + User: UserRepository; + Variables: VariablesRepository; + Webhook: WebhookRepository; + Workflow: WorkflowRepository; + WorkflowStatistics: WorkflowStatisticsRepository; + WorkflowTagMapping: WorkflowTagMappingRepository; +} +/* eslint-enable @typescript-eslint/naming-convention */ // ---------------------------------- // tags @@ -108,7 +120,8 @@ export type UsageCount = { usageCount: number; }; -export type ITagWithCountDb = TagEntity & UsageCount; +export type ITagWithCountDb = Pick & + UsageCount; // ---------------------------------- // workflows @@ -165,7 +178,7 @@ export interface IExecutionBase { // Data in regular format with references export interface IExecutionDb extends IExecutionBase { data: IRunExecutionData; - waitTill?: Date; + waitTill?: Date | null; workflowData?: IWorkflowBase; } @@ -179,7 +192,7 @@ export interface IExecutionResponse extends IExecutionBase { data: IRunExecutionData; retryOf?: string; retrySuccessId?: string; - waitTill?: Date; + waitTill?: Date | null; workflowData: IWorkflowBase; } @@ -445,6 +458,7 @@ export interface IInternalHooksClass { }): Promise; onApiKeyCreated(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; + onVariableCreated(createData: { variable_type: string }): Promise; } export interface IVersionNotificationSettings { @@ -453,84 +467,6 @@ export interface IVersionNotificationSettings { infoUrl: string; } -export interface IN8nUISettings { - endpointWebhook: string; - endpointWebhookTest: string; - saveDataErrorExecution: WorkflowSettings.SaveDataExecution; - saveDataSuccessExecution: WorkflowSettings.SaveDataExecution; - saveManualExecutions: boolean; - executionTimeout: number; - maxExecutionTimeout: number; - workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy; - oauthCallbackUrls: { - oauth1: string; - oauth2: string; - }; - timezone: string; - urlBaseWebhook: string; - urlBaseEditor: string; - versionCli: string; - n8nMetadata?: { - [key: string]: string | number | undefined; - }; - versionNotifications: IVersionNotificationSettings; - instanceId: string; - telemetry: ITelemetrySettings; - posthog: { - enabled: boolean; - apiHost: string; - apiKey: string; - autocapture: boolean; - disableSessionRecording: boolean; - debug: boolean; - }; - personalizationSurveyEnabled: boolean; - defaultLocale: string; - userManagement: IUserManagementSettings; - sso: { - saml: { - loginLabel: string; - loginEnabled: boolean; - }; - ldap: { - loginLabel: string; - loginEnabled: boolean; - }; - }; - publicApi: IPublicApiSettings; - workflowTagsDisabled: boolean; - logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent'; - hiringBannerEnabled: boolean; - templates: { - enabled: boolean; - host: string; - }; - onboardingCallPromptEnabled: boolean; - missingPackages?: boolean; - executionMode: 'regular' | 'queue'; - pushBackend: 'sse' | 'websocket'; - communityNodesEnabled: boolean; - deployment: { - type: string; - }; - isNpmAvailable: boolean; - allowedModules: { - builtIn?: string; - external?: string; - }; - enterprise: { - sharing: boolean; - ldap: boolean; - saml: boolean; - logStreaming: boolean; - advancedExecutionFilters: boolean; - }; - hideUsagePage: boolean; - license: { - environment: 'production' | 'staging'; - }; -} - export interface IPersonalizationSurveyAnswers { email: string | null; codingSkill: string | null; @@ -543,24 +479,14 @@ export interface IPersonalizationSurveyAnswers { export interface IUserSettings { isOnboarded?: boolean; + showUserActivationSurvey?: boolean; + firstSuccessfulWorkflowId?: string; + userActivated?: boolean; } -export interface IUserManagementSettings { - enabled: boolean; - showSetupOnFirstLoad?: boolean; - smtpSetup: boolean; -} export interface IActiveDirectorySettings { enabled: boolean; } -export interface IPublicApiSettings { - enabled: boolean; - latestVersion: number; - path: string; - swaggerUi: { - enabled: boolean; - }; -} export interface IPackageVersions { cli: string; @@ -723,6 +649,7 @@ export interface IProcessMessageDataHook { export interface IWorkflowExecutionDataProcess { destinationNode?: string; + restartExecutionId?: string; executionMode: WorkflowExecuteMode; executionData?: IRunExecutionData; runData?: IRunData; @@ -849,6 +776,7 @@ export interface PublicUser { globalRole?: Role; signInType: AuthProviderType; disabled: boolean; + settings?: IUserSettings | null; inviteAcceptUrl?: string; } @@ -862,3 +790,5 @@ export interface N8nApp { externalHooks: IExternalHooksClass; activeWorkflowRunner: ActiveWorkflowRunner; } + +export type UserSettings = Pick; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index ea36dc04b8627..965f84642b6fa 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -51,7 +51,11 @@ function userToPayload(user: User): { export class InternalHooks implements IInternalHooksClass { private instanceId: string; - constructor(private telemetry: Telemetry, private nodeTypes: NodeTypes) {} + constructor( + private telemetry: Telemetry, + private nodeTypes: NodeTypes, + private roleService: RoleService, + ) {} async init(instanceId: string) { this.instanceId = instanceId; @@ -155,7 +159,7 @@ export class InternalHooks implements IInternalHooksClass { let userRole: 'owner' | 'sharee' | undefined = undefined; if (user.id && workflow.id) { - const role = await RoleService.getUserRoleForWorkflow(user.id, workflow.id); + const role = await this.roleService.getUserRoleForWorkflow(user.id, workflow.id); if (role) { userRole = role.name === 'owner' ? 'owner' : 'sharee'; } @@ -267,12 +271,12 @@ export class InternalHooks implements IInternalHooksClass { runData?: IRun, userId?: string, ): Promise { - const promises = [Promise.resolve()]; - if (!workflow.id) { - return Promise.resolve(); + return; } + const promises = []; + const properties: IExecutionTrackProperties = { workflow_id: workflow.id, is_manual: false, @@ -342,8 +346,7 @@ export class InternalHooks implements IInternalHooksClass { let userRole: 'owner' | 'sharee' | undefined = undefined; if (userId) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const role = await RoleService.getUserRoleForWorkflow(userId, workflow.id); + const role = await this.roleService.getUserRoleForWorkflow(userId, workflow.id); if (role) { userRole = role.name === 'owner' ? 'owner' : 'sharee'; } @@ -978,4 +981,8 @@ export class InternalHooks implements IInternalHooksClass { async onAuditGeneratedViaCli() { return this.telemetry.track('Instance generated security audit via CLI command'); } + + async onVariableCreated(createData: { variable_type: string }): Promise { + return this.telemetry.track('User created variable', createData); + } } diff --git a/packages/cli/src/Ldap/LdapService.ee.ts b/packages/cli/src/Ldap/LdapService.ee.ts index bc5432cbea6ef..63394a4145fe4 100644 --- a/packages/cli/src/Ldap/LdapService.ee.ts +++ b/packages/cli/src/Ldap/LdapService.ee.ts @@ -82,7 +82,7 @@ export class LdapService { await this.client.unbind(); return searchEntries; } - return Promise.resolve([]); + return []; } /** diff --git a/packages/cli/src/Ldap/constants.ts b/packages/cli/src/Ldap/constants.ts index 3b7b369b80abf..c70159e4e071f 100644 --- a/packages/cli/src/Ldap/constants.ts +++ b/packages/cli/src/Ldap/constants.ts @@ -2,8 +2,6 @@ import type { LdapConfig } from './types'; export const LDAP_FEATURE_NAME = 'features.ldap'; -export const LDAP_ENABLED = 'enterprise.features.ldap'; - export const LDAP_LOGIN_LABEL = 'sso.ldap.loginLabel'; export const LDAP_LOGIN_ENABLED = 'sso.ldap.loginEnabled'; diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 6ed83242e001a..8c51add60be37 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -10,6 +10,7 @@ import config from '@/config'; import type { Role } from '@db/entities/Role'; import { User } from '@db/entities/User'; import { AuthIdentity } from '@db/entities/AuthIdentity'; +import { RoleRepository } from '@db/repositories'; import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import { LdapManager } from './LdapManager.ee'; @@ -17,7 +18,6 @@ import { LdapManager } from './LdapManager.ee'; import { BINARY_AD_ATTRIBUTES, LDAP_CONFIG_SCHEMA, - LDAP_ENABLED, LDAP_FEATURE_NAME, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL, @@ -27,17 +27,19 @@ import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow'; import { License } from '@/License'; import { InternalHooks } from '@/InternalHooks'; import { + getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '@/sso/ssoHelpers'; +import { InternalServerError } from '../ResponseHelper'; /** * Check whether the LDAP feature is disabled in the instance */ export const isLdapEnabled = (): boolean => { const license = Container.get(License); - return isUserManagementEnabled() && (config.getEnv(LDAP_ENABLED) || license.isLdapEnabled()); + return isUserManagementEnabled() && license.isLdapEnabled(); }; /** @@ -55,25 +57,21 @@ export const setLdapLoginLabel = (value: string): void => { /** * Set the LDAP login enabled to the configuration object */ -export const setLdapLoginEnabled = async (value: boolean): Promise => { - if (config.get(LDAP_LOGIN_ENABLED) === value) { - return; - } - // only one auth method can be active at a time, with email being the default - if (value && isEmailCurrentAuthenticationMethod()) { - // enable ldap login and disable email login, but only if email is the current auth method - config.set(LDAP_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('ldap'); - } else if (!value && isLdapCurrentAuthenticationMethod()) { - // disable ldap login, but only if ldap is the current auth method - config.set(LDAP_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); +export async function setLdapLoginEnabled(enabled: boolean): Promise { + if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) { + if (enabled) { + config.set(LDAP_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('ldap'); + } else if (!enabled) { + config.set(LDAP_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } } else { - Logger.warn( - 'Cannot switch LDAP login enabled state when an authentication method other than email is active', + throw new InternalServerError( + `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, ); } -}; +} /** * Retrieve the LDAP login label from the configuration object @@ -96,7 +94,7 @@ export const randomPassword = (): string => { * Return the user role to be assigned to LDAP users */ export const getLdapUserRole = async (): Promise => { - return Db.collections.Role.findOneByOrFail({ scope: 'global', name: 'member' }); + return Container.get(RoleRepository).findGlobalMemberRoleOrFail(); }; /** @@ -218,7 +216,15 @@ export const handleLdapInit = async (): Promise => { const ldapConfig = await getLdapConfig(); - await setGlobalLdapConfigVariables(ldapConfig); + try { + await setGlobalLdapConfigVariables(ldapConfig); + } catch (error) { + Logger.error( + `Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + error, + ); + } // init LDAP manager with the current // configuration diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 9dc68d86a5240..1b820931e1ed6 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -1,33 +1,17 @@ -import type { TEntitlement, TLicenseContainerStr } from '@n8n_io/license-sdk'; +import type { TEntitlement, TLicenseBlock } from '@n8n_io/license-sdk'; import { LicenseManager } from '@n8n_io/license-sdk'; import type { ILogger } from 'n8n-workflow'; import { getLogger } from './Logger'; import config from '@/config'; import * as Db from '@/Db'; -import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants'; +import { + LICENSE_FEATURES, + LICENSE_QUOTAS, + N8N_VERSION, + SETTINGS_LICENSE_CERT_KEY, +} from './constants'; import { Service } from 'typedi'; -async function loadCertStr(): Promise { - const databaseSettings = await Db.collections.Settings.findOne({ - where: { - key: SETTINGS_LICENSE_CERT_KEY, - }, - }); - - return databaseSettings?.value ?? ''; -} - -async function saveCertStr(value: TLicenseContainerStr): Promise { - await Db.collections.Settings.upsert( - { - key: SETTINGS_LICENSE_CERT_KEY, - value, - loadOnStartup: false, - }, - ['key'], - ); -} - @Service() export class License { private logger: ILogger; @@ -55,8 +39,8 @@ export class License { autoRenewEnabled, autoRenewOffset, logger: this.logger, - loadCertStr, - saveCertStr, + loadCertStr: async () => this.loadCertStr(), + saveCertStr: async (value: TLicenseBlock) => this.saveCertStr(value), deviceFingerprint: () => instanceId, }); @@ -68,6 +52,34 @@ export class License { } } + async loadCertStr(): Promise { + // if we have an ephemeral license, we don't want to load it from the database + const ephemeralLicense = config.get('license.cert'); + if (ephemeralLicense) { + return ephemeralLicense; + } + const databaseSettings = await Db.collections.Settings.findOne({ + where: { + key: SETTINGS_LICENSE_CERT_KEY, + }, + }); + + return databaseSettings?.value ?? ''; + } + + async saveCertStr(value: TLicenseBlock): Promise { + // if we have an ephemeral license, we don't want to save it to the database + if (config.get('license.cert')) return; + await Db.collections.Settings.upsert( + { + key: SETTINGS_LICENSE_CERT_KEY, + value, + loadOnStartup: false, + }, + ['key'], + ); + } + async activate(activationKey: string): Promise { if (!this.manager) { return; @@ -112,6 +124,14 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } + isVariablesEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES); + } + + isVersionControlLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } @@ -155,10 +175,22 @@ export class License { // Helper functions for computed data getTriggerLimit(): number { - return (this.getFeatureValue('quota:activeWorkflows') ?? -1) as number; + return (this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? -1) as number; + } + + getVariablesLimit(): number { + return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number; } getPlanName(): string { return (this.getFeatureValue('planName') ?? 'Community') as string; } + + getInfo(): string { + if (!this.manager) { + return 'n/a'; + } + + return this.manager.toString(); + } } diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 8048673425728..47623076c4ff6 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -67,9 +67,19 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { this.downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); // Load nodes from `n8n-nodes-base` and any other `n8n-nodes-*` package in the main `node_modules` - await this.loadNodesFromNodeModules(CLI_DIR); - // Load nodes from installed community packages - await this.loadNodesFromNodeModules(this.downloadFolder); + const pathsToScan = [ + // In case "n8n" package is in same node_modules folder. + path.join(CLI_DIR, '..'), + // In case "n8n" package is the root and the packages are + // in the "node_modules" folder underneath it. + path.join(CLI_DIR, 'node_modules'), + // Path where all community nodes are installed + path.join(this.downloadFolder, 'node_modules'), + ]; + + for (const nodeModulesDir of pathsToScan) { + await this.loadNodesFromNodeModules(nodeModulesDir); + } await this.loadNodesFromCustomDirectories(); await this.postProcessLoaders(); @@ -117,8 +127,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { await writeStaticJSON('credentials', this.types.credentials); } - private async loadNodesFromNodeModules(scanDir: string): Promise { - const nodeModulesDir = path.join(scanDir, 'node_modules'); + private async loadNodesFromNodeModules(nodeModulesDir: string): Promise { const globOptions = { cwd: nodeModulesDir, onlyDirectories: true }; const installedPackagePaths = [ ...(await glob('n8n-nodes-*', { ...globOptions, deep: 1 })), diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index d1a44c8c08a82..e207ac55bc7bf 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -5,6 +5,7 @@ import type { ICredentialsDb } from '@/Interfaces'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import type { User } from '@db/entities/User'; +import { RoleRepository } from '@db/repositories'; import { ExternalHooks } from '@/ExternalHooks'; import type { IDependency, IJsonSchema } from '../../../types'; import type { CredentialRequest } from '@/requests'; @@ -58,10 +59,7 @@ export async function saveCredential( user: User, encryptedData: ICredentialsDb, ): Promise { - const role = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'credential', - }); + const role = await Container.get(RoleRepository).findCredentialOwnerRoleOrFail(); await Container.get(ExternalHooks).run('credentials.create', [encryptedData]); diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index f3cbab6a3d09f..2ac5f72a55a4e 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -23,7 +23,7 @@ export = { const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); // user does not have workflows hence no executions - // or the execution he is trying to access belongs to a workflow he does not own + // or the execution they are trying to access belongs to a workflow they do not own if (!sharedWorkflowsIds.length) { return res.status(404).json({ message: 'Not Found' }); } @@ -52,7 +52,7 @@ export = { const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); // user does not have workflows hence no executions - // or the execution he is trying to access belongs to a workflow he does not own + // or the execution they are trying to access belongs to a workflow they do not own if (!sharedWorkflowsIds.length) { return res.status(404).json({ message: 'Not Found' }); } @@ -90,8 +90,8 @@ export = { const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); // user does not have workflows hence no executions - // or the execution he is trying to access belongs to a workflow he does not own - if (!sharedWorkflowsIds.length) { + // or the execution they are trying to access belongs to a workflow they do not own + if (!sharedWorkflowsIds.length || (workflowId && !sharedWorkflowsIds.includes(workflowId))) { return res.status(200).json({ data: [], nextCursor: null }); } @@ -105,7 +105,7 @@ export = { limit, lastId, includeData, - ...(workflowId && { workflowIds: [workflowId] }), + workflowIds: workflowId ? [workflowId] : sharedWorkflowsIds, excludedExecutionsIds: runningExecutionsIds, }; diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts index 57ddb4b84a389..064d0be471416 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts @@ -1,4 +1,5 @@ -import * as Db from '@/Db'; +import { Container } from 'typedi'; +import { RoleRepository } from '@db/repositories'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; @@ -7,8 +8,5 @@ export function isInstanceOwner(user: User): boolean { } export async function getWorkflowOwnerRole(): Promise { - return Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'workflow', - }); + return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); } diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 1dbfe72da28c1..5eaa8fd232dd1 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -62,7 +62,7 @@ export = { const workflow = await WorkflowsService.delete(req.user, workflowId); if (!workflow) { - // user trying to access a workflow he does not own + // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } @@ -78,7 +78,7 @@ export = { const sharedWorkflow = await getSharedWorkflow(req.user, id); if (!sharedWorkflow) { - // user trying to access a workflow he does not own + // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } @@ -158,7 +158,7 @@ export = { const sharedWorkflow = await getSharedWorkflow(req.user, id); if (!sharedWorkflow) { - // user trying to access a workflow he does not own + // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } @@ -212,7 +212,7 @@ export = { const sharedWorkflow = await getSharedWorkflow(req.user, id); if (!sharedWorkflow) { - // user trying to access a workflow he does not own + // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } @@ -246,7 +246,7 @@ export = { const sharedWorkflow = await getSharedWorkflow(req.user, id); if (!sharedWorkflow) { - // user trying to access a workflow he does not own + // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index d727fd88055ee..adb37dce57dd1 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -18,9 +18,12 @@ function insertIf(condition: boolean, elements: string[]): string[] { } export async function getSharedWorkflowIds(user: User): Promise { + const where = user.globalRole.name === 'owner' ? {} : { userId: user.id }; const sharedWorkflows = await Db.collections.SharedWorkflow.find({ - where: { userId: user.id }, + where, + select: ['workflowId'], }); + return sharedWorkflows.map(({ workflowId }) => workflowId); return sharedWorkflows.map(({ workflowId }) => workflowId); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 5bffa178c7e08..e8eb44eba3392 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -10,7 +10,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ - +import assert from 'assert'; import { exec as callbackExec } from 'child_process'; import { access as fsAccess } from 'fs/promises'; import os from 'os'; @@ -49,6 +49,7 @@ import type { ICredentialTypes, ExecutionStatus, IExecutionsSummary, + IN8nUISettings, } from 'n8n-workflow'; import { LoggerProxy, jsonParse } from 'n8n-workflow'; @@ -112,7 +113,6 @@ import type { IDiagnosticInfo, IExecutionFlattedDb, IExecutionsStopData, - IN8nUISettings, } from '@/Interfaces'; import { ActiveExecutions } from '@/ActiveExecutions'; import { @@ -156,7 +156,13 @@ import { import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/saml/samlHelpers'; import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; +import { variablesController } from './environments/variables/variables.controller'; import { LdapManager } from './Ldap/LdapManager.ee'; +import { getVariablesLimit, isVariablesEnabled } from '@/environments/variables/enviromentHelpers'; +import { getCurrentAuthenticationMethod } from './sso/ssoHelpers'; +import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper'; +import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee'; +import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee'; const exec = promisify(callbackExec); @@ -261,6 +267,8 @@ class Server extends AbstractServer { }, personalizationSurveyEnabled: config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'), + userActivationSurveyEnabled: + config.getEnv('userActivationSurvey.enabled') && config.getEnv('diagnostics.enabled'), defaultLocale: config.getEnv('defaultLocale'), userManagement: { enabled: isUserManagementEnabled(), @@ -269,6 +277,7 @@ class Server extends AbstractServer { config.getEnv('userManagement.isInstanceOwnerSetUp') === false && config.getEnv('userManagement.skipInstanceOwnerSetup') === false, smtpSetup: isEmailSetUp(), + authenticationMethod: getCurrentAuthenticationMethod(), }, sso: { saml: { @@ -304,20 +313,25 @@ class Server extends AbstractServer { }, isNpmAvailable: false, allowedModules: { - builtIn: process.env.NODE_FUNCTION_ALLOW_BUILTIN, - external: process.env.NODE_FUNCTION_ALLOW_EXTERNAL, + builtIn: process.env.NODE_FUNCTION_ALLOW_BUILTIN?.split(',') ?? undefined, + external: process.env.NODE_FUNCTION_ALLOW_EXTERNAL?.split(',') ?? undefined, }, enterprise: { sharing: false, ldap: false, saml: false, - logStreaming: config.getEnv('enterprise.features.logStreaming'), - advancedExecutionFilters: config.getEnv('enterprise.features.advancedExecutionFilters'), + logStreaming: false, + advancedExecutionFilters: false, + variables: false, + versionControl: false, }, hideUsagePage: config.getEnv('hideUsagePage'), license: { environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', }, + variables: { + limit: 0, + }, }; } @@ -328,6 +342,7 @@ class Server extends AbstractServer { // refresh user management status Object.assign(this.frontendSettings.userManagement, { enabled: isUserManagementEnabled(), + authenticationMethod: getCurrentAuthenticationMethod(), showSetupOnFirstLoad: config.getEnv('userManagement.disabled') === false && config.getEnv('userManagement.isInstanceOwnerSetUp') === false && @@ -342,6 +357,8 @@ class Server extends AbstractServer { ldap: isLdapEnabled(), saml: isSamlLicensed(), advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), + variables: isVariablesEnabled(), + versionControl: isVersionControlLicensed(), }); if (isLdapEnabled()) { @@ -358,10 +375,13 @@ class Server extends AbstractServer { }); } + if (isVariablesEnabled()) { + this.frontendSettings.variables.limit = getVariablesLimit(); + } + if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } - return this.frontendSettings; } @@ -375,6 +395,7 @@ class Server extends AbstractServer { const mailer = Container.get(UserManagementMailer); const postHog = this.postHog; const samlService = Container.get(SamlService); + const versionControlService = Container.get(VersionControlService); const controllers: object[] = [ new EventBusController(), @@ -403,6 +424,7 @@ class Server extends AbstractServer { postHog, }), new SamlController(samlService), + new VersionControlController(versionControlService), ]; if (isLdapEnabled()) { @@ -422,13 +444,15 @@ class Server extends AbstractServer { async configure(): Promise { configureMetrics(this.app); + this.instanceId = await UserSettings.getInstanceId(); + this.frontendSettings.isNpmAvailable = await exec('npm --version') .then(() => true) .catch(() => false); this.frontendSettings.versionCli = N8N_VERSION; - this.frontendSettings.instanceId = await UserSettings.getInstanceId(); + this.frontendSettings.instanceId = this.instanceId; await this.externalHooks.run('frontend.settings', [this.frontendSettings]); @@ -449,6 +473,11 @@ class Server extends AbstractServer { ...excludeEndpoints.split(':'), ].filter((u) => !!u); + assert( + !ignoredEndpoints.includes(this.restEndpoint), + `REST endpoint cannot be set to any of these values: ${ignoredEndpoints.join()} `, + ); + // eslint-disable-next-line no-useless-escape const authIgnoreRegex = new RegExp(`^\/(${ignoredEndpoints.join('|')})\/?.*$`); @@ -522,15 +551,32 @@ class Server extends AbstractServer { // initialize SamlService if it is licensed, even if not enabled, to // set up the initial environment - if (isSamlLicensed()) { - try { - await Container.get(SamlService).init(); - } catch (error) { - LoggerProxy.error(`SAML initialization failed: ${error.message}`); - } + try { + await Container.get(SamlService).init(); + } catch (error) { + LoggerProxy.warn(`SAML initialization failed: ${error.message}`); } // ---------------------------------------- + // Variables + // ---------------------------------------- + + this.app.use(`/${this.restEndpoint}/variables`, variablesController); + + // ---------------------------------------- + // Version Control + // ---------------------------------------- + + // initialize SamlService if it is licensed, even if not enabled, to + // set up the initial environment + try { + await Container.get(VersionControlService).init(); + } catch (error) { + LoggerProxy.warn(`Version Control initialization failed: ${error.message}`); + } + + // ---------------------------------------- + // Returns parameter values which normally get loaded from an external API or // get generated dynamically this.app.get( diff --git a/packages/cli/src/TagHelpers.ts b/packages/cli/src/TagHelpers.ts index 39313ea5aa356..b433149236d97 100644 --- a/packages/cli/src/TagHelpers.ts +++ b/packages/cli/src/TagHelpers.ts @@ -1,10 +1,6 @@ -/* eslint-disable no-param-reassign */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import type { EntityManager } from 'typeorm'; - -import { getConnection } from '@/Db'; import { TagEntity } from '@db/entities/TagEntity'; -import type { ITagToImport, ITagWithCountDb, IWorkflowToImport } from '@/Interfaces'; +import type { ITagToImport, IWorkflowToImport } from '@/Interfaces'; // ---------------------------------- // utils @@ -25,70 +21,10 @@ export function sortByRequestOrder( return requestOrder.map((tagId) => tagMap[tagId]); } -// ---------------------------------- -// queries -// ---------------------------------- - -/** - * Retrieve all tags and the number of workflows each tag is related to. - */ -export async function getTagsWithCountDb(tablePrefix: string): Promise { - return getConnection() - .createQueryBuilder() - .select(`${tablePrefix}tag_entity.id`, 'id') - .addSelect(`${tablePrefix}tag_entity.name`, 'name') - .addSelect(`${tablePrefix}tag_entity.createdAt`, 'createdAt') - .addSelect(`${tablePrefix}tag_entity.updatedAt`, 'updatedAt') - .addSelect(`COUNT(${tablePrefix}workflows_tags.workflowId)`, 'usageCount') - .from(`${tablePrefix}tag_entity`, 'tag_entity') - .leftJoin( - `${tablePrefix}workflows_tags`, - 'workflows_tags', - `${tablePrefix}workflows_tags.tagId = tag_entity.id`, - ) - .groupBy(`${tablePrefix}tag_entity.id`) - .getRawMany() - .then((tagsWithCount) => { - tagsWithCount.forEach((tag) => { - // NOTE: since this code doesn't use the DB entities, we need to stringify the IDs manually - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - tag.id = tag.id.toString(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - tag.usageCount = Number(tag.usageCount); - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return tagsWithCount; - }); -} - // ---------------------------------- // mutations // ---------------------------------- -/** - * Relate a workflow to one or more tags. - */ -export async function createRelations(workflowId: string, tagIds: string[], tablePrefix: string) { - return getConnection() - .createQueryBuilder() - .insert() - .into(`${tablePrefix}workflows_tags`) - .values(tagIds.map((tagId) => ({ workflowId, tagId }))) - .execute(); -} - -/** - * Remove all tags for a workflow during a tag update operation. - */ -export async function removeRelations(workflowId: string, tablePrefix: string) { - return getConnection() - .createQueryBuilder() - .delete() - .from(`${tablePrefix}workflows_tags`) - .where('workflowId = :id', { id: workflowId }) - .execute(); -} - const createTag = async (transactionManager: EntityManager, name: string): Promise => { const tag = new TagEntity(); tag.name = name; diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 4c34a57b31e26..e299544840759 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { In } from 'typeorm'; -import type express from 'express'; import { compare, genSaltSync, hash } from 'bcryptjs'; -import Container from 'typedi'; +import { Container } from 'typedi'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; @@ -11,15 +10,14 @@ import type { CurrentUser, PublicUser, WhereClause } from '@/Interfaces'; import type { User } from '@db/entities/User'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; import type { Role } from '@db/entities/Role'; -import type { AuthenticatedRequest } from '@/requests'; +import { RoleRepository } from '@db/repositories'; import config from '@/config'; import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { License } from '@/License'; -import { RoleService } from '@/role/role.service'; import type { PostHogClient } from '@/posthog'; export async function getWorkflowOwner(workflowId: string): Promise { - const workflowOwnerRole = await RoleService.get({ name: 'owner', scope: 'workflow' }); + const workflowOwnerRole = await Container.get(RoleRepository).findWorkflowOwnerRole(); const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({ where: { workflowId, roleId: workflowOwnerRole?.id ?? undefined }, @@ -57,20 +55,13 @@ export function isUserManagementEnabled(): boolean { export function isSharingEnabled(): boolean { const license = Container.get(License); - return ( - isUserManagementEnabled() && - (config.getEnv('enterprise.features.sharing') || license.isSharingEnabled()) - ); + return isUserManagementEnabled() && license.isSharingEnabled(); } export async function getRoleId(scope: Role['scope'], name: Role['name']): Promise { - return Db.collections.Role.findOneOrFail({ - select: ['id'], - where: { - name, - scope, - }, - }).then((role) => role.id); + return Container.get(RoleRepository) + .findRoleOrFail(scope, name) + .then((role) => role.id); } export async function getInstanceOwner(): Promise { @@ -182,7 +173,6 @@ export async function withFeatureFlags( const fetchPromise = new Promise(async (resolve) => { user.featureFlags = await postHog.getFeatureFlags(user); - resolve(user); }); @@ -204,30 +194,6 @@ export async function getUserById(userId: string): Promise { return user; } -/** - * Check if a URL contains an auth-excluded endpoint. - */ -export function isAuthExcluded(url: string, ignoredEndpoints: Readonly): boolean { - return !!ignoredEndpoints - .filter(Boolean) // skip empty paths - .find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`)); -} - -/** - * Check if the endpoint is `POST /users/:id`. - */ -export function isPostUsersId(req: express.Request, restEndpoint: string): boolean { - return ( - req.method === 'POST' && - new RegExp(`/${restEndpoint}/users/[\\w\\d-]*`).test(req.url) && - !req.url.includes('reinvite') - ); -} - -export function isAuthenticatedRequest(request: express.Request): request is AuthenticatedRequest { - return request.user !== undefined; -} - // ---------------------------------- // hashing // ---------------------------------- diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index 4441cb8e31bc4..e04daee126ae5 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -124,14 +124,14 @@ export class WaitTracker { }; fullExecutionData.stoppedAt = new Date(); - fullExecutionData.waitTill = undefined; + fullExecutionData.waitTill = null; fullExecutionData.status = 'canceled'; await Db.collections.Execution.update( executionId, ResponseHelper.flattenExecutionData({ ...fullExecutionData, - }), + }) as IExecutionFlattedDb, ); return { diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 85597074b4874..f958d3a49508d 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -71,7 +71,7 @@ import { PermissionChecker } from './UserManagement/PermissionChecker'; import { WorkflowsService } from './workflows/workflows.services'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; -import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata'; +import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -638,7 +638,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { }; if (this.retryOf !== undefined) { - fullExecutionData.retryOf = this.retryOf.toString(); + fullExecutionData.retryOf = this.retryOf?.toString(); } const workflowId = this.workflowData.id; @@ -1054,7 +1054,7 @@ async function executeWorkflow( mode: 'integrated', startedAt: new Date(), stoppedAt: new Date(), - status: 'error', + status: 'failed', }; // When failing, we might not have finished the execution // Therefore, database might not contain finished errors. @@ -1073,6 +1073,9 @@ async function executeWorkflow( fullExecutionData.workflowId = workflowData.id; } + // remove execution from active executions + Container.get(ActiveExecutions).remove(executionId, fullRunData); + const executionData = ResponseHelper.flattenExecutionData(fullExecutionData); await Db.collections.Execution.update(executionId, executionData as IExecutionFlattedDb); @@ -1161,7 +1164,10 @@ export async function getBase( const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest'); - const encryptionKey = await UserSettings.getEncryptionKey(); + const [encryptionKey, variables] = await Promise.all([ + UserSettings.getEncryptionKey(), + WorkflowHelpers.getVariables(), + ]); return { credentialsHelper: new CredentialsHelper(encryptionKey), @@ -1176,6 +1182,7 @@ export async function getBase( executionTimeoutTimestamp, userId, setExecutionStatus, + variables, }; } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index f201abd40c882..4f8b121bbb041 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -30,10 +30,12 @@ import { WorkflowRunner } from '@/WorkflowRunner'; import config from '@/config'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { User } from '@db/entities/User'; +import { RoleRepository } from '@db/repositories'; import { whereClause } from '@/UserManagement/UserManagementHelper'; import omit from 'lodash.omit'; import { PermissionChecker } from './UserManagement/PermissionChecker'; import { isWorkflowIdValid } from './utils'; +import { UserService } from './user/user.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -388,17 +390,11 @@ export async function isBelowOnboardingThreshold(user: User): Promise { let belowThreshold = true; const skippedTypes = ['n8n-nodes-base.start', 'n8n-nodes-base.stickyNote']; - const workflowOwnerRoleId = await Db.collections.Role.findOne({ - select: ['id'], - where: { - name: 'owner', - scope: 'workflow', - }, - }).then((role) => role?.id); + const workflowOwnerRole = await Container.get(RoleRepository).findWorkflowOwnerRole(); const ownedWorkflowsIds = await Db.collections.SharedWorkflow.find({ where: { userId: user.id, - roleId: workflowOwnerRoleId, + roleId: workflowOwnerRole?.id, }, select: ['workflowId'], }).then((ownedWorkflows) => ownedWorkflows.map(({ workflowId }) => workflowId)); @@ -429,7 +425,7 @@ export async function isBelowOnboardingThreshold(user: User): Promise { // user is above threshold --> set flag in settings if (!belowThreshold) { - void Db.collections.User.update(user.id, { settings: { isOnboarded: true } }); + void UserService.updateUserSettings(user.id, { isOnboarded: true }); } return belowThreshold; @@ -566,3 +562,12 @@ export function validateWorkflowCredentialUsage( return newWorkflowVersion; } + +export async function getVariables(): Promise { + return Object.freeze( + (await Db.collections.Variables.find()).reduce((prev, curr) => { + prev[curr.key] = curr.value; + return prev; + }, {} as IDataObject), + ); +} diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 16683b57c9acc..c785a2fa22160 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -21,7 +21,6 @@ import type { IRun, WorkflowExecuteMode, WorkflowHooks, - WorkflowSettings, } from 'n8n-workflow'; import { ErrorReporterProxy as ErrorReporter, @@ -271,6 +270,7 @@ export class WorkflowRunner { undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, ); + additionalData.restartExecutionId = restartExecutionId; // Register the active execution const executionId = await this.activeExecutions.add(data, undefined, restartExecutionId); @@ -644,6 +644,8 @@ export class WorkflowRunner { data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(workflowId); } + data.restartExecutionId = restartExecutionId; + // Register the active execution const executionId = await this.activeExecutions.add(data, subprocess, restartExecutionId); @@ -741,8 +743,22 @@ export class WorkflowRunner { childExecutionIds.splice(executionIdIndex, 1); } - // eslint-disable-next-line @typescript-eslint/await-thenable - this.activeExecutions.remove(message.data.executionId, message.data.result); + if (message.data.result === undefined) { + const noDataError = new WorkflowOperationError('Workflow finished with no result data'); + const subWorkflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain( + data, + message.data.executionId, + ); + await this.processError( + noDataError, + startedAt, + data.executionMode, + message.data?.executionId, + subWorkflowHooks, + ); + } else { + this.activeExecutions.remove(message.data.executionId, message.data.result); + } } }); diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 32c0d6c93ccc9..3b61ed11fab82 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -117,6 +117,9 @@ class WorkflowRunnerProcess { const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); + // Init db since we need to read the license. + await Db.init(); + const instanceId = userSettings.instanceId ?? ''; await Container.get(PostHogClient).init(instanceId); await Container.get(InternalHooks).init(instanceId); @@ -124,9 +127,6 @@ class WorkflowRunnerProcess { const binaryDataConfig = config.getEnv('binaryDataManager'); await BinaryDataManager.init(binaryDataConfig); - // Init db since we need to read the license. - await Db.init(); - const license = Container.get(License); await license.init(instanceId); @@ -170,6 +170,7 @@ class WorkflowRunnerProcess { undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, ); + additionalData.restartExecutionId = this.data.restartExecutionId; additionalData.hooks = this.getProcessForwardHooks(); additionalData.hooks.hookFunctions.sendResponse = [ diff --git a/packages/cli/src/api/e2e.api.ts b/packages/cli/src/api/e2e.api.ts index befd913114449..73adae880dbf9 100644 --- a/packages/cli/src/api/e2e.api.ts +++ b/packages/cli/src/api/e2e.api.ts @@ -5,19 +5,37 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/naming-convention */ import { Router } from 'express'; +import type { Request } from 'express'; import bodyParser from 'body-parser'; import { v4 as uuid } from 'uuid'; +import { Container } from 'typedi'; import config from '@/config'; import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; +import { RoleRepository } from '@db/repositories'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; +import { License } from '../License'; +import { LICENSE_FEATURES } from '@/constants'; if (process.env.E2E_TESTS !== 'true') { console.error('E2E endpoints only allowed during E2E tests'); process.exit(1); } +const enabledFeatures = { + [LICENSE_FEATURES.SHARING]: true, //default to true here instead of setting it in config/index.ts for e2e + [LICENSE_FEATURES.LDAP]: false, + [LICENSE_FEATURES.SAML]: false, + [LICENSE_FEATURES.LOG_STREAMING]: false, + [LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS]: false, + [LICENSE_FEATURES.VERSION_CONTROL]: false, +}; + +type Feature = keyof typeof enabledFeatures; + +Container.get(License).isFeatureEnabled = (feature: Feature) => enabledFeatures[feature] ?? false; + const tablesToTruncate = [ 'auth_identity', 'auth_provider_sync_history', @@ -39,7 +57,7 @@ const tablesToTruncate = [ ]; const truncateAll = async () => { - const { connection } = Db; + const connection = Db.getConnection(); for (const table of tablesToTruncate) { await connection.query( `DELETE FROM ${table}; DELETE FROM sqlite_sequence WHERE name=${table};`, @@ -48,7 +66,7 @@ const truncateAll = async () => { }; const setupUserManagement = async () => { - const { connection } = Db; + const connection = Db.getConnection(); await connection.query('INSERT INTO role (name, scope) VALUES ("owner", "global");'); const instanceOwnerRole = (await connection.query( 'SELECT last_insert_rowid() as insertId', @@ -78,7 +96,7 @@ const setupUserManagement = async () => { }; const resetLogStreaming = async () => { - config.set('enterprise.features.logStreaming', false); + enabledFeatures[LICENSE_FEATURES.LOG_STREAMING] = false; for (const id in eventBus.destinations) { await eventBus.removeDestination(id); } @@ -100,13 +118,7 @@ e2eController.post('/db/setup-owner', bodyParser.json(), async (req, res) => { return; } - const globalRole = await Db.collections.Role.findOneOrFail({ - select: ['id'], - where: { - name: 'owner', - scope: 'global', - }, - }); + const globalRole = await Container.get(RoleRepository).findGlobalOwnerRoleOrFail(); const owner = await Db.collections.User.findOneByOrFail({ globalRoleId: globalRole.id }); @@ -127,7 +139,8 @@ e2eController.post('/db/setup-owner', bodyParser.json(), async (req, res) => { res.writeHead(204).end(); }); -e2eController.post('/enable-feature/:feature', async (req, res) => { - config.set(`enterprise.features.${req.params.feature}`, true); +e2eController.post('/enable-feature/:feature', async (req: Request<{ feature: Feature }>, res) => { + const { feature } = req.params; + enabledFeatures[feature] = true; res.writeHead(204).end(); }); diff --git a/packages/cli/src/api/workflowStats.api.ts b/packages/cli/src/api/workflowStats.api.ts index b8b23a9803dfe..ed16888efea6d 100644 --- a/packages/cli/src/api/workflowStats.api.ts +++ b/packages/cli/src/api/workflowStats.api.ts @@ -9,7 +9,7 @@ import type { IWorkflowStatisticsDataLoaded, IWorkflowStatisticsTimestamps, } from '@/Interfaces'; -import { StatisticsNames } from '../databases/entities/WorkflowStatistics'; +import { StatisticsNames } from '@db/entities/WorkflowStatistics'; import { getLogger } from '../Logger'; import type { ExecutionRequest } from '../requests'; diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index d50846cf34cc4..c8292b5408d83 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -51,13 +51,13 @@ export abstract class BaseCommand extends Command { const credentialTypes = Container.get(CredentialTypes); CredentialsOverwrites(credentialTypes); - this.instanceId = this.userSettings.instanceId ?? ''; - await Container.get(PostHogClient).init(this.instanceId); - await Container.get(InternalHooks).init(this.instanceId); - await Db.init().catch(async (error: Error) => this.exitWithCrash('There was an error initializing DB', error), ); + + this.instanceId = this.userSettings.instanceId ?? ''; + await Container.get(PostHogClient).init(this.instanceId); + await Container.get(InternalHooks).init(this.instanceId); } protected async stopProcess() { @@ -96,7 +96,7 @@ export abstract class BaseCommand extends Command { if (inTest || this.id === 'start') return; if (Db.isInitialized) { await sleep(100); // give any in-flight query some time to finish - await Db.connection.destroy(); + await Db.getConnection().destroy(); } const exitCode = error instanceof ExitError ? error.oclif.exit : error ? 1 : 0; this.exit(exitCode); diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index 8a441592ad740..f0c60bf20f749 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -144,12 +144,16 @@ export class ExecuteBatch extends BaseCommand { 'econnrefused', 'missing a required parameter', 'insufficient credit balance', + 'internal server error', + '503', + '502', + '504', + 'insufficient balance', 'request timed out', 'status code 401', ]; errorMessage = errorMessage.toLowerCase(); - for (let i = 0; i < warningStrings.length; i++) { if (errorMessage.includes(warningStrings[i])) { return true; diff --git a/packages/cli/src/commands/export/credentials.ts b/packages/cli/src/commands/export/credentials.ts index 110febe25e8d1..7d8734f13aa96 100644 --- a/packages/cli/src/commands/export/credentials.ts +++ b/packages/cli/src/commands/export/credentials.ts @@ -110,7 +110,7 @@ export class ExportCredentialsCommand extends BaseCommand { findQuery.id = flags.id; } - const credentials = await Db.collections.Credentials.findBy(findQuery); + const credentials: ICredentialsDb[] = await Db.collections.Credentials.findBy(findQuery); if (flags.decrypted) { const encryptionKey = await UserSettings.getEncryptionKey(); diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index efed0a972cab5..27af3b12b63da 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -2,6 +2,7 @@ import { flags } from '@oclif/command'; import { Credentials } from 'n8n-core'; import fs from 'fs'; import glob from 'fast-glob'; +import { Container } from 'typedi'; import type { EntityManager } from 'typeorm'; import config from '@/config'; import * as Db from '@/Db'; @@ -9,6 +10,7 @@ import type { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import type { Role } from '@db/entities/Role'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; +import { RoleRepository } from '@db/repositories'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; import { BaseCommand, UM_FIX_INSTRUCTION } from '../BaseCommand'; import type { ICredentialsEncrypted } from 'n8n-workflow'; @@ -146,9 +148,7 @@ export class ImportCredentialsCommand extends BaseCommand { } private async initOwnerCredentialRole() { - const ownerCredentialRole = await Db.collections.Role.findOne({ - where: { name: 'owner', scope: 'credential' }, - }); + const ownerCredentialRole = await Container.get(RoleRepository).findCredentialOwnerRole(); if (!ownerCredentialRole) { throw new Error(`Failed to find owner credential role. ${UM_FIX_INSTRUCTION}`); @@ -169,16 +169,15 @@ export class ImportCredentialsCommand extends BaseCommand { ['credentialsId', 'userId'], ); if (config.getEnv('database.type') === 'postgresdb') { + const tablePrefix = config.getEnv('database.tablePrefix'); await this.transactionManager.query( - "SELECT setval('credentials_entity_id_seq', (SELECT MAX(id) from credentials_entity))", + `SELECT setval('${tablePrefix}credentials_entity_id_seq', (SELECT MAX(id) from ${tablePrefix}credentials_entity))`, ); } } private async getOwner() { - const ownerGlobalRole = await Db.collections.Role.findOne({ - where: { name: 'owner', scope: 'global' }, - }); + const ownerGlobalRole = await Container.get(RoleRepository).findGlobalOwnerRole(); const owner = ownerGlobalRole && diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index df93f7f79fac9..7faa184e5e541 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -3,6 +3,7 @@ import type { INode, INodeCredentialsDetails } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; import fs from 'fs'; import glob from 'fast-glob'; +import { Container } from 'typedi'; import type { EntityManager } from 'typeorm'; import { v4 as uuid } from 'uuid'; import config from '@/config'; @@ -12,8 +13,9 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { setTagsForImport } from '@/TagHelpers'; -import type { ICredentialsDb, IWorkflowToImport } from '@/Interfaces'; +import { RoleRepository } from '@db/repositories'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; +import type { ICredentialsDb, IWorkflowToImport } from '@/Interfaces'; import { replaceInvalidCredentials } from '@/WorkflowHelpers'; import { BaseCommand, UM_FIX_INSTRUCTION } from '../BaseCommand'; @@ -131,6 +133,13 @@ export class ImportWorkflowsCommand extends BaseCommand { await setTagsForImport(transactionManager, workflow, tags); } + if (workflow.active) { + this.logger.info( + `Deactivating workflow "${workflow.name}" during import, remember to activate it later.`, + ); + workflow.active = false; + } + await this.storeWorkflow(workflow, user); } }); @@ -174,6 +183,12 @@ export class ImportWorkflowsCommand extends BaseCommand { if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { await setTagsForImport(transactionManager, workflow, tags); } + if (workflow.active) { + this.logger.info( + `Deactivating workflow "${workflow.name}" during import, remember to activate it later.`, + ); + workflow.active = false; + } await this.storeWorkflow(workflow, user); } @@ -192,9 +207,7 @@ export class ImportWorkflowsCommand extends BaseCommand { } private async initOwnerWorkflowRole() { - const ownerWorkflowRole = await Db.collections.Role.findOne({ - where: { name: 'owner', scope: 'workflow' }, - }); + const ownerWorkflowRole = await Container.get(RoleRepository).findWorkflowOwnerRole(); if (!ownerWorkflowRole) { throw new Error(`Failed to find owner workflow role. ${UM_FIX_INSTRUCTION}`); @@ -215,16 +228,15 @@ export class ImportWorkflowsCommand extends BaseCommand { ['workflowId', 'userId'], ); if (config.getEnv('database.type') === 'postgresdb') { + const tablePrefix = config.getEnv('database.tablePrefix'); await this.transactionManager.query( - "SELECT setval('workflow_entity_id_seq', (SELECT MAX(id) from workflow_entity))", + `SELECT setval('${tablePrefix}workflow_entity_id_seq', (SELECT MAX(id) from "${tablePrefix}workflow_entity"))`, ); } } private async getOwner() { - const ownerGlobalRole = await Db.collections.Role.findOne({ - where: { name: 'owner', scope: 'global' }, - }); + const ownerGlobalRole = await Container.get(RoleRepository).findGlobalOwnerRole(); const owner = ownerGlobalRole && diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts new file mode 100644 index 0000000000000..f4ebd406dc1e9 --- /dev/null +++ b/packages/cli/src/commands/license/info.ts @@ -0,0 +1,22 @@ +import { License } from '@/License'; +import { Container } from 'typedi'; +import { BaseCommand } from '../BaseCommand'; + +export class LicenseInfoCommand extends BaseCommand { + static description = 'Print license information'; + + static examples = ['$ n8n license:info']; + + async run() { + const license = Container.get(License); + await license.init(this.instanceId); + + this.logger.info('Printing license information:\n' + license.getInfo()); + } + + async catch(error: Error) { + this.logger.error('\nGOT ERROR'); + this.logger.info('===================================='); + this.logger.error(error.message); + } +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index db53900de2393..358d8091eb692 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -144,6 +144,7 @@ export class Start extends BaseCommand { private async generateStaticAssets() { // Read the index file and replace the path placeholder const n8nPath = config.getEnv('path'); + const restEndpoint = config.getEnv('endpoints.rest'); const hooksUrls = config.getEnv('externalFrontendHooksUrls'); let scriptsString = ''; @@ -167,6 +168,7 @@ export class Start extends BaseCommand { ]; if (filePath.endsWith('index.html')) { streams.push( + replaceStream('{{REST_ENDPOINT}}', restEndpoint, { ignoreCase: false }), replaceStream(closingTitleTag, closingTitleTag + scriptsString, { ignoreCase: false, }), @@ -187,8 +189,16 @@ export class Start extends BaseCommand { await license.init(this.instanceId); const activationKey = config.getEnv('license.activationKey'); + if (activationKey) { + const hasCert = (await license.loadCertStr()).length > 0; + + if (hasCert) { + return LoggerProxy.debug('Skipping license activation'); + } + try { + LoggerProxy.debug('Attempting license activation'); await license.activate(activationKey); } catch (e) { LoggerProxy.error('Could not activate license', e as Error); diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index 8c3bac1a96dc6..66c8de0117cd5 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -1,7 +1,9 @@ +import { Container } from 'typedi'; import { Not } from 'typeorm'; import * as Db from '@/Db'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { User } from '@db/entities/User'; +import { RoleRepository } from '@db/repositories'; import { BaseCommand } from '../BaseCommand'; const defaultUserProps = { @@ -20,15 +22,8 @@ export class Reset extends BaseCommand { async run(): Promise { const owner = await this.getInstanceOwner(); - const ownerWorkflowRole = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'workflow', - }); - - const ownerCredentialRole = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'credential', - }); + const ownerWorkflowRole = await Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); + const ownerCredentialRole = await Container.get(RoleRepository).findCredentialOwnerRoleOrFail(); await Db.collections.SharedWorkflow.update( { userId: Not(owner.id), roleId: ownerWorkflowRole.id }, @@ -44,10 +39,10 @@ export class Reset extends BaseCommand { await Db.collections.User.save(Object.assign(owner, defaultUserProps)); const danglingCredentials: CredentialsEntity[] = - (await Db.collections.Credentials.createQueryBuilder('credentials') + await Db.collections.Credentials.createQueryBuilder('credentials') .leftJoinAndSelect('credentials.shared', 'shared') .where('shared.credentialsId is null') - .getMany()) as CredentialsEntity[]; + .getMany(); const newSharedCredentials = danglingCredentials.map((credentials) => Db.collections.SharedCredentials.create({ credentials, @@ -70,10 +65,7 @@ export class Reset extends BaseCommand { } async getInstanceOwner(): Promise { - const globalRole = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'global', - }); + const globalRole = await Container.get(RoleRepository).findGlobalOwnerRoleOrFail(); const owner = await Db.collections.User.findOneBy({ globalRoleId: globalRole.id }); diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index f84c73cddb936..6f20ba3fb9e0f 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -16,6 +16,7 @@ if (inE2ETests) { N8N_PUBLIC_API_DISABLED: 'true', EXTERNAL_FRONTEND_HOOKS_URLS: '', N8N_PERSONALIZATION_ENABLED: 'false', + NODE_FUNCTION_ALLOW_EXTERNAL: 'node-fetch', }; } else if (inTest) { process.env.N8N_PUBLIC_API_DISABLED = 'true'; @@ -26,10 +27,6 @@ if (inE2ETests) { const config = convict(schema, { args: [] }); -if (inE2ETests) { - config.set('enterprise.features.sharing', true); -} - // eslint-disable-next-line @typescript-eslint/unbound-method config.getEnv = config.get; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 5d9cb40045740..e570109f11240 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -990,31 +990,6 @@ export const schema = { }, }, - enterprise: { - features: { - sharing: { - format: Boolean, - default: false, - }, - ldap: { - format: Boolean, - default: false, - }, - saml: { - format: Boolean, - default: false, - }, - logStreaming: { - format: Boolean, - default: false, - }, - advancedExecutionFilters: { - format: Boolean, - default: false, - }, - }, - }, - sso: { justInTimeProvisioning: { format: Boolean, @@ -1067,6 +1042,15 @@ export const schema = { }, }, + userActivationSurvey: { + enabled: { + doc: 'Whether user activation survey is enabled.', + format: Boolean, + default: true, + env: 'N8N_USER_ACTIVATION_SURVEY_ENABLED', + }, + }, + diagnostics: { enabled: { doc: 'Whether diagnostic mode is enabled.', @@ -1146,7 +1130,7 @@ export const schema = { format: Boolean, default: true, env: 'N8N_LICENSE_AUTO_RENEW_ENABLED', - doc: 'Whether autorenew for licenses is enabled.', + doc: 'Whether auto renewal for licenses is enabled.', }, autoRenewOffset: { format: Number, @@ -1166,6 +1150,12 @@ export const schema = { env: 'N8N_LICENSE_TENANT_ID', doc: 'Tenant id used by the license manager', }, + cert: { + format: String, + default: '', + env: 'N8N_LICENSE_CERT', + doc: 'Ephemeral license certificate', + }, }, hideUsagePage: { diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 82f87dc13c048..6d6ebab38589e 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -68,12 +68,19 @@ export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day export const SETTINGS_LICENSE_CERT_KEY = 'license.cert'; -export enum LICENSE_FEATURES { +export const enum LICENSE_FEATURES { SHARING = 'feat:sharing', LDAP = 'feat:ldap', SAML = 'feat:saml', LOG_STREAMING = 'feat:logStreaming', ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', + VARIABLES = 'feat:variables', + VERSION_CONTROL = 'feat:versionControl', +} + +export const enum LICENSE_QUOTAS { + TRIGGER_LIMIT = 'quota:activeWorkflows', + VARIABLES_LIMIT = 'quota:maxVariables', } export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 7a6405de36479..7aeb7e1cfe0cb 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -1,5 +1,5 @@ import validator from 'validator'; -import { Get, Post, RestController } from '@/decorators'; +import { Authorized, Get, Post, RestController } from '@/decorators'; import { AuthError, BadRequestError, InternalServerError } from '@/ResponseHelper'; import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper'; import { issueCookie, resolveJwt } from '@/auth/jwt'; @@ -8,7 +8,6 @@ import { Request, Response } from 'express'; import type { ILogger } from 'n8n-workflow'; import type { User } from '@db/entities/User'; import { LoginRequest, UserRequest } from '@/requests'; -import type { Repository } from 'typeorm'; import { In } from 'typeorm'; import type { Config } from '@/config'; import type { @@ -23,6 +22,7 @@ import { isLdapCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, } from '@/sso/ssoHelpers'; +import type { UserRepository } from '@db/repositories'; @RestController() export class AuthController { @@ -32,7 +32,7 @@ export class AuthController { private readonly internalHooks: IInternalHooksClass; - private readonly userRepository: Repository; + private readonly userRepository: UserRepository; private readonly postHog?: PostHogClient; @@ -58,7 +58,6 @@ export class AuthController { /** * Log in a user. - * Authless endpoint. */ @Post('/login') async login(req: LoginRequest, res: Response): Promise { @@ -135,7 +134,6 @@ export class AuthController { /** * Validate invite token to enable invitee to set up their account. - * Authless endpoint. */ @Get('/resolve-signup-token') async resolveSignupToken(req: UserRequest.ResolveSignUp) { @@ -196,8 +194,8 @@ export class AuthController { /** * Log out a user. - * Authless endpoint. */ + @Authorized() @Post('/logout') logout(req: Request, res: Response) { res.clearCookie(AUTH_COOKIE_NAME); diff --git a/packages/cli/src/controllers/ldap.controller.ts b/packages/cli/src/controllers/ldap.controller.ts index 619a70db286b2..b354ef660021c 100644 --- a/packages/cli/src/controllers/ldap.controller.ts +++ b/packages/cli/src/controllers/ldap.controller.ts @@ -1,5 +1,5 @@ import pick from 'lodash.pick'; -import { Get, Post, Put, RestController } from '@/decorators'; +import { Authorized, Get, Post, Put, RestController } from '@/decorators'; import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers'; import { LdapService } from '@/Ldap/LdapService.ee'; import { LdapSync } from '@/Ldap/LdapSync.ee'; @@ -8,6 +8,7 @@ import { BadRequestError } from '@/ResponseHelper'; import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '@/Ldap/constants'; import { InternalHooks } from '@/InternalHooks'; +@Authorized(['global', 'owner']) @RestController('/ldap') export class LdapController { constructor( diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index e56c820155a5d..0ff4cd78f0a5c 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -1,6 +1,6 @@ import validator from 'validator'; import { plainToInstance } from 'class-transformer'; -import { Delete, Get, Patch, Post, RestController } from '@/decorators'; +import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators'; import { compareHash, hashPassword, @@ -8,13 +8,18 @@ import { validatePassword, } from '@/UserManagement/UserManagementHelper'; import { BadRequestError } from '@/ResponseHelper'; -import type { User } from '@db/entities/User'; import { validateEntity } from '@/GenericHelpers'; import { issueCookie } from '@/auth/jwt'; +import type { User } from '@db/entities/User'; +import type { UserRepository } from '@db/repositories'; import { Response } from 'express'; -import type { Repository } from 'typeorm'; import type { ILogger } from 'n8n-workflow'; -import { AuthenticatedRequest, MeRequest, UserUpdatePayload } from '@/requests'; +import { + AuthenticatedRequest, + MeRequest, + UserSettingsUpdatePayload, + UserUpdatePayload, +} from '@/requests'; import type { PublicUser, IDatabaseCollections, @@ -23,7 +28,9 @@ import type { } from '@/Interfaces'; import { randomBytes } from 'crypto'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; +import { UserService } from '@/user/user.service'; +@Authorized() @RestController('/me') export class MeController { private readonly logger: ILogger; @@ -32,7 +39,7 @@ export class MeController { private readonly internalHooks: IInternalHooksClass; - private readonly userRepository: Repository; + private readonly userRepository: UserRepository; constructor({ logger, @@ -52,7 +59,7 @@ export class MeController { } /** - * Update the logged-in user's settings, except password. + * Update the logged-in user's properties, except password. */ @Patch('/') async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise { @@ -234,4 +241,22 @@ export class MeController { return { success: true }; } + + /** + * Update the logged-in user's settings. + */ + @Patch('/settings') + async updateCurrentUserSettings(req: MeRequest.UserSettingsUpdate): Promise { + const payload = plainToInstance(UserSettingsUpdatePayload, req.body); + const { id } = req.user; + + await UserService.updateUserSettings(id, payload); + + const user = await this.userRepository.findOneOrFail({ + select: ['settings'], + where: { id }, + }); + + return user.settings; + } } diff --git a/packages/cli/src/controllers/nodeTypes.controller.ts b/packages/cli/src/controllers/nodeTypes.controller.ts index 5f8af5a909f22..63002c7c122a9 100644 --- a/packages/cli/src/controllers/nodeTypes.controller.ts +++ b/packages/cli/src/controllers/nodeTypes.controller.ts @@ -2,11 +2,12 @@ import { readFile } from 'fs/promises'; import get from 'lodash.get'; import { Request } from 'express'; import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow'; -import { Post, RestController } from '@/decorators'; +import { Authorized, Post, RestController } from '@/decorators'; import { getNodeTranslationPath } from '@/TranslationHelpers'; import type { Config } from '@/config'; import type { NodeTypes } from '@/NodeTypes'; +@Authorized() @RestController('/node-types') export class NodeTypesController { private readonly config: Config; diff --git a/packages/cli/src/controllers/nodes.controller.ts b/packages/cli/src/controllers/nodes.controller.ts index e82bd3e5895b8..9d872c8693d56 100644 --- a/packages/cli/src/controllers/nodes.controller.ts +++ b/packages/cli/src/controllers/nodes.controller.ts @@ -4,7 +4,7 @@ import { STARTER_TEMPLATE_NAME, UNKNOWN_FAILURE_REASON, } from '@/constants'; -import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; +import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; import { NodeRequest } from '@/requests'; import { BadRequestError, InternalServerError } from '@/ResponseHelper'; import { @@ -30,10 +30,10 @@ import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { InternalHooks } from '@/InternalHooks'; import { Push } from '@/push'; import { Config } from '@/config'; -import { isAuthenticatedRequest } from '@/UserManagement/UserManagementHelper'; const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES; +@Authorized(['global', 'owner']) @RestController('/nodes') export class NodesController { constructor( @@ -43,14 +43,6 @@ export class NodesController { private internalHooks: InternalHooks, ) {} - // TODO: move this into a new decorator `@Authorized` - @Middleware() - checkIfOwner(req: Request, res: Response, next: NextFunction) { - if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') - res.status(403).json({ status: 'error', message: 'Unauthorized' }); - else next(); - } - // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` @Middleware() checkIfCommunityNodesEnabled(req: Request, res: Response, next: NextFunction) { diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 8040c5aaac531..ae8745a9afab7 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -1,6 +1,6 @@ import validator from 'validator'; import { validateEntity } from '@/GenericHelpers'; -import { Get, Post, RestController } from '@/decorators'; +import { Authorized, Get, Post, RestController } from '@/decorators'; import { BadRequestError } from '@/ResponseHelper'; import { hashPassword, @@ -9,15 +9,18 @@ import { } from '@/UserManagement/UserManagementHelper'; import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; -import type { Repository } from 'typeorm'; import type { ILogger } from 'n8n-workflow'; import type { Config } from '@/config'; import { OwnerRequest } from '@/requests'; -import type { IDatabaseCollections, IInternalHooksClass, ICredentialsDb } from '@/Interfaces'; -import type { Settings } from '@db/entities/Settings'; -import type { User } from '@db/entities/User'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; - +import type { IDatabaseCollections, IInternalHooksClass } from '@/Interfaces'; +import type { + CredentialsRepository, + SettingsRepository, + UserRepository, + WorkflowRepository, +} from '@db/repositories'; + +@Authorized(['global', 'owner']) @RestController('/owner') export class OwnerController { private readonly config: Config; @@ -26,13 +29,13 @@ export class OwnerController { private readonly internalHooks: IInternalHooksClass; - private readonly userRepository: Repository; + private readonly userRepository: UserRepository; - private readonly settingsRepository: Repository; + private readonly settingsRepository: SettingsRepository; - private readonly credentialsRepository: Repository; + private readonly credentialsRepository: CredentialsRepository; - private readonly workflowsRepository: Repository; + private readonly workflowsRepository: WorkflowRepository; constructor({ config, diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 4b0ec6c9dfcaa..64e46582066bd 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -1,4 +1,3 @@ -import type { Repository } from 'typeorm'; import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; import { v4 as uuid } from 'uuid'; import validator from 'validator'; @@ -7,6 +6,7 @@ import { BadRequestError, InternalServerError, NotFoundError, + UnauthorizedError, UnprocessableRequestError, } from '@/ResponseHelper'; import { @@ -19,11 +19,12 @@ import type { UserManagementMailer } from '@/UserManagement/email'; import { Response } from 'express'; import type { ILogger } from 'n8n-workflow'; import type { Config } from '@/config'; -import type { User } from '@db/entities/User'; +import type { UserRepository } from '@db/repositories'; import { PasswordResetRequest } from '@/requests'; import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import { issueCookie } from '@/auth/jwt'; import { isLdapEnabled } from '@/Ldap/helpers'; +import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; @RestController() export class PasswordResetController { @@ -37,7 +38,7 @@ export class PasswordResetController { private readonly mailer: UserManagementMailer; - private readonly userRepository: Repository; + private readonly userRepository: UserRepository; constructor({ config, @@ -64,7 +65,6 @@ export class PasswordResetController { /** * Send a password reset email. - * Authless endpoint. */ @Post('/forgot-password') async forgotPassword(req: PasswordResetRequest.Email) { @@ -100,9 +100,18 @@ export class PasswordResetController { email, password: Not(IsNull()), }, - relations: ['authIdentities'], + relations: ['authIdentities', 'globalRole'], }); + if (isSamlCurrentAuthenticationMethod() && user?.globalRole.name !== 'owner') { + this.logger.debug( + 'Request to send password reset email failed because login is handled by SAML', + ); + throw new UnauthorizedError( + 'Login is handled by SAML. Please contact your Identity Provider to reset your password.', + ); + } + const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); if (!user?.password || (ldapIdentity && user.disabled)) { @@ -161,7 +170,6 @@ export class PasswordResetController { /** * Verify password reset token and user ID. - * Authless endpoint. */ @Get('/resolve-password-token') async resolvePasswordToken(req: PasswordResetRequest.Credentials) { @@ -203,7 +211,6 @@ export class PasswordResetController { /** * Verify password reset token and user ID and update password. - * Authless endpoint. */ @Post('/change-password') async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { @@ -244,8 +251,10 @@ export class PasswordResetController { throw new NotFoundError(''); } + const passwordHash = await hashPassword(validPassword); + await this.userRepository.update(userId, { - password: await hashPassword(validPassword), + password: passwordHash, resetPasswordToken: null, resetPasswordTokenExpiration: null, }); @@ -268,6 +277,6 @@ export class PasswordResetController { }); } - await this.externalHooks.run('user.password.update', [user.email, password]); + await this.externalHooks.run('user.password.update', [user.email, passwordHash]); } } diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts index 3c73235c853b3..7b68d95aafb06 100644 --- a/packages/cli/src/controllers/tags.controller.ts +++ b/packages/cli/src/controllers/tags.controller.ts @@ -1,21 +1,21 @@ import { Request, Response, NextFunction } from 'express'; -import type { Repository } from 'typeorm'; import type { Config } from '@/config'; -import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; +import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; import type { IDatabaseCollections, IExternalHooksClass, ITagWithCountDb } from '@/Interfaces'; import { TagEntity } from '@db/entities/TagEntity'; -import { getTagsWithCountDb } from '@/TagHelpers'; +import type { TagRepository } from '@db/repositories'; import { validateEntity } from '@/GenericHelpers'; -import { BadRequestError, UnauthorizedError } from '@/ResponseHelper'; +import { BadRequestError } from '@/ResponseHelper'; import { TagsRequest } from '@/requests'; +@Authorized() @RestController('/tags') export class TagsController { private config: Config; private externalHooks: IExternalHooksClass; - private tagsRepository: Repository; + private tagsRepository: TagRepository; constructor({ config, @@ -44,8 +44,17 @@ export class TagsController { async getAll(req: TagsRequest.GetAll): Promise { const { withUsageCount } = req.query; if (withUsageCount === 'true') { - const tablePrefix = this.config.getEnv('database.tablePrefix'); - return getTagsWithCountDb(tablePrefix); + return this.tagsRepository + .find({ + select: ['id', 'name', 'createdAt', 'updatedAt'], + relations: ['workflowMappings'], + }) + .then((tags) => + tags.map(({ workflowMappings, ...rest }) => ({ + ...rest, + usageCount: workflowMappings.length, + })), + ); } return this.tagsRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] }); @@ -83,15 +92,9 @@ export class TagsController { return tag; } + @Authorized(['global', 'owner']) @Delete('/:id(\\d+)') async deleteTag(req: TagsRequest.Delete) { - const isInstanceOwnerSetUp = this.config.getEnv('userManagement.isInstanceOwnerSetUp'); - if (isInstanceOwnerSetUp && req.user.globalRole.name !== 'owner') { - throw new UnauthorizedError( - 'You are not allowed to perform this action', - 'Only owners can remove tags', - ); - } const { id } = req.params; await this.externalHooks.run('tag.beforeDelete', [id]); diff --git a/packages/cli/src/controllers/translation.controller.ts b/packages/cli/src/controllers/translation.controller.ts index 8240a376a7e64..6d36d650d2e2c 100644 --- a/packages/cli/src/controllers/translation.controller.ts +++ b/packages/cli/src/controllers/translation.controller.ts @@ -2,7 +2,7 @@ import type { Request } from 'express'; import { ICredentialTypes } from 'n8n-workflow'; import { join } from 'path'; import { access } from 'fs/promises'; -import { Get, RestController } from '@/decorators'; +import { Authorized, Get, RestController } from '@/decorators'; import { BadRequestError, InternalServerError } from '@/ResponseHelper'; import { Config } from '@/config'; import { NODES_BASE_DIR } from '@/constants'; @@ -14,6 +14,7 @@ export declare namespace TranslationRequest { export type Credential = Request<{}, {}, {}, { credentialType: string }>; } +@Authorized() @RestController('/') export class TranslationController { constructor(private config: Config, private credentialTypes: ICredentialTypes) {} diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index bb094f9ef1841..c2ae07d344d1d 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -1,12 +1,11 @@ import validator from 'validator'; -import type { Repository } from 'typeorm'; import { In } from 'typeorm'; import type { ILogger } from 'n8n-workflow'; import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import { Delete, Get, Post, RestController } from '@/decorators'; +import { Authorized, NoAuthRequired, Delete, Get, Post, RestController } from '@/decorators'; import { addInviteLinkToUser, generateUserInviteUrl, @@ -23,7 +22,6 @@ import { Response } from 'express'; import type { Config } from '@/config'; import { UserRequest } from '@/requests'; import type { UserManagementMailer } from '@/UserManagement/email'; -import type { Role } from '@db/entities/Role'; import type { PublicUser, IDatabaseCollections, @@ -36,7 +34,14 @@ import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { PostHogClient } from '@/posthog'; import { userManagementEnabledMiddleware } from '../middlewares/userManagementEnabled'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; +import type { + RoleRepository, + SharedCredentialsRepository, + SharedWorkflowRepository, + UserRepository, +} from '@db/repositories'; +@Authorized(['global', 'owner']) @RestController('/users') export class UsersController { private config: Config; @@ -47,13 +52,13 @@ export class UsersController { private internalHooks: IInternalHooksClass; - private userRepository: Repository; + private userRepository: UserRepository; - private roleRepository: Repository; + private roleRepository: RoleRepository; - private sharedCredentialsRepository: Repository; + private sharedCredentialsRepository: SharedCredentialsRepository; - private sharedWorkflowRepository: Repository; + private sharedWorkflowRepository: SharedWorkflowRepository; private activeWorkflowRunner: ActiveWorkflowRunner; @@ -147,7 +152,7 @@ export class UsersController { createUsers[invite.email.toLowerCase()] = null; }); - const role = await this.roleRepository.findOneBy({ scope: 'global', name: 'member' }); + const role = await this.roleRepository.findGlobalMemberRole(); if (!role) { this.logger.error( @@ -278,6 +283,7 @@ export class UsersController { /** * Fill out user shell with first name, last name, and password. */ + @NoAuthRequired() @Post('/:id') async updateUser(req: UserRequest.Update, res: Response) { const { id: inviteeId } = req.params; @@ -339,6 +345,7 @@ export class UsersController { return withFeatureFlags(this.postHog, sanitizeUser(updatedUser)); } + @Authorized('any') @Get('/') async listUsers(req: UserRequest.List) { const users = await this.userRepository.find({ relations: ['globalRole', 'authIdentities'] }); @@ -396,8 +403,8 @@ export class UsersController { } const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([ - this.roleRepository.findOneBy({ name: 'owner', scope: 'workflow' }), - this.roleRepository.findOneBy({ name: 'owner', scope: 'credential' }), + this.roleRepository.findWorkflowOwnerRole(), + this.roleRepository.findCredentialOwnerRole(), ]); if (transferId) { diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 8d5d99455af1f..2eb0f559613c1 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -8,6 +8,7 @@ import type { INodeProperties, } from 'n8n-workflow'; import { deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow'; +import { Container } from 'typedi'; import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; @@ -20,11 +21,10 @@ import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; - import type { User } from '@db/entities/User'; +import { RoleRepository } from '@db/repositories'; import type { CredentialRequest } from '@/requests'; import { CredentialTypes } from '@/CredentialTypes'; -import { Container } from 'typedi'; export class CredentialsService { static async get( @@ -116,9 +116,7 @@ export class CredentialsService { // This saves us a merge but requires some type casting. These // types are compatible for this case. - const newCredentials = Db.collections.Credentials.create( - rest as ICredentialsDb, - ) as CredentialsEntity; + const newCredentials = Db.collections.Credentials.create(rest as ICredentialsDb); await validateEntity(newCredentials); @@ -140,10 +138,8 @@ export class CredentialsService { } // This saves us a merge but requires some type casting. These - // types are compatiable for this case. - const updateData = Db.collections.Credentials.create( - mergedData as ICredentialsDb, - ) as CredentialsEntity; + // types are compatible for this case. + const updateData = Db.collections.Credentials.create(mergedData as ICredentialsDb); await validateEntity(updateData); @@ -227,10 +223,7 @@ export class CredentialsService { await Container.get(ExternalHooks).run('credentials.create', [encryptedData]); - const role = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'credential', - }); + const role = await Container.get(RoleRepository).findCredentialOwnerRoleOrFail(); const result = await Db.transaction(async (transactionManager) => { const savedCredential = await transactionManager.save(newCredential); diff --git a/packages/cli/src/databases/entities/MessageEventBusDestinationEntity.ts b/packages/cli/src/databases/entities/EventDestinations.ts similarity index 100% rename from packages/cli/src/databases/entities/MessageEventBusDestinationEntity.ts rename to packages/cli/src/databases/entities/EventDestinations.ts diff --git a/packages/cli/src/databases/entities/ExecutionEntity.ts b/packages/cli/src/databases/entities/ExecutionEntity.ts index a29ef9da0e991..c16365bbcf638 100644 --- a/packages/cli/src/databases/entities/ExecutionEntity.ts +++ b/packages/cli/src/databases/entities/ExecutionEntity.ts @@ -49,7 +49,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { workflowId: string; @Column({ type: datetimeColumnType, nullable: true }) - waitTill: Date; + waitTill: Date | null; @OneToMany('ExecutionMetadata', 'execution') metadata: ExecutionMetadata[]; diff --git a/packages/cli/src/databases/entities/TagEntity.ts b/packages/cli/src/databases/entities/TagEntity.ts index a2e84a41a0ef2..de25ba482bd81 100644 --- a/packages/cli/src/databases/entities/TagEntity.ts +++ b/packages/cli/src/databases/entities/TagEntity.ts @@ -1,8 +1,9 @@ -import { Column, Entity, Generated, Index, ManyToMany, PrimaryColumn } from 'typeorm'; +import { Column, Entity, Generated, Index, ManyToMany, OneToMany, PrimaryColumn } from 'typeorm'; import { IsString, Length } from 'class-validator'; import { idStringifier } from '../utils/transformers'; import type { WorkflowEntity } from './WorkflowEntity'; +import type { WorkflowTagMapping } from './WorkflowTagMapping'; import { AbstractEntity } from './AbstractEntity'; @Entity() @@ -19,4 +20,7 @@ export class TagEntity extends AbstractEntity { @ManyToMany('WorkflowEntity', 'tags') workflows: WorkflowEntity[]; + + @OneToMany('WorkflowTagMapping', 'tags') + workflowMappings: WorkflowTagMapping[]; } diff --git a/packages/cli/src/databases/entities/Variables.ts b/packages/cli/src/databases/entities/Variables.ts new file mode 100644 index 0000000000000..64eef5a9fc8f9 --- /dev/null +++ b/packages/cli/src/databases/entities/Variables.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class Variables { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + key: string; + + @Column('text', { default: 'string' }) + type: string; + + @Column('text') + value: string; +} diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts index 84bb5fc2ffd40..bee34796f124f 100644 --- a/packages/cli/src/databases/entities/WorkflowEntity.ts +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -19,6 +19,7 @@ import config from '@/config'; import type { TagEntity } from './TagEntity'; import type { SharedWorkflow } from './SharedWorkflow'; import type { WorkflowStatistics } from './WorkflowStatistics'; +import type { WorkflowTagMapping } from './WorkflowTagMapping'; import { idStringifier, objectRetriever, sqlite } from '../utils/transformers'; import { AbstractEntity, jsonColumnType } from './AbstractEntity'; import type { IWorkflowDb } from '@/Interfaces'; @@ -73,6 +74,9 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb { }) tags?: TagEntity[]; + @OneToMany('WorkflowTagMapping', 'workflows') + tagMappings: WorkflowTagMapping[]; + @OneToMany('SharedWorkflow', 'workflow') shared: SharedWorkflow[]; diff --git a/packages/cli/src/databases/entities/WorkflowStatistics.ts b/packages/cli/src/databases/entities/WorkflowStatistics.ts index 1d3de6316d832..5181bb257c54a 100644 --- a/packages/cli/src/databases/entities/WorkflowStatistics.ts +++ b/packages/cli/src/databases/entities/WorkflowStatistics.ts @@ -3,7 +3,7 @@ import { idStringifier } from '../utils/transformers'; import { datetimeColumnType } from './AbstractEntity'; import { WorkflowEntity } from './WorkflowEntity'; -export enum StatisticsNames { +export const enum StatisticsNames { productionSuccess = 'production_success', productionError = 'production_error', manualSuccess = 'manual_success', diff --git a/packages/cli/src/databases/entities/WorkflowTagMapping.ts b/packages/cli/src/databases/entities/WorkflowTagMapping.ts new file mode 100644 index 0000000000000..88b92b0b194bc --- /dev/null +++ b/packages/cli/src/databases/entities/WorkflowTagMapping.ts @@ -0,0 +1,21 @@ +import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { idStringifier } from '../utils/transformers'; +import type { TagEntity } from './TagEntity'; +import type { WorkflowEntity } from './WorkflowEntity'; + +@Entity({ name: 'workflows_tags' }) +export class WorkflowTagMapping { + @PrimaryColumn({ transformer: idStringifier }) + workflowId: string; + + @ManyToOne('WorkflowEntity', 'tagMappings') + @JoinColumn({ name: 'workflowId' }) + workflows: WorkflowEntity[]; + + @PrimaryColumn() + tagId: string; + + @ManyToOne('TagEntity', 'workflowMappings') + @JoinColumn({ name: 'tagId' }) + tags: TagEntity[]; +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 55ba7b865183e..8e2fdd75872ca 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -2,7 +2,7 @@ import { AuthIdentity } from './AuthIdentity'; import { AuthProviderSyncHistory } from './AuthProviderSyncHistory'; import { CredentialsEntity } from './CredentialsEntity'; -import { EventDestinations } from './MessageEventBusDestinationEntity'; +import { EventDestinations } from './EventDestinations'; import { ExecutionEntity } from './ExecutionEntity'; import { InstalledNodes } from './InstalledNodes'; import { InstalledPackages } from './InstalledPackages'; @@ -12,8 +12,10 @@ import { SharedCredentials } from './SharedCredentials'; import { SharedWorkflow } from './SharedWorkflow'; import { TagEntity } from './TagEntity'; import { User } from './User'; +import { Variables } from './Variables'; import { WebhookEntity } from './WebhookEntity'; import { WorkflowEntity } from './WorkflowEntity'; +import { WorkflowTagMapping } from './WorkflowTagMapping'; import { WorkflowStatistics } from './WorkflowStatistics'; import { ExecutionMetadata } from './ExecutionMetadata'; @@ -31,8 +33,10 @@ export const entities = { SharedWorkflow, TagEntity, User, + Variables, WebhookEntity, WorkflowEntity, + WorkflowTagMapping, WorkflowStatistics, ExecutionMetadata, }; diff --git a/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts new file mode 100644 index 0000000000000..1e35fe5574518 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636753 implements MigrationInterface { + name = 'CreateVariables1677501636753'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id int(11) auto_increment NOT NULL PRIMARY KEY, + \`key\` VARCHAR(50) NOT NULL, + \`type\` VARCHAR(50) DEFAULT 'string' NOT NULL, + value VARCHAR(255) NULL, + UNIQUE (\`key\`) + ) + ENGINE=InnoDB; + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/1681134145996-AddUserActivatedProperty.ts b/packages/cli/src/databases/migrations/mysqldb/1681134145996-AddUserActivatedProperty.ts new file mode 100644 index 0000000000000..e1c193a959935 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1681134145996-AddUserActivatedProperty.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; +import type { UserSettings } from '@/Interfaces'; + +export class AddUserActivatedProperty1681134145996 implements MigrationInterface { + name = 'AddUserActivatedProperty1681134145996'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = getTablePrefix(); + + const activatedUsers: UserSettings[] = await queryRunner.query( + `SELECT DISTINCT sw.userId AS id, + JSON_SET(COALESCE(u.settings, '{}'), '$.userActivated', true) AS settings + FROM ${tablePrefix}workflow_statistics AS ws + JOIN ${tablePrefix}shared_workflow as sw + ON ws.workflowId = sw.workflowId + JOIN ${tablePrefix}role AS r + ON r.id = sw.roleId + JOIN ${tablePrefix}user AS u + ON u.id = sw.userId + WHERE ws.name = 'production_success' + AND r.name = 'owner' + AND r.scope = 'workflow'`, + ); + + const updatedUsers = activatedUsers.map((user) => + queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = '${JSON.stringify(user.settings)}' WHERE id = '${ + user.id + }' `, + ), + ); + + await Promise.all(updatedUsers); + + if (!activatedUsers.length) { + await queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivated', false)`, + ); + } else { + const activatedUserIds = activatedUsers.map((user) => `'${user.id}'`).join(','); + + await queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivated', false) WHERE id NOT IN (${activatedUserIds})`, + ); + } + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + await queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = JSON_REMOVE(settings, '$.userActivated')`, + ); + await queryRunner.query(`UPDATE ${tablePrefix}user SET settings = NULL WHERE settings = '{}'`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index bb021d495b800..3f53f8b24af55 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -35,6 +35,8 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable'; +import { CreateVariables1677501636753 } from './1677501636753-CreateVariables'; +import { AddUserActivatedProperty1681134145996 } from './1681134145996-AddUserActivatedProperty'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -74,4 +76,6 @@ export const mysqlMigrations = [ MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236788851, CreateExecutionMetadataTable1679416281779, + CreateVariables1677501636753, + AddUserActivatedProperty1681134145996, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts new file mode 100644 index 0000000000000..59842d73b1c8d --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636754 implements MigrationInterface { + name = 'CreateVariables1677501636754'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id serial4 NOT NULL PRIMARY KEY, + "key" varchar(50) NOT NULL, + "type" varchar(50) NOT NULL DEFAULT 'string', + value varchar(255) NULL, + UNIQUE ("key") + ); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts b/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts new file mode 100644 index 0000000000000..bf4293d4ddaae --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1681134145996-AddUserActivatedProperty.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; +import type { UserSettings } from '@/Interfaces'; + +export class AddUserActivatedProperty1681134145996 implements MigrationInterface { + name = 'AddUserActivatedProperty1681134145996'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = getTablePrefix(); + + const activatedUsers: UserSettings[] = await queryRunner.query( + `SELECT DISTINCT sw."userId" AS id, + JSONB_SET(COALESCE(u.settings::jsonb, '{}'), '{userActivated}', 'true', true) as settings + FROM ${tablePrefix}workflow_statistics ws + JOIN ${tablePrefix}shared_workflow sw + ON ws."workflowId" = sw."workflowId" + JOIN ${tablePrefix}role r + ON r.id = sw."roleId" + JOIN "${tablePrefix}user" u + ON u.id = sw."userId" + WHERE ws.name = 'production_success' + AND r.name = 'owner' + AND r.scope = 'workflow'`, + ); + + const updatedUsers = activatedUsers.map((user) => + queryRunner.query( + `UPDATE "${tablePrefix}user" SET settings = '${JSON.stringify( + user.settings, + )}' WHERE id = '${user.id}' `, + ), + ); + + await Promise.all(updatedUsers); + + if (!activatedUsers.length) { + await queryRunner.query( + `UPDATE "${tablePrefix}user" SET settings = JSONB_SET(COALESCE(settings::jsonb, '{}'), '{userActivated}', 'false', true)`, + ); + } else { + const activatedUserIds = activatedUsers.map((user) => `'${user.id}'`).join(','); + + await queryRunner.query( + `UPDATE "${tablePrefix}user" SET settings = JSONB_SET(COALESCE(settings::jsonb, '{}'), '{userActivated}', 'false', true) WHERE id NOT IN (${activatedUserIds})`, + ); + } + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + await queryRunner.query( + `UPDATE "${tablePrefix}user" SET settings = settings::jsonb - 'userActivated'`, + ); + await queryRunner.query( + `UPDATE "${tablePrefix}user" SET settings = NULL WHERE settings::jsonb = '{}'::jsonb`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 0c0c33ca38624..231518df99249 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -33,6 +33,8 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable'; +import { CreateVariables1677501636754 } from './1677501636754-CreateVariables'; +import { AddUserActivatedProperty1681134145996 } from './1681134145996-AddUserActivatedProperty'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -70,4 +72,6 @@ export const postgresMigrations = [ MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236854063, CreateExecutionMetadataTable1679416281778, + CreateVariables1677501636754, + AddUserActivatedProperty1681134145996, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts new file mode 100644 index 0000000000000..da6265defa9e1 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636752 implements MigrationInterface { + name = 'CreateVariables1677501636752'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT ('string'), + value TEXT, + UNIQUE("key") + ); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts b/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts new file mode 100644 index 0000000000000..335c4b29a8e0f --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1681134145996-AddUserActivatedProperty.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; +import type { UserSettings } from '@/Interfaces'; + +export class AddUserActivatedProperty1681134145996 implements MigrationInterface { + name = 'AddUserActivatedProperty1681134145996'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = getTablePrefix(); + + const activatedUsers: UserSettings[] = await queryRunner.query( + `SELECT DISTINCT sw.userId AS id, + JSON_SET(COALESCE(u.settings, '{}'), '$.userActivated', JSON('true')) AS settings + FROM ${tablePrefix}workflow_statistics AS ws + JOIN ${tablePrefix}shared_workflow AS sw + ON ws.workflowId = sw.workflowId + JOIN ${tablePrefix}role AS r + ON r.id = sw.roleId + JOIN ${tablePrefix}user AS u + ON u.id = sw.userId + WHERE ws.name = 'production_success' + AND r.name = 'owner' + AND r.scope = "workflow"`, + ); + + const updatedUsers = activatedUsers.map((user) => + queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = '${user.settings}' WHERE id = '${user.id}' `, + ), + ); + + await Promise.all(updatedUsers); + + if (!activatedUsers.length) { + await queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivated', JSON('false'))`, + ); + } else { + const activatedUserIds = activatedUsers.map((user) => `'${user.id}'`).join(','); + await queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivated', JSON('false')) WHERE id NOT IN (${activatedUserIds})`, + ); + } + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + await queryRunner.query( + `UPDATE ${tablePrefix}user SET settings = JSON_REMOVE(settings, '$.userActivated')`, + ); + await queryRunner.query(`UPDATE ${tablePrefix}user SET settings = NULL WHERE settings = '{}'`); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 2d1d3e6709201..e78aad2bc69ac 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -32,6 +32,8 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable'; +import { CreateVariables1677501636752 } from './1677501636752-CreateVariables'; +import { AddUserActivatedProperty1681134145996 } from './1681134145996-AddUserActivatedProperty'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -67,7 +69,9 @@ const sqliteMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677237073720, + CreateVariables1677501636752, CreateExecutionMetadataTable1679416281777, + AddUserActivatedProperty1681134145996, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/authIdentity.repository.ts b/packages/cli/src/databases/repositories/authIdentity.repository.ts new file mode 100644 index 0000000000000..6ec5fe2310b85 --- /dev/null +++ b/packages/cli/src/databases/repositories/authIdentity.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { AuthIdentity } from '../entities/AuthIdentity'; + +@Service() +export class AuthIdentityRepository extends Repository { + constructor(dataSource: DataSource) { + super(AuthIdentity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/authProviderSyncHistory.repository.ts b/packages/cli/src/databases/repositories/authProviderSyncHistory.repository.ts new file mode 100644 index 0000000000000..092f4273ee00e --- /dev/null +++ b/packages/cli/src/databases/repositories/authProviderSyncHistory.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { AuthProviderSyncHistory } from '../entities/AuthProviderSyncHistory'; + +@Service() +export class AuthProviderSyncHistoryRepository extends Repository { + constructor(dataSource: DataSource) { + super(AuthProviderSyncHistory, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts new file mode 100644 index 0000000000000..43bc18dfb128b --- /dev/null +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { CredentialsEntity } from '../entities/CredentialsEntity'; + +@Service() +export class CredentialsRepository extends Repository { + constructor(dataSource: DataSource) { + super(CredentialsEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/eventDestinations.repository.ts b/packages/cli/src/databases/repositories/eventDestinations.repository.ts new file mode 100644 index 0000000000000..627a9638f18e5 --- /dev/null +++ b/packages/cli/src/databases/repositories/eventDestinations.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { EventDestinations } from '../entities/EventDestinations'; + +@Service() +export class EventDestinationsRepository extends Repository { + constructor(dataSource: DataSource) { + super(EventDestinations, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts new file mode 100644 index 0000000000000..bc332ed96609e --- /dev/null +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { ExecutionEntity } from '../entities/ExecutionEntity'; + +@Service() +export class ExecutionRepository extends Repository { + constructor(dataSource: DataSource) { + super(ExecutionEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/executionMetadata.repository.ts b/packages/cli/src/databases/repositories/executionMetadata.repository.ts new file mode 100644 index 0000000000000..917ce755c81c0 --- /dev/null +++ b/packages/cli/src/databases/repositories/executionMetadata.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { ExecutionMetadata } from '../entities/ExecutionMetadata'; + +@Service() +export class ExecutionMetadataRepository extends Repository { + constructor(dataSource: DataSource) { + super(ExecutionMetadata, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/index.ts b/packages/cli/src/databases/repositories/index.ts new file mode 100644 index 0000000000000..04a11369c64f4 --- /dev/null +++ b/packages/cli/src/databases/repositories/index.ts @@ -0,0 +1,19 @@ +export { AuthIdentityRepository } from './authIdentity.repository'; +export { AuthProviderSyncHistoryRepository } from './authProviderSyncHistory.repository'; +export { CredentialsRepository } from './credentials.repository'; +export { EventDestinationsRepository } from './eventDestinations.repository'; +export { ExecutionMetadataRepository } from './executionMetadata.repository'; +export { ExecutionRepository } from './execution.repository'; +export { InstalledNodesRepository } from './installedNodes.repository'; +export { InstalledPackagesRepository } from './installedPackages.repository'; +export { RoleRepository } from './role.repository'; +export { SettingsRepository } from './settings.repository'; +export { SharedCredentialsRepository } from './sharedCredentials.repository'; +export { SharedWorkflowRepository } from './sharedWorkflow.repository'; +export { TagRepository } from './tag.repository'; +export { UserRepository } from './user.repository'; +export { VariablesRepository } from './variables.repository'; +export { WebhookRepository } from './webhook.repository'; +export { WorkflowRepository } from './workflow.repository'; +export { WorkflowStatisticsRepository } from './workflowStatistics.repository'; +export { WorkflowTagMappingRepository } from './workflowTagMapping.repository'; diff --git a/packages/cli/src/databases/repositories/installedNodes.repository.ts b/packages/cli/src/databases/repositories/installedNodes.repository.ts new file mode 100644 index 0000000000000..021535c31fd88 --- /dev/null +++ b/packages/cli/src/databases/repositories/installedNodes.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { InstalledNodes } from '../entities/InstalledNodes'; + +@Service() +export class InstalledNodesRepository extends Repository { + constructor(dataSource: DataSource) { + super(InstalledNodes, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/installedPackages.repository.ts b/packages/cli/src/databases/repositories/installedPackages.repository.ts new file mode 100644 index 0000000000000..7a0a1fff29277 --- /dev/null +++ b/packages/cli/src/databases/repositories/installedPackages.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { InstalledPackages } from '../entities/InstalledPackages'; + +@Service() +export class InstalledPackagesRepository extends Repository { + constructor(dataSource: DataSource) { + super(InstalledPackages, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/role.repository.ts b/packages/cli/src/databases/repositories/role.repository.ts new file mode 100644 index 0000000000000..6b2537a26de74 --- /dev/null +++ b/packages/cli/src/databases/repositories/role.repository.ts @@ -0,0 +1,59 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import type { RoleNames, RoleScopes } from '../entities/Role'; +import { Role } from '../entities/Role'; + +@Service() +export class RoleRepository extends Repository { + constructor(dataSource: DataSource) { + super(Role, dataSource.manager); + } + + async findGlobalOwnerRole(): Promise { + return this.findRole('global', 'owner'); + } + + async findGlobalOwnerRoleOrFail(): Promise { + return this.findRoleOrFail('global', 'owner'); + } + + async findGlobalMemberRole(): Promise { + return this.findRole('global', 'member'); + } + + async findGlobalMemberRoleOrFail(): Promise { + return this.findRoleOrFail('global', 'member'); + } + + async findWorkflowOwnerRole(): Promise { + return this.findRole('workflow', 'owner'); + } + + async findWorkflowOwnerRoleOrFail(): Promise { + return this.findRoleOrFail('workflow', 'owner'); + } + + async findWorkflowEditorRoleOrFail(): Promise { + return this.findRoleOrFail('workflow', 'editor'); + } + + async findCredentialOwnerRole(): Promise { + return this.findRole('credential', 'owner'); + } + + async findCredentialOwnerRoleOrFail(): Promise { + return this.findRoleOrFail('credential', 'owner'); + } + + async findCredentialUserRole(): Promise { + return this.findRole('credential', 'user'); + } + + async findRole(scope: RoleScopes, name: RoleNames): Promise { + return this.findOne({ where: { scope, name } }); + } + + async findRoleOrFail(scope: RoleScopes, name: RoleNames): Promise { + return this.findOneOrFail({ where: { scope, name } }); + } +} diff --git a/packages/cli/src/databases/repositories/settings.repository.ts b/packages/cli/src/databases/repositories/settings.repository.ts new file mode 100644 index 0000000000000..d0ae091ce69e8 --- /dev/null +++ b/packages/cli/src/databases/repositories/settings.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { Settings } from '../entities/Settings'; + +@Service() +export class SettingsRepository extends Repository { + constructor(dataSource: DataSource) { + super(Settings, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts new file mode 100644 index 0000000000000..29e473b885455 --- /dev/null +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { SharedCredentials } from '../entities/SharedCredentials'; + +@Service() +export class SharedCredentialsRepository extends Repository { + constructor(dataSource: DataSource) { + super(SharedCredentials, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts new file mode 100644 index 0000000000000..e8c21df37985c --- /dev/null +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { SharedWorkflow } from '../entities/SharedWorkflow'; + +@Service() +export class SharedWorkflowRepository extends Repository { + constructor(dataSource: DataSource) { + super(SharedWorkflow, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/tag.repository.ts b/packages/cli/src/databases/repositories/tag.repository.ts new file mode 100644 index 0000000000000..3eb848446d996 --- /dev/null +++ b/packages/cli/src/databases/repositories/tag.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { TagEntity } from '../entities/TagEntity'; + +@Service() +export class TagRepository extends Repository { + constructor(dataSource: DataSource) { + super(TagEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/user.repository.ts b/packages/cli/src/databases/repositories/user.repository.ts new file mode 100644 index 0000000000000..863a797a45d84 --- /dev/null +++ b/packages/cli/src/databases/repositories/user.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { User } from '../entities/User'; + +@Service() +export class UserRepository extends Repository { + constructor(dataSource: DataSource) { + super(User, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/variables.repository.ts b/packages/cli/src/databases/repositories/variables.repository.ts new file mode 100644 index 0000000000000..d787a8b98431e --- /dev/null +++ b/packages/cli/src/databases/repositories/variables.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { Variables } from '../entities/Variables'; + +@Service() +export class VariablesRepository extends Repository { + constructor(dataSource: DataSource) { + super(Variables, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/webhook.repository.ts b/packages/cli/src/databases/repositories/webhook.repository.ts new file mode 100644 index 0000000000000..64bb49a643ecb --- /dev/null +++ b/packages/cli/src/databases/repositories/webhook.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { WebhookEntity } from '../entities/WebhookEntity'; + +@Service() +export class WebhookRepository extends Repository { + constructor(dataSource: DataSource) { + super(WebhookEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts new file mode 100644 index 0000000000000..5085f47be4fda --- /dev/null +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { WorkflowEntity } from '../entities/WorkflowEntity'; + +@Service() +export class WorkflowRepository extends Repository { + constructor(dataSource: DataSource) { + super(WorkflowEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts new file mode 100644 index 0000000000000..90ebd510d0e74 --- /dev/null +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { WorkflowStatistics } from '../entities/WorkflowStatistics'; + +@Service() +export class WorkflowStatisticsRepository extends Repository { + constructor(dataSource: DataSource) { + super(WorkflowStatistics, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/workflowTagMapping.repository.ts b/packages/cli/src/databases/repositories/workflowTagMapping.repository.ts new file mode 100644 index 0000000000000..c3a45e862459f --- /dev/null +++ b/packages/cli/src/databases/repositories/workflowTagMapping.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { WorkflowTagMapping } from '../entities/WorkflowTagMapping'; + +@Service() +export class WorkflowTagMappingRepository extends Repository { + constructor(dataSource: DataSource) { + super(WorkflowTagMapping, dataSource.manager); + } +} diff --git a/packages/cli/src/decorators/Authorized.ts b/packages/cli/src/decorators/Authorized.ts new file mode 100644 index 0000000000000..880938ae5ce72 --- /dev/null +++ b/packages/cli/src/decorators/Authorized.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { CONTROLLER_AUTH_ROLES } from './constants'; +import type { AuthRoleMetadata } from './types'; + +export function Authorized(authRole: AuthRoleMetadata[string] = 'any'): Function { + return function (target: Function | Object, handlerName?: string) { + const controllerClass = handlerName ? target.constructor : target; + const authRoles = (Reflect.getMetadata(CONTROLLER_AUTH_ROLES, controllerClass) ?? + {}) as AuthRoleMetadata; + authRoles[handlerName ?? '*'] = authRole; + Reflect.defineMetadata(CONTROLLER_AUTH_ROLES, authRoles, controllerClass); + }; +} + +export const NoAuthRequired = () => Authorized('none'); diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts index 6bff0e4a6cb90..5a5efc938db6c 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -1,3 +1,4 @@ export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; +export const CONTROLLER_AUTH_ROLES = 'CONTROLLER_AUTH_ROLES'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index 71b82b5b69c30..0e683d410cbc9 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -1,3 +1,4 @@ +export { Authorized, NoAuthRequired } from './Authorized'; export { RestController } from './RestController'; export { Get, Post, Put, Patch, Delete } from './Route'; export { Middleware } from './Middleware'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index e20293ae21c71..fe792040b6067 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -1,10 +1,36 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Router } from 'express'; +import type { Application, Request, Response, RequestHandler } from 'express'; import type { Config } from '@/config'; -import { CONTROLLER_BASE_PATH, CONTROLLER_MIDDLEWARES, CONTROLLER_ROUTES } from './constants'; +import type { AuthenticatedRequest } from '@/requests'; import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file -import type { Application, Request, Response, RequestHandler } from 'express'; -import type { Controller, MiddlewareMetadata, RouteMetadata } from './types'; +import { + CONTROLLER_AUTH_ROLES, + CONTROLLER_BASE_PATH, + CONTROLLER_MIDDLEWARES, + CONTROLLER_ROUTES, +} from './constants'; +import type { + AuthRole, + AuthRoleMetadata, + Controller, + MiddlewareMetadata, + RouteMetadata, +} from './types'; + +export const createAuthMiddleware = + (authRole: AuthRole): RequestHandler => + ({ user }: AuthenticatedRequest, res, next) => { + if (authRole === 'none') return next(); + + if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); + + const { globalRole } = user; + if (authRole === 'any' || (globalRole.scope === authRole[0] && globalRole.name === authRole[1])) + return next(); + + res.status(403).json({ status: 'error', message: 'Unauthorized' }); + }; export const registerController = (app: Application, config: Config, controller: object) => { const controllerClass = controller.constructor; @@ -14,11 +40,16 @@ export const registerController = (app: Application, config: Config, controller: if (!controllerBasePath) throw new Error(`${controllerClass.name} is missing the RestController decorator`); + const authRoles = Reflect.getMetadata(CONTROLLER_AUTH_ROLES, controllerClass) as + | AuthRoleMetadata + | undefined; const routes = Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) as RouteMetadata[]; if (routes.length > 0) { const router = Router({ mergeParams: true }); const restBasePath = config.getEnv('endpoints.rest'); - const prefix = `/${[restBasePath, controllerBasePath].join('/')}`.replace(/\/+/g, '/'); + const prefix = `/${[restBasePath, controllerBasePath].join('/')}` + .replace(/\/+/g, '/') + .replace(/\/$/, ''); const controllerMiddlewares = ( (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[] @@ -28,8 +59,10 @@ export const registerController = (app: Application, config: Config, controller: ); routes.forEach(({ method, path, middlewares: routeMiddlewares, handlerName }) => { + const authRole = authRoles && (authRoles[handlerName] ?? authRoles['*']); router[method]( path, + ...(authRole ? [createAuthMiddleware(authRole)] : []), ...controllerMiddlewares, ...routeMiddlewares, send(async (req: Request, res: Response) => diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index 250abf0d27c95..d118e6e6c9cbd 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -1,7 +1,11 @@ import type { Request, Response, RequestHandler } from 'express'; +import type { RoleNames, RoleScopes } from '@db/entities/Role'; export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; +export type AuthRole = [RoleScopes, RoleNames] | 'any' | 'none'; +export type AuthRoleMetadata = Record; + export interface MiddlewareMetadata { handlerName: string; } diff --git a/packages/cli/src/environments/variables/enviromentHelpers.ts b/packages/cli/src/environments/variables/enviromentHelpers.ts new file mode 100644 index 0000000000000..d7cc12249264b --- /dev/null +++ b/packages/cli/src/environments/variables/enviromentHelpers.ts @@ -0,0 +1,26 @@ +import { License } from '@/License'; +import Container from 'typedi'; + +export function isVariablesEnabled(): boolean { + const license = Container.get(License); + return license.isVariablesEnabled(); +} + +export function canCreateNewVariable(variableCount: number): boolean { + if (!isVariablesEnabled()) { + return false; + } + const license = Container.get(License); + // This defaults to -1 which is what we want if we've enabled + // variables via the config + const limit = license.getVariablesLimit(); + if (limit === -1) { + return true; + } + return limit > variableCount; +} + +export function getVariablesLimit(): number { + const license = Container.get(License); + return license.getVariablesLimit(); +} diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments/variables/variables.controller.ee.ts new file mode 100644 index 0000000000000..3be05f32855d0 --- /dev/null +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -0,0 +1,79 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { + VariablesLicenseError, + EEVariablesService, + VariablesValidationError, +} from './variables.service.ee'; +import { isVariablesEnabled } from './enviromentHelpers'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EEVariablesController = express.Router(); + +/** + * Initialize Logger if needed + */ +EEVariablesController.use((req, res, next) => { + if (!isVariablesEnabled()) { + next('router'); + return; + } + + next(); +}); + +EEVariablesController.post( + '/', + ResponseHelper.send(async (req: VariablesRequest.Create) => { + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to update a variable blocked due to lack of permissions', { + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + const variable = req.body; + delete variable.id; + try { + return await EEVariablesService.create(variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } else if (error instanceof VariablesValidationError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } + }), +); + +EEVariablesController.patch( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Update) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to update a variable blocked due to lack of permissions', { + id, + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + const variable = req.body; + delete variable.id; + try { + return await EEVariablesService.update(id, variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } else if (error instanceof VariablesValidationError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } + }), +); diff --git a/packages/cli/src/environments/variables/variables.controller.ts b/packages/cli/src/environments/variables/variables.controller.ts new file mode 100644 index 0000000000000..931df7784d8f1 --- /dev/null +++ b/packages/cli/src/environments/variables/variables.controller.ts @@ -0,0 +1,82 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import { getLogger } from '@/Logger'; +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { VariablesService } from './variables.service'; +import { EEVariablesController } from './variables.controller.ee'; + +export const variablesController = express.Router(); + +variablesController.use('/', EEVariablesController); + +/** + * Initialize Logger if needed + */ +variablesController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +variablesController.use(EEVariablesController); + +variablesController.get( + '/', + ResponseHelper.send(async () => { + return VariablesService.getAll(); + }), +); + +variablesController.post( + '/', + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); + }), +); + +variablesController.get( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Get) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + const variable = await VariablesService.get(id); + if (variable === null) { + throw new ResponseHelper.NotFoundError(`Variable with id ${req.params.id} not found`); + } + return variable; + }), +); + +variablesController.patch( + '/:id(\\d+)', + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); + }), +); + +variablesController.delete( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Delete) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to delete a variable blocked due to lack of permissions', { + id, + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + await VariablesService.delete(id); + + return true; + }), +); diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts new file mode 100644 index 0000000000000..b5c48dcef08d7 --- /dev/null +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -0,0 +1,45 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; +import { InternalHooks } from '@/InternalHooks'; +import Container from 'typedi'; +import { canCreateNewVariable } from './enviromentHelpers'; +import { VariablesService } from './variables.service'; + +export class VariablesLicenseError extends Error {} +export class VariablesValidationError extends Error {} + +export class EEVariablesService extends VariablesService { + static async getCount(): Promise { + return collections.Variables.count(); + } + + static validateVariable(variable: Omit): void { + if (variable.key.length > 50) { + throw new VariablesValidationError('key cannot be longer than 50 characters'); + } + if (variable.key.replace(/[A-Za-z0-9_]/g, '').length !== 0) { + throw new VariablesValidationError('key can only contain characters A-Za-z0-9_'); + } + if (variable.value.length > 255) { + throw new VariablesValidationError('value cannot be longer than 255 characters'); + } + } + + static async create(variable: Omit): Promise { + if (!canCreateNewVariable(await this.getCount())) { + throw new VariablesLicenseError('Variables limit reached'); + } + this.validateVariable(variable); + + void Container.get(InternalHooks).onVariableCreated({ variable_type: variable.type }); + return collections.Variables.save(variable); + } + + static async update(id: number, variable: Omit): Promise { + this.validateVariable(variable); + + await collections.Variables.update(id, variable); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (await this.get(id))!; + } +} diff --git a/packages/cli/src/environments/variables/variables.service.ts b/packages/cli/src/environments/variables/variables.service.ts new file mode 100644 index 0000000000000..646f9368f2382 --- /dev/null +++ b/packages/cli/src/environments/variables/variables.service.ts @@ -0,0 +1,20 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; + +export class VariablesService { + static async getAll(): Promise { + return collections.Variables.find(); + } + + static async getCount(): Promise { + return collections.Variables.count(); + } + + static async get(id: number): Promise { + return collections.Variables.findOne({ where: { id } }); + } + + static async delete(id: number): Promise { + await collections.Variables.delete(id); + } +} diff --git a/packages/cli/src/environments/versionControl/constants.ts b/packages/cli/src/environments/versionControl/constants.ts new file mode 100644 index 0000000000000..dc415672beae8 --- /dev/null +++ b/packages/cli/src/environments/versionControl/constants.ts @@ -0,0 +1 @@ +export const VERSION_CONTROL_PREFERENCES_DB_KEY = 'features.versionControl'; diff --git a/packages/cli/src/environments/versionControl/middleware/versionControlEnabledMiddleware.ts b/packages/cli/src/environments/versionControl/middleware/versionControlEnabledMiddleware.ts new file mode 100644 index 0000000000000..5ed3a1293b443 --- /dev/null +++ b/packages/cli/src/environments/versionControl/middleware/versionControlEnabledMiddleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from 'express'; +import { + isVersionControlLicensed, + isVersionControlLicensedAndEnabled, +} from '../versionControlHelper'; + +export const versionControlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => { + if (isVersionControlLicensedAndEnabled()) { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; + +export const versionControlLicensedMiddleware: RequestHandler = (req, res, next) => { + if (isVersionControlLicensed()) { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; diff --git a/packages/cli/src/environments/versionControl/types/keyPair.ts b/packages/cli/src/environments/versionControl/types/keyPair.ts new file mode 100644 index 0000000000000..239406ec332d1 --- /dev/null +++ b/packages/cli/src/environments/versionControl/types/keyPair.ts @@ -0,0 +1,4 @@ +export interface KeyPair { + privateKey: string; + publicKey: string; +} diff --git a/packages/cli/src/environments/versionControl/types/requests.ts b/packages/cli/src/environments/versionControl/types/requests.ts new file mode 100644 index 0000000000000..0782873ba56ae --- /dev/null +++ b/packages/cli/src/environments/versionControl/types/requests.ts @@ -0,0 +1,6 @@ +import type { AuthenticatedRequest } from '@/requests'; +import type { VersionControlPreferences } from './versionControlPreferences'; + +export declare namespace VersionControlRequest { + type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial, {}>; +} diff --git a/packages/cli/src/environments/versionControl/types/versionControlPreferences.ts b/packages/cli/src/environments/versionControl/types/versionControlPreferences.ts new file mode 100644 index 0000000000000..9481270ec5394 --- /dev/null +++ b/packages/cli/src/environments/versionControl/types/versionControlPreferences.ts @@ -0,0 +1,36 @@ +import { IsBoolean, IsEmail, IsHexColor, IsOptional, IsString } from 'class-validator'; + +export class VersionControlPreferences { + constructor(preferences: Partial | undefined = undefined) { + if (preferences) Object.assign(this, preferences); + } + + @IsBoolean() + connected: boolean; + + @IsString() + repositoryUrl: string; + + @IsString() + authorName: string; + + @IsEmail() + authorEmail: string; + + @IsString() + branchName: string; + + @IsBoolean() + branchReadOnly: boolean; + + @IsHexColor() + branchColor: string; + + @IsOptional() + @IsString() + readonly privateKey?: string; + + @IsOptional() + @IsString() + readonly publicKey?: string; +} diff --git a/packages/cli/src/environments/versionControl/versionControl.controller.ee.ts b/packages/cli/src/environments/versionControl/versionControl.controller.ee.ts new file mode 100644 index 0000000000000..5bbac46f0bcb6 --- /dev/null +++ b/packages/cli/src/environments/versionControl/versionControl.controller.ee.ts @@ -0,0 +1,37 @@ +import { Authorized, Get, Post, RestController } from '@/decorators'; +import { versionControlLicensedMiddleware } from './middleware/versionControlEnabledMiddleware'; +import { VersionControlService } from './versionControl.service.ee'; +import { VersionControlRequest } from './types/requests'; +import type { VersionControlPreferences } from './types/versionControlPreferences'; + +@RestController('/versionControl') +export class VersionControlController { + constructor(private versionControlService: VersionControlService) {} + + @Authorized('any') + @Get('/preferences', { middlewares: [versionControlLicensedMiddleware] }) + async getPreferences(): Promise { + // returns the settings with the privateKey property redacted + return this.versionControlService.versionControlPreferences; + } + + @Authorized(['global', 'owner']) + @Post('/preferences', { middlewares: [versionControlLicensedMiddleware] }) + async setPreferences(req: VersionControlRequest.UpdatePreferences) { + const sanitizedPreferences: Partial = { + ...req.body, + privateKey: undefined, + publicKey: undefined, + }; + await this.versionControlService.validateVersionControlPreferences(sanitizedPreferences); + return this.versionControlService.setPreferences(sanitizedPreferences); + } + + //TODO: temporary function to generate key and save new pair + // REMOVE THIS FUNCTION AFTER TESTING + @Authorized(['global', 'owner']) + @Get('/generateKeyPair', { middlewares: [versionControlLicensedMiddleware] }) + async generateKeyPair() { + return this.versionControlService.generateAndSaveKeyPair(); + } +} diff --git a/packages/cli/src/environments/versionControl/versionControl.service.ee.ts b/packages/cli/src/environments/versionControl/versionControl.service.ee.ts new file mode 100644 index 0000000000000..d531935f00152 --- /dev/null +++ b/packages/cli/src/environments/versionControl/versionControl.service.ee.ts @@ -0,0 +1,108 @@ +import { Service } from 'typedi'; +import { generateSshKeyPair } from './versionControlHelper'; +import { VersionControlPreferences } from './types/versionControlPreferences'; +import { VERSION_CONTROL_PREFERENCES_DB_KEY } from './constants'; +import * as Db from '@/Db'; +import { jsonParse, LoggerProxy } from 'n8n-workflow'; +import type { ValidationError } from 'class-validator'; +import { validate } from 'class-validator'; + +@Service() +export class VersionControlService { + private _versionControlPreferences: VersionControlPreferences = new VersionControlPreferences(); + + async init(): Promise { + await this.loadFromDbAndApplyVersionControlPreferences(); + } + + public get versionControlPreferences(): VersionControlPreferences { + return { + ...this._versionControlPreferences, + privateKey: '(redacted)', + }; + } + + public set versionControlPreferences(preferences: Partial) { + this._versionControlPreferences = { + connected: preferences.connected ?? this._versionControlPreferences.connected, + authorEmail: preferences.authorEmail ?? this._versionControlPreferences.authorEmail, + authorName: preferences.authorName ?? this._versionControlPreferences.authorName, + branchName: preferences.branchName ?? this._versionControlPreferences.branchName, + branchColor: preferences.branchColor ?? this._versionControlPreferences.branchColor, + branchReadOnly: preferences.branchReadOnly ?? this._versionControlPreferences.branchReadOnly, + privateKey: preferences.privateKey ?? this._versionControlPreferences.privateKey, + publicKey: preferences.publicKey ?? this._versionControlPreferences.publicKey, + repositoryUrl: preferences.repositoryUrl ?? this._versionControlPreferences.repositoryUrl, + }; + } + + async generateAndSaveKeyPair() { + const keyPair = generateSshKeyPair('ed25519'); + if (keyPair.publicKey && keyPair.privateKey) { + await this.setPreferences({ ...keyPair }); + } else { + LoggerProxy.error('Failed to generate key pair'); + } + return keyPair; + } + + async validateVersionControlPreferences( + preferences: Partial, + ): Promise { + const preferencesObject = new VersionControlPreferences(preferences); + const validationResult = await validate(preferencesObject, { + forbidUnknownValues: false, + skipMissingProperties: true, + stopAtFirstError: false, + validationError: { target: false }, + }); + if (validationResult.length > 0) { + throw new Error(`Invalid version control preferences: ${JSON.stringify(validationResult)}`); + } + // TODO: if repositoryUrl is changed, check if it is valid + // TODO: if branchName is changed, check if it is valid + return validationResult; + } + + async setPreferences( + preferences: Partial, + saveToDb = true, + ): Promise { + this.versionControlPreferences = preferences; + if (saveToDb) { + const settingsValue = JSON.stringify(this._versionControlPreferences); + try { + await Db.collections.Settings.save({ + key: VERSION_CONTROL_PREFERENCES_DB_KEY, + value: settingsValue, + loadOnStartup: true, + }); + } catch (error) { + throw new Error(`Failed to save version control preferences: ${(error as Error).message}`); + } + } + return this.versionControlPreferences; + } + + async loadFromDbAndApplyVersionControlPreferences(): Promise< + VersionControlPreferences | undefined + > { + const loadedPreferences = await Db.collections.Settings.findOne({ + where: { key: VERSION_CONTROL_PREFERENCES_DB_KEY }, + }); + if (loadedPreferences) { + try { + const preferences = jsonParse(loadedPreferences.value); + if (preferences) { + await this.setPreferences(preferences, false); + return preferences; + } + } catch (error) { + LoggerProxy.warn( + `Could not parse Version Control settings from database: ${(error as Error).message}`, + ); + } + } + return; + } +} diff --git a/packages/cli/src/environments/versionControl/versionControlHelper.ts b/packages/cli/src/environments/versionControl/versionControlHelper.ts new file mode 100644 index 0000000000000..9810977f91983 --- /dev/null +++ b/packages/cli/src/environments/versionControl/versionControlHelper.ts @@ -0,0 +1,56 @@ +import Container from 'typedi'; +import { License } from '../../License'; +import { generateKeyPairSync } from 'crypto'; +import sshpk from 'sshpk'; +import type { KeyPair } from './types/keyPair'; + +export function isVersionControlLicensed() { + const license = Container.get(License); + return license.isVersionControlLicensed(); +} + +export function isVersionControlEnabled() { + // TODO: VERSION CONTROL check if enabled + return true; +} + +export function isVersionControlLicensedAndEnabled() { + return isVersionControlLicensed() && isVersionControlEnabled(); +} + +export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { + const keyPair: KeyPair = { + publicKey: '', + privateKey: '', + }; + let generatedKeyPair: KeyPair; + switch (keyType) { + case 'ed25519': + generatedKeyPair = generateKeyPairSync('ed25519', { + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + publicKeyEncoding: { format: 'pem', type: 'spki' }, + }); + break; + case 'rsa': + generatedKeyPair = generateKeyPairSync('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + break; + } + const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem'); + keyPair.publicKey = keyPublic.toString('ssh'); + const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem'); + keyPair.privateKey = keyPrivate.toString('ssh-private'); + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + }; +} diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts index 7dfc68e564d5e..29eab2872aa5e 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts @@ -1,8 +1,7 @@ -import config from '@/config'; import { License } from '@/License'; import { Container } from 'typedi'; export function isLogStreamingEnabled(): boolean { const license = Container.get(License); - return config.getEnv('enterprise.features.logStreaming') || license.isLogStreamingEnabled(); + return license.isLogStreamingEnabled(); } diff --git a/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts b/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts index af95df2e88cad..516a3549ee994 100644 --- a/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts +++ b/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts @@ -108,7 +108,7 @@ export async function recoverExecutionDataFromEventLogMessages( [ { json: { - isArtificalRecoveredEventItem: true, + isArtificialRecoveredEventItem: true, }, pairedItem: undefined, }, @@ -155,9 +155,10 @@ export async function recoverExecutionDataFromEventLogMessages( } if (applyToDb) { + const newStatus = executionEntry.status === 'failed' ? 'failed' : 'crashed'; await Db.collections.Execution.update(executionId, { data: stringify(executionData), - status: 'crashed', + status: newStatus, stoppedAt: lastNodeRunTimestamp?.toJSDate(), }); await Container.get(InternalHooks).onWorkflowPostExecute( @@ -170,7 +171,7 @@ export async function recoverExecutionDataFromEventLogMessages( waitTill: executionEntry.waitTill ?? undefined, startedAt: executionEntry.startedAt, stoppedAt: lastNodeRunTimestamp?.toJSDate(), - status: 'crashed', + status: newStatus, }, ); const iRunData: IRun = { @@ -180,7 +181,7 @@ export async function recoverExecutionDataFromEventLogMessages( waitTill: executionEntry.waitTill ?? undefined, startedAt: executionEntry.startedAt, stoppedAt: lastNodeRunTimestamp?.toJSDate(), - status: 'crashed', + status: newStatus, }; const workflowHooks = getWorkflowHooksMain( { diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts index 301c38c213624..f69c417516ead 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts @@ -1,4 +1,4 @@ -import type { EventDestinations } from '@/databases/entities/MessageEventBusDestinationEntity'; +import type { EventDestinations } from '@db/entities/EventDestinations'; import { promClient } from '@/metrics'; import { EventMessageTypeNames, diff --git a/packages/cli/src/eventbus/eventBus.controller.ts b/packages/cli/src/eventbus/eventBus.controller.ts index bc18472d4c910..7c9ce21ccc5ee 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ts @@ -28,15 +28,13 @@ import type { IRunExecutionData, } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames, EventMessageTypeNames } from 'n8n-workflow'; -import type { User } from '@db/entities/User'; -import * as ResponseHelper from '@/ResponseHelper'; import type { EventMessageNodeOptions } from './EventMessageClasses/EventMessageNode'; import { EventMessageNode } from './EventMessageClasses/EventMessageNode'; import { recoverExecutionDataFromEventLogMessages } from './MessageEventBus/recoverEvents'; -import { RestController, Get, Post, Delete } from '@/decorators'; +import { RestController, Get, Post, Delete, Authorized } from '@/decorators'; import type { MessageEventBusDestination } from './MessageEventBusDestination/MessageEventBusDestination.ee'; -import { isOwnerMiddleware } from '../middlewares/isOwner'; import type { DeleteResult } from 'typeorm'; +import { AuthenticatedRequest } from '@/requests'; // ---------------------------------------- // TypeGuards @@ -74,12 +72,14 @@ const isMessageEventBusDestinationOptions = ( // Controller // ---------------------------------------- +@Authorized() @RestController('/eventbus') export class EventBusController { // ---------------------------------------- // Events // ---------------------------------------- - @Get('/event', { middlewares: [isOwnerMiddleware] }) + @Authorized(['global', 'owner']) + @Get('/event') async getEvents( req: express.Request, ): Promise> { @@ -132,7 +132,8 @@ export class EventBusController { return; } - @Post('/event', { middlewares: [isOwnerMiddleware] }) + @Authorized(['global', 'owner']) + @Post('/event') async postEvent(req: express.Request): Promise { let msg: EventMessageTypes | undefined; if (isEventMessageOptions(req.body)) { @@ -172,12 +173,9 @@ export class EventBusController { } } - @Post('/destination', { middlewares: [isOwnerMiddleware] }) - async postDestination(req: express.Request): Promise { - if (!req.user || (req.user as User).globalRole.name !== 'owner') { - throw new ResponseHelper.UnauthorizedError('Invalid request'); - } - + @Authorized(['global', 'owner']) + @Post('/destination') + async postDestination(req: AuthenticatedRequest): Promise { let result: MessageEventBusDestination | undefined; if (isMessageEventBusDestinationOptions(req.body)) { switch (req.body.__type) { @@ -228,11 +226,9 @@ export class EventBusController { return false; } - @Delete('/destination', { middlewares: [isOwnerMiddleware] }) - async deleteDestination(req: express.Request): Promise { - if (!req.user || (req.user as User).globalRole.name !== 'owner') { - throw new ResponseHelper.UnauthorizedError('Invalid request'); - } + @Authorized(['global', 'owner']) + @Delete('/destination') + async deleteDestination(req: AuthenticatedRequest): Promise { if (isWithIdString(req.query)) { return eventBus.removeDestination(req.query.id); } else { diff --git a/packages/cli/src/events/WorkflowStatistics.ts b/packages/cli/src/events/WorkflowStatistics.ts index 34b191b45ae6c..863aeb6e1897c 100644 --- a/packages/cli/src/events/WorkflowStatistics.ts +++ b/packages/cli/src/events/WorkflowStatistics.ts @@ -1,10 +1,84 @@ import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; +import { LoggerProxy } from 'n8n-workflow'; import * as Db from '@/Db'; import { StatisticsNames } from '@db/entities/WorkflowStatistics'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { QueryFailedError } from 'typeorm'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; +import config from '@/config'; +import { UserService } from '@/user/user.service'; + +const enum StatisticsUpsertResult { + insert = 'insert', + update = 'update', + failed = 'failed', +} + +async function upsertWorkflowStatistics( + eventName: StatisticsNames, + workflowId: string, +): Promise { + const dbType = config.getEnv('database.type'); + const { tableName } = Db.collections.WorkflowStatistics.metadata; + try { + if (dbType === 'sqlite') { + await Db.collections.WorkflowStatistics.query( + `INSERT INTO "${tableName}" ("count", "name", "workflowId", "latestEvent") + VALUES (1, "${eventName}", "${workflowId}", CURRENT_TIMESTAMP) + ON CONFLICT (workflowId, name) + DO UPDATE SET count = count + 1, latestEvent = CURRENT_TIMESTAMP`, + ); + // SQLite does not offer a reliable way to know whether or not an insert or update happened. + // We'll use a naive approach in this case. Query again after and it might cause us to miss the + // first production execution sometimes due to concurrency, but it's the only way. + + const counter = await Db.collections.WorkflowStatistics.findOne({ + select: ['count'], + where: { + name: eventName, + workflowId, + }, + }); + + if (counter?.count === 1) { + return StatisticsUpsertResult.insert; + } + return StatisticsUpsertResult.update; + } else if (dbType === 'postgresdb') { + const queryResult = (await Db.collections.WorkflowStatistics.query( + `INSERT INTO "${tableName}" ("count", "name", "workflowId", "latestEvent") + VALUES (1, '${eventName}', '${workflowId}', CURRENT_TIMESTAMP) + ON CONFLICT ("name", "workflowId") + DO UPDATE SET "count" = "${tableName}"."count" + 1, "latestEvent" = CURRENT_TIMESTAMP + RETURNING *;`, + )) as Array<{ + count: number; + }>; + if (queryResult[0].count === 1) { + return StatisticsUpsertResult.insert; + } + return StatisticsUpsertResult.update; + } else { + const queryResult = (await Db.collections.WorkflowStatistics.query( + `INSERT INTO \`${tableName}\` (count, name, workflowId, latestEvent) + VALUES (1, "${eventName}", "${workflowId}", NOW()) + ON DUPLICATE KEY + UPDATE count = count + 1, latestEvent = NOW();`, + )) as { + affectedRows: number; + }; + if (queryResult.affectedRows === 1) { + return StatisticsUpsertResult.insert; + } + // MySQL returns 2 affected rows on update + return StatisticsUpsertResult.update; + } + } catch (error) { + if (error instanceof QueryFailedError) return StatisticsUpsertResult.failed; + throw error; + } +} export async function workflowExecutionCompleted( workflowData: IWorkflowBase, @@ -27,36 +101,32 @@ export async function workflowExecutionCompleted( const workflowId = workflowData.id; if (!workflowId) return; - // Try insertion and if it fails due to key conflicts then update the existing entry instead try { - await Db.collections.WorkflowStatistics.insert({ - count: 1, - name, - workflowId, - latestEvent: new Date(), - }); + const upsertResult = await upsertWorkflowStatistics(name, workflowId); - // If we're here we can check if we're sending the first production success metric - if (name !== StatisticsNames.productionSuccess) return; + if ( + name === StatisticsNames.productionSuccess && + upsertResult === StatisticsUpsertResult.insert + ) { + const owner = await getWorkflowOwner(workflowId); + const metrics = { + user_id: owner.id, + workflow_id: workflowId, + }; - // Get the owner of the workflow so we can send the metric - const owner = await getWorkflowOwner(workflowId); - const metrics = { - user_id: owner.id, - workflow_id: workflowId, - }; + if (!owner.settings?.firstSuccessfulWorkflowId) { + await UserService.updateUserSettings(owner.id, { + firstSuccessfulWorkflowId: workflowId, + userActivated: true, + showUserActivationSurvey: true, + }); + } - // Send the metrics - await Container.get(InternalHooks).onFirstProductionWorkflowSuccess(metrics); - } catch (error) { - if (!(error instanceof QueryFailedError)) { - throw error; + // Send the metrics + await Container.get(InternalHooks).onFirstProductionWorkflowSuccess(metrics); } - - await Db.collections.WorkflowStatistics.update( - { workflowId, name }, - { count: () => 'count + 1', latestEvent: new Date() }, - ); + } catch (error) { + LoggerProxy.verbose('Unable to fire first workflow success telemetry event'); } } diff --git a/packages/cli/src/executions/executionHelpers.ts b/packages/cli/src/executions/executionHelpers.ts index de27577e22170..148bd9b8b9644 100644 --- a/packages/cli/src/executions/executionHelpers.ts +++ b/packages/cli/src/executions/executionHelpers.ts @@ -2,7 +2,6 @@ import { Container } from 'typedi'; import type { IExecutionFlattedDb } from '@/Interfaces'; import type { ExecutionStatus } from 'n8n-workflow'; import { License } from '@/License'; -import config from '@/config'; export function getStatusUsingPreviousExecutionStatusMethod( execution: IExecutionFlattedDb, @@ -22,8 +21,5 @@ export function getStatusUsingPreviousExecutionStatusMethod( export function isAdvancedExecutionFiltersEnabled(): boolean { const license = Container.get(License); - return ( - config.getEnv('enterprise.features.advancedExecutionFilters') || - license.isAdvancedExecutionFiltersEnabled() - ); + return license.isAdvancedExecutionFiltersEnabled(); } diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index 6165d13b2a125..41372335f04e2 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -39,7 +39,7 @@ import { getStatusUsingPreviousExecutionStatusMethod, isAdvancedExecutionFiltersEnabled, } from './executionHelpers'; -import { ExecutionMetadata } from '@/databases/entities/ExecutionMetadata'; +import { ExecutionMetadata } from '@db/entities/ExecutionMetadata'; import { DateUtils } from 'typeorm/util/DateUtils'; interface IGetExecutionsQueryFilter { @@ -304,7 +304,7 @@ export class ExecutionsService { }); } - // Omit `data` from the Execution since it is the largest and not necesary for the list. + // Omit `data` from the Execution since it is the largest and not necessary for the list. let query = Db.collections.Execution.createQueryBuilder('execution') .select([ 'execution.id', diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 75d8252411826..e5dd13a18ba9c 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -75,6 +75,7 @@ licenseController.post( } catch (e) { const error = e as Error & { errorId?: string }; + //override specific error messages (to map License Server vocabulary to n8n terms) switch (error.errorId ?? 'UNSPECIFIED') { case 'SCHEMA_VALIDATION': error.message = 'Activation key is in the wrong format'; @@ -92,7 +93,7 @@ licenseController.post( break; } - throw new ResponseHelper.BadRequestError((e as Error).message); + throw new ResponseHelper.BadRequestError(error.message); } // Return the read data, plus the management JWT @@ -115,10 +116,12 @@ licenseController.post( try { await license.renew(); } catch (e) { + const error = e as Error & { errorId?: string }; + // not awaiting so as not to make the endpoint hang void Container.get(InternalHooks).onLicenseRenewAttempt({ success: false }); - if (e instanceof Error) { - throw new ResponseHelper.BadRequestError(e.message); + if (error instanceof Error) { + throw new ResponseHelper.BadRequestError(error.message); } } diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index 1299f474084b6..231b48beddf91 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -10,15 +10,8 @@ import type { AuthenticatedRequest } from '@/requests'; import config from '@/config'; import { AUTH_COOKIE_NAME, EDITOR_UI_DIST_DIR } from '@/constants'; import { issueCookie, resolveJwtContent } from '@/auth/jwt'; -import { - isAuthenticatedRequest, - isAuthExcluded, - isPostUsersId, - isUserManagementEnabled, -} from '@/UserManagement/UserManagementHelper'; -import type { Repository } from 'typeorm'; -import type { User } from '@db/entities/User'; -import { SamlUrls } from '@/sso/saml/constants'; +import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; +import type { UserRepository } from '@db/repositories'; const jwtFromRequest = (req: Request) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -67,6 +60,17 @@ const staticAssets = globSync(['**/*.html', '**/*.svg', '**/*.png', '**/*.ico'], cwd: EDITOR_UI_DIST_DIR, }); +// TODO: delete this +const isPostUsersId = (req: Request, restEndpoint: string): boolean => + req.method === 'POST' && + new RegExp(`/${restEndpoint}/users/[\\w\\d-]*`).test(req.url) && + !req.url.includes('reinvite'); + +const isAuthExcluded = (url: string, ignoredEndpoints: Readonly): boolean => + !!ignoredEndpoints + .filter(Boolean) // skip empty paths + .find((ignoredEndpoint) => url.startsWith(`/${ignoredEndpoint}`)); + /** * This sets up the auth middlewares in the correct order */ @@ -74,7 +78,7 @@ export const setupAuthMiddlewares = ( app: Application, ignoredEndpoints: Readonly, restEndpoint: string, - userRepository: Repository, + userRepository: UserRepository, ) => { // needed for testing; not adding overhead since it directly returns if req.cookies exists app.use(cookieParser()); @@ -86,20 +90,16 @@ export const setupAuthMiddlewares = ( // skip authentication for preflight requests req.method === 'OPTIONS' || staticAssets.includes(req.url.slice(1)) || + isAuthExcluded(req.url, ignoredEndpoints) || req.url.startsWith(`/${restEndpoint}/settings`) || req.url.startsWith(`/${restEndpoint}/login`) || - req.url.startsWith(`/${restEndpoint}/logout`) || req.url.startsWith(`/${restEndpoint}/resolve-signup-token`) || isPostUsersId(req, restEndpoint) || req.url.startsWith(`/${restEndpoint}/forgot-password`) || req.url.startsWith(`/${restEndpoint}/resolve-password-token`) || req.url.startsWith(`/${restEndpoint}/change-password`) || req.url.startsWith(`/${restEndpoint}/oauth2-credential/callback`) || - req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`) || - req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.metadata}`) || - req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.initSSO}`) || - req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.acs}`) || - isAuthExcluded(req.url, ignoredEndpoints) + req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`) ) { return next(); } @@ -116,43 +116,5 @@ export const setupAuthMiddlewares = ( return passportMiddleware(req, res, next); }); - app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => { - // req.user is empty for public routes, so just proceed - // owner can do anything, so proceed as well - if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) { - next(); - return; - } - // Not owner and user exists. We now protect restricted urls. - const postRestrictedUrls = [ - `/${restEndpoint}/users`, - `/${restEndpoint}/owner`, - `/${restEndpoint}/ldap/sync`, - `/${restEndpoint}/ldap/test-connection`, - ]; - const getRestrictedUrls = [`/${restEndpoint}/ldap/sync`, `/${restEndpoint}/ldap/config`]; - const putRestrictedUrls = [`/${restEndpoint}/ldap/config`]; - const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; - if ( - (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || - (req.method === 'GET' && getRestrictedUrls.includes(trimmedUrl)) || - (req.method === 'PUT' && putRestrictedUrls.includes(trimmedUrl)) || - (req.method === 'DELETE' && - new RegExp(`/${restEndpoint}/users/[^/]+`, 'gm').test(trimmedUrl)) || - (req.method === 'POST' && - new RegExp(`/${restEndpoint}/users/[^/]+/reinvite`, 'gm').test(trimmedUrl)) || - new RegExp(`/${restEndpoint}/owner/[^/]+`, 'gm').test(trimmedUrl) - ) { - Logger.verbose('User attempted to access endpoint without authorization', { - endpoint: `${req.method} ${trimmedUrl}`, - userId: isAuthenticatedRequest(req) ? req.user.id : 'unknown', - }); - res.status(403).json({ status: 'error', message: 'Unauthorized' }); - return; - } - - next(); - }); - app.use(refreshExpiringCookie); }; diff --git a/packages/cli/src/middlewares/isOwner.ts b/packages/cli/src/middlewares/isOwner.ts deleted file mode 100644 index d3e3c70a0fb46..0000000000000 --- a/packages/cli/src/middlewares/isOwner.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RequestHandler } from 'express'; -import { LoggerProxy } from 'n8n-workflow'; -import type { AuthenticatedRequest } from '@/requests'; - -export const isOwnerMiddleware: RequestHandler = (req: AuthenticatedRequest, res, next) => { - if (req.user.globalRole.name === 'owner') { - next(); - } else { - LoggerProxy.debug('Request failed because user is not owner'); - res.status(401).send('Unauthorized'); - } -}; diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts index e513e366366cf..df390c53b39a8 100644 --- a/packages/cli/src/posthog/index.ts +++ b/packages/cli/src/posthog/index.ts @@ -44,7 +44,7 @@ export class PostHogClient { } async getFeatureFlags(user: Pick): Promise { - if (!this.postHog) return Promise.resolve({}); + if (!this.postHog) return {}; const fullId = [this.instanceId, user.id].join('#'); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index a90ec2b021f7d..af1fdae591b43 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -10,12 +10,13 @@ import type { IWorkflowSettings, } from 'n8n-workflow'; -import { IsEmail, IsString, Length } from 'class-validator'; +import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator'; import { NoXss } from '@db/utils/customValidators'; import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type { UserManagementMailer } from '@/UserManagement/email'; +import type { Variables } from '@db/entities/Variables'; export class UserUpdatePayload implements Pick { @IsEmail() @@ -31,6 +32,15 @@ export class UserUpdatePayload implements Pick; export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>; export type Password = AuthenticatedRequest< {}, @@ -376,3 +387,17 @@ export type BinaryDataRequest = AuthenticatedRequest< mimeType?: string; } >; + +// ---------------------------------- +// /variables +// ---------------------------------- +// +export declare namespace VariablesRequest { + type CreateUpdatePayload = Omit & { id?: unknown }; + + type GetAll = AuthenticatedRequest; + type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; + type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload, {}>; + type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>; + type Delete = Get; +} diff --git a/packages/cli/src/role/role.service.ts b/packages/cli/src/role/role.service.ts index 6ba367e1b1565..d0d52cba35f05 100644 --- a/packages/cli/src/role/role.service.ts +++ b/packages/cli/src/role/role.service.ts @@ -1,18 +1,18 @@ +import { Service } from 'typedi'; import type { EntityManager, FindOptionsWhere } from 'typeorm'; -import * as Db from '@/Db'; import { Role } from '@db/entities/Role'; +import { SharedWorkflowRepository } from '@db/repositories'; +@Service() export class RoleService { - static async get(role: FindOptionsWhere): Promise { - return Db.collections.Role.findOneBy(role); - } + constructor(private sharedWorkflowRepository: SharedWorkflowRepository) {} static async trxGet(transaction: EntityManager, role: FindOptionsWhere) { return transaction.findOneBy(Role, role); } - static async getUserRoleForWorkflow(userId: string, workflowId: string) { - const shared = await Db.collections.SharedWorkflow.findOne({ + async getUserRoleForWorkflow(userId: string, workflowId: string) { + const shared = await this.sharedWorkflowRepository.findOne({ where: { workflowId, userId }, relations: ['role'], }); diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso/saml/constants.ts index 6f92690f5f5d1..b2dd3f2a9b444 100644 --- a/packages/cli/src/sso/saml/constants.ts +++ b/packages/cli/src/sso/saml/constants.ts @@ -3,8 +3,6 @@ export class SamlUrls { static readonly initSSO = '/initsso'; - static readonly restInitSSO = this.samlRESTRoot + this.initSSO; - static readonly acs = '/acs'; static readonly restAcs = this.samlRESTRoot + this.acs; @@ -17,19 +15,17 @@ export class SamlUrls { static readonly configTest = '/config/test'; - static readonly configToggleEnabled = '/config/toggle'; + static readonly configTestReturn = '/config/test/return'; - static readonly restConfig = this.samlRESTRoot + this.config; + static readonly configToggleEnabled = '/config/toggle'; static readonly defaultRedirect = '/'; - static readonly samlOnboarding = '/settings/personal'; // TODO:SAML: implement signup page + static readonly samlOnboarding = '/saml/onboarding'; } export const SAML_PREFERENCES_DB_KEY = 'features.saml'; -export const SAML_ENTERPRISE_FEATURE_ENABLED = 'enterprise.features.saml'; - export const SAML_LOGIN_LABEL = 'sso.saml.loginLabel'; export const SAML_LOGIN_ENABLED = 'sso.saml.loginEnabled'; diff --git a/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts index 6e4600b895f06..69015838d7f69 100644 --- a/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts +++ b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts @@ -1,24 +1,11 @@ import type { RequestHandler } from 'express'; -import type { AuthenticatedRequest } from '@/requests'; import { isSamlLicensed, isSamlLicensedAndEnabled } from '../samlHelpers'; -export const samlLicensedOwnerMiddleware: RequestHandler = ( - req: AuthenticatedRequest, - res, - next, -) => { - if (isSamlLicensed() && req.user?.globalRole.name === 'owner') { - next(); - } else { - res.status(401).json({ status: 'error', message: 'Unauthorized' }); - } -}; - export const samlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => { if (isSamlLicensedAndEnabled()) { next(); } else { - res.status(401).json({ status: 'error', message: 'Unauthorized' }); + res.status(403).json({ status: 'error', message: 'Unauthorized' }); } }; @@ -26,6 +13,6 @@ export const samlLicensedMiddleware: RequestHandler = (req, res, next) => { if (isSamlLicensed()) { next(); } else { - res.status(401).json({ status: 'error', message: 'Unauthorized' }); + res.status(403).json({ status: 'error', message: 'Unauthorized' }); } }; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 28b9d3c5223f9..c6b04cae460ef 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -1,10 +1,10 @@ import express from 'express'; -import { Get, Post, RestController } from '@/decorators'; +import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; +import { Authorized, Get, Post, RestController } from '@/decorators'; import { SamlUrls } from '../constants'; import { samlLicensedAndEnabledMiddleware, samlLicensedMiddleware, - samlLicensedOwnerMiddleware, } from '../middleware/samlEnabledMiddleware'; import { SamlService } from '../saml.service.ee'; import { SamlConfiguration } from '../types/requests'; @@ -13,10 +13,16 @@ import { getInitSSOFormView } from '../views/initSsoPost'; import { issueCookie } from '@/auth/jwt'; import { validate } from 'class-validator'; import type { PostBindingContext } from 'samlify/types/src/entity'; -import { isSamlLicensedAndEnabled } from '../samlHelpers'; +import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../samlHelpers'; import type { SamlLoginBinding } from '../types'; import { AuthenticatedRequest } from '@/requests'; -import { getServiceProviderEntityId, getServiceProviderReturnUrl } from '../serviceProvider.ee'; +import { + getServiceProviderConfigTestReturnUrl, + getServiceProviderEntityId, + getServiceProviderReturnUrl, +} from '../serviceProvider.ee'; +import { getSamlConnectionTestSuccessView } from '../views/samlConnectionTestSuccess'; +import { getSamlConnectionTestFailedView } from '../views/samlConnectionTestFailed'; @RestController('/sso/saml') export class SamlController { @@ -33,26 +39,28 @@ export class SamlController { * GET /sso/saml/config * Return SAML config */ - @Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) - async configGet(req: AuthenticatedRequest, res: express.Response) { + @Authorized(['global', 'owner']) + @Get(SamlUrls.config, { middlewares: [samlLicensedMiddleware] }) + async configGet() { const prefs = this.samlService.samlPreferences; - return res.send({ + return { ...prefs, entityID: getServiceProviderEntityId(), returnUrl: getServiceProviderReturnUrl(), - }); + }; } /** * POST /sso/saml/config * Set SAML config */ - @Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) - async configPost(req: SamlConfiguration.Update, res: express.Response) { + @Authorized(['global', 'owner']) + @Post(SamlUrls.config, { middlewares: [samlLicensedMiddleware] }) + async configPost(req: SamlConfiguration.Update) { const validationResult = await validate(req.body); if (validationResult.length === 0) { const result = await this.samlService.setSamlPreferences(req.body); - return res.send(result); + return result; } else { throw new BadRequestError( 'Body is not a valid SamlPreferences object: ' + @@ -65,7 +73,8 @@ export class SamlController { * POST /sso/saml/config/toggle * Set SAML config */ - @Post(SamlUrls.configToggleEnabled, { middlewares: [samlLicensedOwnerMiddleware] }) + @Authorized(['global', 'owner']) + @Post(SamlUrls.configToggleEnabled, { middlewares: [samlLicensedMiddleware] }) async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) { if (req.body.loginEnabled === undefined) { throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); @@ -79,7 +88,7 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Get(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) - async acsGet(req: express.Request, res: express.Response) { + async acsGet(req: SamlConfiguration.AcsRequest, res: express.Response) { return this.acsHandler(req, res, 'redirect'); } @@ -88,7 +97,7 @@ export class SamlController { * Assertion Consumer Service endpoint */ @Post(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) - async acsPost(req: express.Request, res: express.Response) { + async acsPost(req: SamlConfiguration.AcsRequest, res: express.Response) { return this.acsHandler(req, res, 'post'); } @@ -97,24 +106,41 @@ export class SamlController { * Available if SAML is licensed, even if not enabled to run connection tests * For test connections, returns status 202 if SAML is not enabled */ - private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) { - const loginResult = await this.samlService.handleSamlLogin(req, binding); - if (loginResult) { + private async acsHandler( + req: SamlConfiguration.AcsRequest, + res: express.Response, + binding: SamlLoginBinding, + ) { + try { + const loginResult = await this.samlService.handleSamlLogin(req, binding); + // if RelayState is set to the test connection Url, this is a test connection + if (isConnectionTestRequest(req)) { + if (loginResult.authenticatedUser) { + return res.send(getSamlConnectionTestSuccessView(loginResult.attributes)); + } else { + return res.send(getSamlConnectionTestFailedView('', loginResult.attributes)); + } + } if (loginResult.authenticatedUser) { // Only sign in user if SAML is enabled, otherwise treat as test connection if (isSamlLicensedAndEnabled()) { await issueCookie(res, loginResult.authenticatedUser); if (loginResult.onboardingRequired) { - return res.redirect(SamlUrls.samlOnboarding); + return res.redirect(getInstanceBaseUrl() + SamlUrls.samlOnboarding); } else { - return res.redirect(SamlUrls.defaultRedirect); + return res.redirect(getInstanceBaseUrl() + SamlUrls.defaultRedirect); } } else { return res.status(202).send(loginResult.attributes); } } + throw new AuthError('SAML Authentication failed'); + } catch (error) { + if (isConnectionTestRequest(req)) { + return res.send(getSamlConnectionTestFailedView((error as Error).message)); + } + throw new AuthError('SAML Authentication failed: ' + (error as Error).message); } - throw new AuthError('SAML Authentication failed'); } /** @@ -132,15 +158,16 @@ export class SamlController { * Test SAML config * This endpoint is available if SAML is licensed and the requestor is an instance owner */ - @Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] }) + @Authorized(['global', 'owner']) + @Get(SamlUrls.configTest, { middlewares: [samlLicensedMiddleware] }) async configTestGet(req: AuthenticatedRequest, res: express.Response) { - return this.handleInitSSO(res); + return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); } - private async handleInitSSO(res: express.Response) { - const result = this.samlService.getLoginRequestUrl(); + private async handleInitSSO(res: express.Response, relayState?: string) { + const result = this.samlService.getLoginRequestUrl(relayState); if (result?.binding === 'redirect') { - return res.send(result.context.context); + return result.context.context; } else if (result?.binding === 'post') { return res.send(getInitSSOFormView(result.context as PostBindingContext)); } else { diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 1bf8e36b9ffc2..b16d14600e1d8 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -1,7 +1,7 @@ import type express from 'express'; import { Service } from 'typedi'; import * as Db from '@/Db'; -import type { User } from '@/databases/entities/User'; +import type { User } from '@db/entities/User'; import { jsonParse, LoggerProxy } from 'n8n-workflow'; import { AuthError, BadRequestError } from '@/ResponseHelper'; import { getServiceProviderInstance } from './serviceProvider.ee'; @@ -20,12 +20,13 @@ import { setSamlLoginLabel, updateUserFromSamlAttributes, } from './samlHelpers'; -import type { Settings } from '../../databases/entities/Settings'; +import type { Settings } from '@db/entities/Settings'; import axios from 'axios'; import https from 'https'; import type { SamlLoginBinding } from './types'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import { validateMetadata, validateResponse } from './samlValidator'; +import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; @Service() export class SamlService { @@ -48,6 +49,7 @@ export class SamlService { loginLabel: 'SAML', wantAssertionsSigned: true, wantMessageSigned: true, + relayState: getInstanceBaseUrl(), signatureConfig: { prefix: 'ds', location: { @@ -71,9 +73,8 @@ export class SamlService { validate: async (response: string) => { const valid = await validateResponse(response); if (!valid) { - return Promise.reject(new Error('Invalid SAML response')); + throw new Error('Invalid SAML response'); } - return Promise.resolve(); }, }); } @@ -92,7 +93,10 @@ export class SamlService { return getServiceProviderInstance(this._samlPreferences); } - getLoginRequestUrl(binding?: SamlLoginBinding): { + getLoginRequestUrl( + relayState?: string, + binding?: SamlLoginBinding, + ): { binding: SamlLoginBinding; context: BindingContext | PostBindingContext; } { @@ -100,28 +104,29 @@ export class SamlService { if (binding === 'post') { return { binding, - context: this.getPostLoginRequestUrl(), + context: this.getPostLoginRequestUrl(relayState), }; } else { return { binding, - context: this.getRedirectLoginRequestUrl(), + context: this.getRedirectLoginRequestUrl(relayState), }; } } - private getRedirectLoginRequestUrl(): BindingContext { - const loginRequest = this.getServiceProviderInstance().createLoginRequest( - this.getIdentityProviderInstance(), - 'redirect', - ); + private getRedirectLoginRequestUrl(relayState?: string): BindingContext { + const sp = this.getServiceProviderInstance(); + sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect'); //TODO:SAML: debug logging LoggerProxy.debug(loginRequest.context); return loginRequest; } - private getPostLoginRequestUrl(): PostBindingContext { - const loginRequest = this.getServiceProviderInstance().createLoginRequest( + private getPostLoginRequestUrl(relayState?: string): PostBindingContext { + const sp = this.getServiceProviderInstance(); + sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + const loginRequest = sp.createLoginRequest( this.getIdentityProviderInstance(), 'post', ) as PostBindingContext; @@ -133,14 +138,11 @@ export class SamlService { async handleSamlLogin( req: express.Request, binding: SamlLoginBinding, - ): Promise< - | { - authenticatedUser: User | undefined; - attributes: SamlUserAttributes; - onboardingRequired: boolean; - } - | undefined - > { + ): Promise<{ + authenticatedUser: User | undefined; + attributes: SamlUserAttributes; + onboardingRequired: boolean; + }> { const attributes = await this.getAttributesFromLoginResponse(req, binding); if (attributes.email) { const user = await Db.collections.User.findOne({ @@ -148,7 +150,7 @@ export class SamlService { relations: ['globalRole', 'authIdentities'], }); if (user) { - // Login path for existing users that are fully set up + // Login path for existing users that are fully set up and that have a SAML authIdentity set up if ( user.authIdentities.find( (e) => e.providerType === 'saml' && e.providerId === attributes.userPrincipalName, @@ -162,10 +164,11 @@ export class SamlService { } else { // Login path for existing users that are NOT fully set up for SAML const updatedUser = await updateUserFromSamlAttributes(user, attributes); + const onboardingRequired = !updatedUser.firstName || !updatedUser.lastName; return { authenticatedUser: updatedUser, attributes, - onboardingRequired: true, + onboardingRequired, }; } } else { @@ -180,7 +183,11 @@ export class SamlService { } } } - return undefined; + return { + authenticatedUser: undefined, + attributes, + onboardingRequired: false, + }; } async setSamlPreferences(prefs: SamlPreferences): Promise { @@ -294,7 +301,9 @@ export class SamlService { ); } catch (error) { // throw error; - throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); + throw new AuthError( + `SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`, + ); } const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( parsedSamlResponse, diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index 57c710e7071a4..0672965deb8b0 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -3,20 +3,23 @@ import config from '@/config'; import * as Db from '@/Db'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import { User } from '@db/entities/User'; +import { RoleRepository } from '@db/repositories'; import { License } from '@/License'; -import { AuthError } from '@/ResponseHelper'; +import { AuthError, InternalServerError } from '@/ResponseHelper'; import { hashPassword, isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import type { SamlPreferences } from './types/samlPreferences'; import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { FlowResult } from 'samlify/types/src/flow'; import type { SamlAttributeMapping } from './types/samlAttributeMapping'; -import { SAML_ENTERPRISE_FEATURE_ENABLED, SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; +import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { + getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '../ssoHelpers'; -import { LoggerProxy } from 'n8n-workflow'; +import { getServiceProviderConfigTestReturnUrl } from './serviceProvider.ee'; +import type { SamlConfiguration } from './types/requests'; /** * Check whether the SAML feature is licensed and enabled in the instance */ @@ -30,18 +33,17 @@ export function getSamlLoginLabel(): string { // can only toggle between email and saml, not directly to e.g. ldap export async function setSamlLoginEnabled(enabled: boolean): Promise { - if (config.get(SAML_LOGIN_ENABLED) === enabled) { - return; - } - if (enabled && isEmailCurrentAuthenticationMethod()) { - config.set(SAML_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('saml'); - } else if (!enabled && isSamlCurrentAuthenticationMethod()) { - config.set(SAML_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); + if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) { + if (enabled) { + config.set(SAML_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('saml'); + } else if (!enabled) { + config.set(SAML_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } } else { - LoggerProxy.warn( - 'Cannot switch SAML login enabled state when an authentication method other than email is active', + throw new InternalServerError( + `Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${getCurrentAuthenticationMethod()})`, ); } } @@ -52,10 +54,7 @@ export function setSamlLoginLabel(label: string): void { export function isSamlLicensed(): boolean { const license = Container.get(License); - return ( - isUserManagementEnabled() && - (license.isSamlEnabled() || config.getEnv(SAML_ENTERPRISE_FEATURE_ENABLED)) - ); + return isUserManagementEnabled() && license.isSamlEnabled(); } export function isSamlLicensedAndEnabled(): boolean { @@ -102,9 +101,7 @@ export async function createUserFromSamlAttributes(attributes: SamlUserAttribute user.email = attributes.email; user.firstName = attributes.firstName; user.lastName = attributes.lastName; - user.globalRole = await Db.collections.Role.findOneOrFail({ - where: { name: 'member', scope: 'global' }, - }); + user.globalRole = await Container.get(RoleRepository).findGlobalMemberRoleOrFail(); // generates a password that is not used or known to the user user.password = await hashPassword(generatePassword()); authIdentity.providerId = attributes.userPrincipalName; @@ -178,3 +175,7 @@ export function getMappedSamlAttributesFromFlowResult( } return result; } + +export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean { + return req.body.RelayState === getServiceProviderConfigTestReturnUrl(); +} diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index 5d992830120a0..f6d707eaf86be 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -15,6 +15,10 @@ export function getServiceProviderReturnUrl(): string { return getInstanceBaseUrl() + SamlUrls.restAcs; } +export function getServiceProviderConfigTestReturnUrl(): string { + return getInstanceBaseUrl() + SamlUrls.configTestReturn; +} + // TODO:SAML: make these configurable for the end user export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance { if (serviceProviderInstance === undefined) { @@ -24,6 +28,7 @@ export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProvi wantAssertionsSigned: prefs.wantAssertionsSigned, wantMessageSigned: prefs.wantMessageSigned, signatureConfig: prefs.signatureConfig, + relayState: prefs.relayState, nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'], assertionConsumerService: [ { diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts index 4d58afb2dd53d..3d3e4125fc8d8 100644 --- a/packages/cli/src/sso/saml/types/requests.ts +++ b/packages/cli/src/sso/saml/types/requests.ts @@ -1,7 +1,17 @@ -import type { AuthenticatedRequest } from '@/requests'; +import type { AuthenticatedRequest, AuthlessRequest } from '@/requests'; import type { SamlPreferences } from './samlPreferences'; export declare namespace SamlConfiguration { type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>; type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>; + + type AcsRequest = AuthlessRequest< + {}, + {}, + { + // eslint-disable-next-line @typescript-eslint/naming-convention + RelayState?: string; + }, + {} + >; } diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts index c5c72bcd0f85e..da02f1ebc4cef 100644 --- a/packages/cli/src/sso/saml/types/samlPreferences.ts +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -57,4 +57,8 @@ export class SamlPreferences { action: 'after', }, }; + + @IsString() + @IsOptional() + relayState?: string = ''; } diff --git a/packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts b/packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts new file mode 100644 index 0000000000000..8d9a3578f0635 --- /dev/null +++ b/packages/cli/src/sso/saml/views/samlConnectionTestFailed.ts @@ -0,0 +1,42 @@ +import type { SamlUserAttributes } from '../types/samlUserAttributes'; + +export function getSamlConnectionTestFailedView( + message: string, + attributes?: SamlUserAttributes, +): string { + return ` + + + n8n - SAML Connection Test Result + + + +
+

SAML Connection Test failed

+

${message ?? 'A common issue could be that no email attribute is set'}

+ +

+ ${ + attributes + ? ` +

Here are the attributes returned by your SAML IdP:

+
    +
  • Email: ${attributes?.email ?? '(n/a)'}
  • +
  • First Name: ${attributes?.firstName ?? '(n/a)'}
  • +
  • Last Name: ${attributes?.lastName ?? '(n/a)'}
  • +
  • UPN: ${attributes?.userPrincipalName ?? '(n/a)'}
  • +
` + : '' + } +
+ +
+ `; +} diff --git a/packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts b/packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts new file mode 100644 index 0000000000000..59e6aed263a0c --- /dev/null +++ b/packages/cli/src/sso/saml/views/samlConnectionTestSuccess.ts @@ -0,0 +1,33 @@ +import type { SamlUserAttributes } from '../types/samlUserAttributes'; + +export function getSamlConnectionTestSuccessView(attributes: SamlUserAttributes): string { + return ` + + + n8n - SAML Connection Test Result + + + +
+

SAML Connection Test was successful

+ +

+

Here are the attributes returned by your SAML IdP:

+
    +
  • Email: ${attributes.email ?? '(n/a)'}
  • +
  • First Name: ${attributes.firstName ?? '(n/a)'}
  • +
  • Last Name: ${attributes.lastName ?? '(n/a)'}
  • +
  • UPN: ${attributes.userPrincipalName ?? '(n/a)'}
  • +
+
+ +
+ `; +} diff --git a/packages/cli/src/sso/ssoHelpers.ts b/packages/cli/src/sso/ssoHelpers.ts index 70c375b6c44a4..30636c533d6c9 100644 --- a/packages/cli/src/sso/ssoHelpers.ts +++ b/packages/cli/src/sso/ssoHelpers.ts @@ -1,6 +1,6 @@ import config from '@/config'; import * as Db from '@/Db'; -import type { AuthProviderType } from '@/databases/entities/AuthIdentity'; +import type { AuthProviderType } from '@db/entities/AuthIdentity'; /** * Only one authentication method can be active at a time. This function sets the current authentication method diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index cdc7b96f8be74..7edb6f6172f41 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -76,7 +76,7 @@ export class Telemetry { private async pulse(): Promise { if (!this.rudderStack) { - return Promise.resolve(); + return; } const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => { diff --git a/packages/cli/src/user/user.service.ts b/packages/cli/src/user/user.service.ts index b7f97f02d29ae..a7f8bff52d0c8 100644 --- a/packages/cli/src/user/user.service.ts +++ b/packages/cli/src/user/user.service.ts @@ -2,6 +2,7 @@ import type { EntityManager, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; import * as Db from '@/Db'; import { User } from '@db/entities/User'; +import type { IUserSettings } from '@/Interfaces'; export class UserService { static async get(where: FindOptionsWhere): Promise { @@ -14,4 +15,11 @@ export class UserService { static async getByIds(transaction: EntityManager, ids: string[]) { return transaction.find(User, { where: { id: In(ids) } }); } + + static async updateUserSettings(id: string, userSettings: Partial) { + const { settings: currentSettings } = await Db.collections.User.findOneOrFail({ + where: { id }, + }); + return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } }); + } } diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index 833b319a28eb4..399886cae2ee2 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -11,6 +11,7 @@ import { isSharingEnabled, rightDiff } from '@/UserManagement/UserManagementHelp import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee'; import { ExternalHooks } from '@/ExternalHooks'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import { RoleRepository } from '@db/repositories'; import { LoggerProxy } from 'n8n-workflow'; import * as TagHelpers from '@/TagHelpers'; import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee'; @@ -162,10 +163,7 @@ EEWorkflowController.post( await Db.transaction(async (transactionManager) => { savedWorkflow = await transactionManager.save(newWorkflow); - const role = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'workflow', - }); + const role = await Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); const newSharedWorkflow = new SharedWorkflow(); @@ -206,10 +204,7 @@ EEWorkflowController.get( ResponseHelper.send(async (req: WorkflowRequest.GetAll) => { const [workflows, workflowOwnerRole] = await Promise.all([ EEWorkflows.getMany(req.user, req.query.filter), - Db.collections.Role.findOneOrFail({ - select: ['id'], - where: { name: 'owner', scope: 'workflow' }, - }), + Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(), ]); return workflows.map((workflow) => { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 979f4b852fc96..99a46c78af009 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -14,6 +14,7 @@ import config from '@/config'; import * as TagHelpers from '@/TagHelpers'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { RoleRepository } from '@db/repositories'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; import { getLogger } from '@/Logger'; @@ -80,10 +81,7 @@ workflowsController.post( await Db.transaction(async (transactionManager) => { savedWorkflow = await transactionManager.save(newWorkflow); - const role = await Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'workflow', - }); + const role = await Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); const newSharedWorkflow = new SharedWorkflow(); diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 8563038e4cdac..e942620e070a9 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -186,7 +186,7 @@ export class WorkflowsService { user: User, workflow: WorkflowEntity, workflowId: string, - tags?: string[], + tagIds?: string[], forceSave?: boolean, roles?: string[], ): Promise { @@ -285,13 +285,11 @@ export class WorkflowsService { ]), ); - if (tags && !config.getEnv('workflowTagsDisabled')) { - const tablePrefix = config.getEnv('database.tablePrefix'); - await TagHelpers.removeRelations(workflowId, tablePrefix); - - if (tags.length) { - await TagHelpers.createRelations(workflowId, tags, tablePrefix); - } + if (tagIds && !config.getEnv('workflowTagsDisabled')) { + await Db.collections.WorkflowTagMapping.delete({ workflowId }); + await Db.collections.WorkflowTagMapping.insert( + tagIds.map((tagId) => ({ tagId, workflowId })), + ); } const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; @@ -309,9 +307,9 @@ export class WorkflowsService { ); } - if (updatedWorkflow.tags?.length && tags?.length) { + if (updatedWorkflow.tags?.length && tagIds?.length) { updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { - requestOrder: tags, + requestOrder: tagIds, }); } diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts new file mode 100644 index 0000000000000..bc00a5e53d481 --- /dev/null +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -0,0 +1,72 @@ +import * as testDb from '../shared/testDb'; +import { mockInstance } from '../shared/utils'; +import { InternalHooks } from '@/InternalHooks'; +import { ImportWorkflowsCommand } from '../../../src/commands/import/workflow'; +import * as Config from '@oclif/config'; + +beforeAll(async () => { + mockInstance(InternalHooks); + await testDb.init(); +}); + +beforeEach(async () => { + await testDb.truncate(['Workflow']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +test('import:workflow should import active workflow and deactivate it', async () => { + const config: Config.IConfig = new Config.Config({ root: __dirname }); + const before = await testDb.getAllWorkflows(); + expect(before.length).toBe(0); + const importer = new ImportWorkflowsCommand( + ['--separate', '--input=./test/integration/commands/importWorkflows/separate'], + config, + ); + const mockExit = jest.spyOn(process, 'exit').mockImplementation((number) => { + throw new Error('process.exit'); + }); + + await importer.init(); + try { + await importer.run(); + } catch (error) { + expect(error.message).toBe('process.exit'); + } + const after = await testDb.getAllWorkflows(); + expect(after.length).toBe(2); + expect(after[0].name).toBe('active-workflow'); + expect(after[0].active).toBe(false); + expect(after[1].name).toBe('inactive-workflow'); + expect(after[1].active).toBe(false); + mockExit.mockRestore(); +}); + +test('import:workflow should import active workflow from combined file and deactivate it', async () => { + const config: Config.IConfig = new Config.Config({ root: __dirname }); + const before = await testDb.getAllWorkflows(); + expect(before.length).toBe(0); + const importer = new ImportWorkflowsCommand( + ['--input=./test/integration/commands/importWorkflows/combined/combined.json'], + config, + ); + const mockExit = jest.spyOn(process, 'exit').mockImplementation((number) => { + throw new Error('process.exit'); + }); + + await importer.init(); + try { + await importer.run(); + } catch (error) { + expect(error.message).toBe('process.exit'); + } + const after = await testDb.getAllWorkflows(); + expect(after.length).toBe(2); + expect(after[0].name).toBe('active-workflow'); + expect(after[0].active).toBe(false); + expect(after[1].name).toBe('inactive-workflow'); + expect(after[1].active).toBe(false); + mockExit.mockRestore(); +}); diff --git a/packages/cli/test/integration/commands/importWorkflows/combined/combined.json b/packages/cli/test/integration/commands/importWorkflows/combined/combined.json new file mode 100644 index 0000000000000..4bace0f042c50 --- /dev/null +++ b/packages/cli/test/integration/commands/importWorkflows/combined/combined.json @@ -0,0 +1,160 @@ +[ + { + "name": "active-workflow", + "nodes": [ + { + "parameters": { + "path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138", + "responseMode": "responseNode", + "options": {} + }, + "id": "c26d8782-bd57-43d0-86dc-0c618a7e4024", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [800, 580], + "webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138" + }, + { + "parameters": { + "values": { + "boolean": [ + { + "name": "hooked", + "value": true + } + ] + }, + "options": {} + }, + "id": "9701b1ef-9ab0-432a-b086-cf76981b097d", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [1020, 580] + }, + { + "parameters": { + "options": {} + }, + "id": "d0f086b8-c2b2-4404-b347-95d3f91e555a", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [1240, 580] + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": {}, + "versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f", + "id": "998", + "meta": { + "instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9" + }, + "tags": [] + }, + { + "name": "inactive-workflow", + "nodes": [ + { + "parameters": { + "path": "e20b4873-fcf7-4bce-88fc-a1a56d66b137", + "responseMode": "responseNode", + "options": {} + }, + "id": "c26d8782-bd57-43d0-86dc-0c618a7e4024", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [800, 580], + "webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b137" + }, + { + "parameters": { + "values": { + "boolean": [ + { + "name": "hooked", + "value": true + } + ] + }, + "options": {} + }, + "id": "9701b1ef-9ab0-432a-b086-cf76981b097c", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [1020, 580] + }, + { + "parameters": { + "options": {} + }, + "id": "d0f086b8-c2b2-4404-b347-95d3f91e555a", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [1240, 580] + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f", + "id": "999", + "meta": { + "instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9" + }, + "tags": [] + } +] diff --git a/packages/cli/test/integration/commands/importWorkflows/separate/001-activeWorkflow.json b/packages/cli/test/integration/commands/importWorkflows/separate/001-activeWorkflow.json new file mode 100644 index 0000000000000..71641916b1df9 --- /dev/null +++ b/packages/cli/test/integration/commands/importWorkflows/separate/001-activeWorkflow.json @@ -0,0 +1,79 @@ +{ + "name": "active-workflow", + "nodes": [ + { + "parameters": { + "path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138", + "responseMode": "responseNode", + "options": {} + }, + "id": "c26d8782-bd57-43d0-86dc-0c618a7e4024", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [800, 580], + "webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138" + }, + { + "parameters": { + "values": { + "boolean": [ + { + "name": "hooked", + "value": true + } + ] + }, + "options": {} + }, + "id": "9701b1ef-9ab0-432a-b086-cf76981b097d", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [1020, 580] + }, + { + "parameters": { + "options": {} + }, + "id": "d0f086b8-c2b2-4404-b347-95d3f91e555a", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [1240, 580] + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": {}, + "versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f", + "id": "998", + "meta": { + "instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9" + }, + "tags": [] +} diff --git a/packages/cli/test/integration/commands/importWorkflows/separate/002-inactiveWorkflow.json b/packages/cli/test/integration/commands/importWorkflows/separate/002-inactiveWorkflow.json new file mode 100644 index 0000000000000..89e2c39d0eeb1 --- /dev/null +++ b/packages/cli/test/integration/commands/importWorkflows/separate/002-inactiveWorkflow.json @@ -0,0 +1,79 @@ +{ + "name": "inactive-workflow", + "nodes": [ + { + "parameters": { + "path": "e20b4873-fcf7-4bce-88fc-a1a56d66b137", + "responseMode": "responseNode", + "options": {} + }, + "id": "c26d8782-bd57-43d0-86dc-0c618a7e4024", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [800, 580], + "webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b137" + }, + { + "parameters": { + "values": { + "boolean": [ + { + "name": "hooked", + "value": true + } + ] + }, + "options": {} + }, + "id": "9701b1ef-9ab0-432a-b086-cf76981b097c", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [1020, 580] + }, + { + "parameters": { + "options": {} + }, + "id": "d0f086b8-c2b2-4404-b347-95d3f91e555a", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [1240, 580] + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f", + "id": "999", + "meta": { + "instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9" + }, + "tags": [] +} diff --git a/packages/cli/test/integration/environments/VersionControl.test.ts b/packages/cli/test/integration/environments/VersionControl.test.ts new file mode 100644 index 0000000000000..d438bf906b3d8 --- /dev/null +++ b/packages/cli/test/integration/environments/VersionControl.test.ts @@ -0,0 +1,38 @@ +import { Container } from 'typedi'; +import type { SuperAgentTest } from 'supertest'; +import type { User } from '@db/entities/User'; +import { License } from '@/License'; +import * as testDb from '../shared/testDb'; +import * as utils from '../shared/utils'; +import { VersionControlService } from '../../../src/environments/versionControl/versionControl.service.ee'; + +let owner: User; +let authOwnerAgent: SuperAgentTest; + +beforeAll(async () => { + Container.get(License).isVersionControlLicensed = () => true; + const app = await utils.initTestServer({ endpointGroups: ['versionControl'] }); + owner = await testDb.createOwner(); + authOwnerAgent = utils.createAuthAgent(app)(owner); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('GET /versionControl/preferences', () => { + test('should return Version Control preferences', async () => { + await Container.get(VersionControlService).generateAndSaveKeyPair(); + await authOwnerAgent + .get('/versionControl/preferences') + .expect(200) + .expect((res) => { + return ( + 'privateKey' in res.body && + 'publicKey' in res.body && + res.body.publicKey.includes('ssh-ed25519') && + res.body.privateKey.includes('BEGIN OPENSSH PRIVATE KEY') + ); + }); + }); +}); diff --git a/packages/cli/test/integration/eventbus.test.ts b/packages/cli/test/integration/eventbus.test.ts index d456f86799884..a5b712321747d 100644 --- a/packages/cli/test/integration/eventbus.test.ts +++ b/packages/cli/test/integration/eventbus.test.ts @@ -3,6 +3,7 @@ import config from '@/config'; import axios from 'axios'; import syslog from 'syslog-client'; import { v4 as uuid } from 'uuid'; +import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; @@ -23,6 +24,7 @@ import { MessageEventBusDestinationWebhook } from '@/eventbus/MessageEventBusDes import { MessageEventBusDestinationSentry } from '@/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee'; import { EventMessageAudit } from '@/eventbus/EventMessageClasses/EventMessageAudit'; import { EventNamesTypes } from '@/eventbus/EventMessageClasses'; +import { License } from '@/License'; jest.unmock('@/eventbus/MessageEventBus/MessageEventBus'); jest.mock('axios'); @@ -77,6 +79,7 @@ async function confirmIdSent(id: string) { } beforeAll(async () => { + Container.get(License).isLogStreamingEnabled = () => true; app = await utils.initTestServer({ endpointGroups: ['eventBus'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); @@ -101,7 +104,6 @@ beforeAll(async () => { utils.initConfigFile(); config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter'); config.set('eventBus.logWriter.keepLogCount', 1); - config.set('enterprise.features.logStreaming', true); config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); @@ -178,7 +180,6 @@ test.skip('should send message to syslog', async () => { eventName: 'n8n.test.message' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const syslogDestination = eventBus.destinations[ testSyslogDestination.id! @@ -219,7 +220,6 @@ test.skip('should confirm send message if there are no subscribers', async () => eventName: 'n8n.test.unsub' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const syslogDestination = eventBus.destinations[ testSyslogDestination.id! @@ -255,7 +255,6 @@ test('should anonymize audit message to syslog ', async () => { }, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const syslogDestination = eventBus.destinations[ testSyslogDestination.id! @@ -317,7 +316,6 @@ test('should send message to webhook ', async () => { eventName: 'n8n.test.message' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const webhookDestination = eventBus.destinations[ testWebhookDestination.id! @@ -352,7 +350,6 @@ test('should send message to sentry ', async () => { eventName: 'n8n.test.message' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const sentryDestination = eventBus.destinations[ testSentryDestination.id! diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index fcecb25d8fcfb..eaffa15494f23 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -1,6 +1,7 @@ import express from 'express'; import type { Entry as LdapUser } from 'ldapts'; import { Not } from 'typeorm'; +import { Container } from 'typedi'; import { jsonParse } from 'n8n-workflow'; import config from '@/config'; import * as Db from '@/Db'; @@ -12,11 +13,12 @@ import { LdapService } from '@/Ldap/LdapService.ee'; import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers'; import type { LdapConfig } from '@/Ldap/types'; import { sanitizeUser } from '@/UserManagement/UserManagementHelper'; +import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; +import { License } from '@/License'; import { randomEmail, randomName, uniqueId } from './../shared/random'; import * as testDb from './../shared/testDb'; import type { AuthAgent } from '../shared/types'; import * as utils from '../shared/utils'; -import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; jest.mock('@/telemetry'); jest.mock('@/UserManagement/email/NodeMailer'); @@ -41,6 +43,7 @@ const defaultLdapConfig = { }; beforeAll(async () => { + Container.get(License).isLdapEnabled = () => true; app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'] }); const [globalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles(); @@ -77,7 +80,6 @@ beforeEach(async () => { config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); config.set('userManagement.emails.mode', ''); - config.set('enterprise.features.ldap', true); }); afterAll(async () => { @@ -203,9 +205,7 @@ test('GET /ldap/config route should retrieve current configuration', async () => describe('POST /ldap/test-connection', () => { test('route should success', async () => { - jest - .spyOn(LdapService.prototype, 'testConnection') - .mockImplementation(async () => Promise.resolve()); + jest.spyOn(LdapService.prototype, 'testConnection').mockResolvedValue(); const response = await authAgent(owner).post('/ldap/test-connection'); expect(response.statusCode).toBe(200); @@ -214,9 +214,7 @@ describe('POST /ldap/test-connection', () => { test('route should fail', async () => { const errorMessage = 'Invalid connection'; - jest - .spyOn(LdapService.prototype, 'testConnection') - .mockImplementation(async () => Promise.reject(new Error(errorMessage))); + jest.spyOn(LdapService.prototype, 'testConnection').mockRejectedValue(new Error(errorMessage)); const response = await authAgent(owner).post('/ldap/test-connection'); expect(response.statusCode).toBe(400); @@ -238,9 +236,7 @@ describe('POST /ldap/sync', () => { describe('dry mode', () => { const runTest = async (ldapUsers: LdapUser[]) => { - jest - .spyOn(LdapService.prototype, 'searchWithAdminBinding') - .mockImplementation(async () => Promise.resolve(ldapUsers)); + jest.spyOn(LdapService.prototype, 'searchWithAdminBinding').mockResolvedValue(ldapUsers); const response = await authAgent(owner).post('/ldap/sync').send({ type: 'dry' }); @@ -335,9 +331,7 @@ describe('POST /ldap/sync', () => { describe('live mode', () => { const runTest = async (ldapUsers: LdapUser[]) => { - jest - .spyOn(LdapService.prototype, 'searchWithAdminBinding') - .mockImplementation(async () => Promise.resolve(ldapUsers)); + jest.spyOn(LdapService.prototype, 'searchWithAdminBinding').mockResolvedValue(ldapUsers); const response = await authAgent(owner).post('/ldap/sync').send({ type: 'live' }); @@ -465,9 +459,7 @@ describe('POST /ldap/sync', () => { test('should remove user instance access once the user is disabled during synchronization', async () => { const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId()); - jest - .spyOn(LdapService.prototype, 'searchWithAdminBinding') - .mockImplementation(async () => Promise.resolve([])); + jest.spyOn(LdapService.prototype, 'searchWithAdminBinding').mockResolvedValue([]); await authAgent(owner).post('/ldap/sync').send({ type: 'live' }); @@ -506,13 +498,9 @@ describe('POST /login', () => { const authlessAgent = utils.createAgent(app); - jest - .spyOn(LdapService.prototype, 'searchWithAdminBinding') - .mockImplementation(async () => Promise.resolve([ldapUser])); + jest.spyOn(LdapService.prototype, 'searchWithAdminBinding').mockResolvedValue([ldapUser]); - jest - .spyOn(LdapService.prototype, 'validUser') - .mockImplementation(async () => Promise.resolve()); + jest.spyOn(LdapService.prototype, 'validUser').mockResolvedValue(); const response = await authlessAgent .post('/login') diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index 38c9042ddf29e..60ccfa0ce5b46 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -14,6 +14,8 @@ import { randomValidPassword, } from './shared/random'; import * as testDb from './shared/testDb'; +import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; +import { ExternalHooks } from '@/ExternalHooks'; jest.mock('@/UserManagement/email/NodeMailer'); @@ -21,6 +23,7 @@ let globalOwnerRole: Role; let globalMemberRole: Role; let owner: User; let authlessAgent: SuperAgentTest; +let externalHooks = utils.mockInstance(ExternalHooks); beforeAll(async () => { const app = await utils.initTestServer({ endpointGroups: ['passwordReset'] }); @@ -36,6 +39,7 @@ beforeEach(async () => { owner = await testDb.createUser({ globalRole: globalOwnerRole }); config.set('userManagement.isInstanceOwnerSetUp', true); + externalHooks.run.mockReset(); }); afterAll(async () => { @@ -74,6 +78,35 @@ describe('POST /forgot-password', () => { expect(storedOwner.resetPasswordToken).toBeNull(); }); + test('should fail if SAML is authentication method', async () => { + await setCurrentAuthenticationMethod('saml'); + config.set('userManagement.emails.mode', 'smtp'); + const member = await testDb.createUser({ + email: 'test@test.com', + globalRole: globalMemberRole, + }); + + await authlessAgent.post('/forgot-password').send({ email: member.email }).expect(403); + + const storedOwner = await Db.collections.User.findOneByOrFail({ email: member.email }); + expect(storedOwner.resetPasswordToken).toBeNull(); + await setCurrentAuthenticationMethod('email'); + }); + + test('should succeed if SAML is authentication method and requestor is owner', async () => { + await setCurrentAuthenticationMethod('saml'); + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent.post('/forgot-password').send({ email: owner.email }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({}); + + const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email }); + expect(storedOwner.resetPasswordToken).not.toBeNull(); + await setCurrentAuthenticationMethod('email'); + }); + test('should fail with invalid inputs', async () => { config.set('userManagement.emails.mode', 'smtp'); @@ -191,6 +224,11 @@ describe('POST /change-password', () => { const comparisonResult = await compare(passwordToStore, storedPassword); expect(comparisonResult).toBe(true); expect(storedPassword).not.toBe(passwordToStore); + + expect(externalHooks.run).toHaveBeenCalledWith('user.password.update', [ + owner.email, + storedPassword, + ]); }); test('should fail with invalid inputs', async () => { @@ -246,5 +284,7 @@ describe('POST /change-password', () => { }); expect(response.statusCode).toBe(404); + + expect(externalHooks.run).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 4c70977dcef75..1d5debdbfb1e3 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -10,7 +10,11 @@ import * as testDb from '../shared/testDb'; let app: Application; let owner: User; +let user1: User; +let user2: User; let authOwnerAgent: SuperAgentTest; +let authUser1Agent: SuperAgentTest; +let authUser2Agent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; beforeAll(async () => { @@ -21,7 +25,10 @@ beforeAll(async () => { }); const globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalUserRole = await testDb.getGlobalMemberRole(); owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + user1 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); + user2 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); await utils.initBinaryManager(); await utils.initNodeTypes(); @@ -46,6 +53,20 @@ beforeEach(async () => { version: 1, }); + authUser1Agent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: user1, + version: 1, + }); + + authUser2Agent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: user2, + version: 1, + }); + config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); }); @@ -70,7 +91,7 @@ describe('GET /executions/:id', () => { test('should fail due to invalid API Key', testWithAPIKey('get', '/executions/1', 'abcXYZ')); - test('should get an execution', async () => { + test('owner should be able to get an execution owned by him', async () => { const workflow = await testDb.createWorkflow({}, owner); const execution = await testDb.createSuccessfulExecution(workflow); @@ -101,6 +122,46 @@ describe('GET /executions/:id', () => { expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); }); + + test('owner should be able to read executions of other users', async () => { + const workflow = await testDb.createWorkflow({}, user1); + const execution = await testDb.createSuccessfulExecution(workflow); + + const response = await authOwnerAgent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + }); + + test('member should be able to fetch his own executions', async () => { + const workflow = await testDb.createWorkflow({}, user1); + const execution = await testDb.createSuccessfulExecution(workflow); + + const response = await authUser1Agent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + }); + + test('member should not get an execution of another user without the workflow being shared', async () => { + const workflow = await testDb.createWorkflow({}, owner); + + const execution = await testDb.createSuccessfulExecution(workflow); + + const response = await authUser1Agent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(404); + }); + + test('member should be able to fetch executions of workflows shared with him', async () => { + const workflow = await testDb.createWorkflow({}, user1); + + const execution = await testDb.createSuccessfulExecution(workflow); + + await testDb.shareWorkflowWithUsers(workflow, [user2]); + + const response = await authUser2Agent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + }); }); describe('DELETE /executions/:id', () => { @@ -324,10 +385,8 @@ describe('GET /executions', () => { const savedExecutions = await testDb.createManyExecutions( 2, workflow, - // @ts-ignore testDb.createSuccessfulExecution, ); - // @ts-ignore await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfulExecution); const response = await authOwnerAgent.get(`/executions`).query({ @@ -362,4 +421,78 @@ describe('GET /executions', () => { expect(waitTill).toBeNull(); } }); + + test('owner should retrieve all executions regardless of ownership', async () => { + const [firstWorkflowForUser1, secondWorkflowForUser1] = await testDb.createManyWorkflows( + 2, + {}, + user1, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser1, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser1, testDb.createSuccessfulExecution); + + const [firstWorkflowForUser2, secondWorkflowForUser2] = await testDb.createManyWorkflows( + 2, + {}, + user2, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser2, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser2, testDb.createSuccessfulExecution); + + const response = await authOwnerAgent.get(`/executions`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(8); + expect(response.body.nextCursor).toBe(null); + }); + + test('member should not see executions of workflows not shared with him', async () => { + const [firstWorkflowForUser1, secondWorkflowForUser1] = await testDb.createManyWorkflows( + 2, + {}, + user1, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser1, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser1, testDb.createSuccessfulExecution); + + const [firstWorkflowForUser2, secondWorkflowForUser2] = await testDb.createManyWorkflows( + 2, + {}, + user2, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser2, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser2, testDb.createSuccessfulExecution); + + const response = await authUser1Agent.get(`/executions`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); + expect(response.body.nextCursor).toBe(null); + }); + + test('member should also see executions of workflows shared with him', async () => { + const [firstWorkflowForUser1, secondWorkflowForUser1] = await testDb.createManyWorkflows( + 2, + {}, + user1, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser1, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser1, testDb.createSuccessfulExecution); + + const [firstWorkflowForUser2, secondWorkflowForUser2] = await testDb.createManyWorkflows( + 2, + {}, + user2, + ); + await testDb.createManyExecutions(2, firstWorkflowForUser2, testDb.createSuccessfulExecution); + await testDb.createManyExecutions(2, secondWorkflowForUser2, testDb.createSuccessfulExecution); + + await testDb.shareWorkflowWithUsers(firstWorkflowForUser2, [user1]); + + const response = await authUser1Agent.get(`/executions`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(6); + expect(response.body.nextCursor).toBe(null); + }); }); diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index a3abb177aa2d7..fa52693b1b325 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -1,8 +1,9 @@ +import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; -import config from '@/config'; import type { User } from '@db/entities/User'; import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; +import { License } from '@/License'; import { randomEmail, randomName, randomValidPassword } from '../shared/random'; import * as testDb from '../shared/testDb'; import * as utils from '../shared/utils'; @@ -13,10 +14,10 @@ let authOwnerAgent: SuperAgentTest; async function enableSaml(enable: boolean) { await setSamlLoginEnabled(enable); - config.set('enterprise.features.saml', enable); } beforeAll(async () => { + Container.get(License).isSamlEnabled = () => true; const app = await utils.initTestServer({ endpointGroups: ['me', 'saml'] }); owner = await testDb.createOwner(); authOwnerAgent = utils.createAuthAgent(app)(owner); @@ -125,7 +126,7 @@ describe('Instance owner', () => { .send({ loginEnabled: true, }) - .expect(200); + .expect(500); expect(getCurrentAuthenticationMethod()).toBe('ldap'); }); diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index 3608225eaa1e1..37798aa4d0f83 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -42,7 +42,7 @@ export const ROUTES_REQUIRING_AUTHORIZATION: Readonly = [ 'POST /users', 'DELETE /users/123', 'POST /users/123/reinvite', - 'POST /owner/pre-setup', + 'GET /owner/pre-setup', 'POST /owner/setup', 'POST /owner/skip-setup', ]; diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index 866ef8492ea70..876670720d47f 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -18,6 +18,8 @@ export function randomApiKey() { const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)]; +export const randomInteger = (max = 1000) => Math.floor(Math.random() * max); + export const randomDigit = () => Math.floor(Math.random() * 10); export const randomPositiveDigit = (): number => { diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index c7fb165b3d1ed..95e7303010622 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -4,6 +4,7 @@ import { DataSourceOptions as ConnectionOptions, Repository, } from 'typeorm'; +import { Container } from 'typedi'; import config from '@/config'; import * as Db from '@/Db'; @@ -14,7 +15,7 @@ import { mysqlMigrations } from '@db/migrations/mysqldb'; import { postgresMigrations } from '@db/migrations/postgresdb'; import { sqliteMigrations } from '@db/migrations/sqlite'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; -import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; import { InstalledNodes } from '@db/entities/InstalledNodes'; import { InstalledPackages } from '@db/entities/InstalledPackages'; @@ -22,6 +23,7 @@ import type { Role } from '@db/entities/Role'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { RoleRepository } from '@db/repositories'; import { ICredentialsDb } from '@/Interfaces'; import { DB_INITIALIZATION_TIMEOUT } from './constants'; @@ -104,8 +106,7 @@ export async function terminate() { */ export async function truncate(collections: CollectionName[]) { for (const collection of collections) { - const repository: Repository = Db.collections[collection]; - await repository.delete({}); + await (Db.collections[collection] as Repository).delete({}); } } @@ -142,7 +143,7 @@ export async function saveCredential( } export async function shareCredentialWithUsers(credential: CredentialsEntity, users: User[]) { - const role = await Db.collections.Role.findOneBy({ scope: 'credential', name: 'user' }); + const role = await Container.get(RoleRepository).findCredentialUserRole(); const newSharedCredentials = users.map((user) => Db.collections.SharedCredentials.create({ userId: user.id, @@ -266,38 +267,23 @@ export async function addApiKey(user: User): Promise { // ---------------------------------- export async function getGlobalOwnerRole() { - return Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'global', - }); + return Container.get(RoleRepository).findGlobalOwnerRoleOrFail(); } export async function getGlobalMemberRole() { - return Db.collections.Role.findOneByOrFail({ - name: 'member', - scope: 'global', - }); + return Container.get(RoleRepository).findGlobalMemberRoleOrFail(); } export async function getWorkflowOwnerRole() { - return Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'workflow', - }); + return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); } export async function getWorkflowEditorRole() { - return Db.collections.Role.findOneByOrFail({ - name: 'editor', - scope: 'workflow', - }); + return Container.get(RoleRepository).findWorkflowEditorRoleOrFail(); } export async function getCredentialOwnerRole() { - return Db.collections.Role.findOneByOrFail({ - name: 'owner', - scope: 'credential', - }); + return Container.get(RoleRepository).findCredentialOwnerRoleOrFail(); } export async function getAllRoles() { @@ -489,6 +475,10 @@ export async function createWorkflowWithTrigger( return workflow; } +export async function getAllWorkflows() { + return Db.collections.Workflow.find(); +} + // ---------------------------------- // workflow sharing // ---------------------------------- @@ -499,6 +489,33 @@ export async function getWorkflowSharing(workflow: WorkflowEntity) { }); } +// ---------------------------------- +// variables +// ---------------------------------- + +export async function createVariable(key: string, value: string) { + return Db.collections.Variables.save({ + key, + value, + }); +} + +export async function getVariableByKey(key: string) { + return Db.collections.Variables.findOne({ + where: { + key, + }, + }); +} + +export async function getVariableById(id: number) { + return Db.collections.Variables.findOne({ + where: { + id, + }, + }); +} + // ---------------------------------- // connection options // ---------------------------------- @@ -512,7 +529,7 @@ export const getSqliteOptions = ({ name }: { name: string }): ConnectionOptions name, type: 'sqlite', database: ':memory:', - entityPrefix: '', + entityPrefix: config.getEnv('database.tablePrefix'), dropSchema: true, migrations: sqliteMigrations, migrationsTableName: 'migrations', @@ -525,6 +542,7 @@ const baseOptions = (type: TestDBType) => ({ port: config.getEnv(`database.${type}db.port`), username: config.getEnv(`database.${type}db.user`), password: config.getEnv(`database.${type}db.password`), + entityPrefix: config.getEnv('database.tablePrefix'), schema: type === 'postgres' ? config.getEnv('database.postgresdb.schema') : undefined, }); diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 97bb13843ddbd..62dfcd3a874f3 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -23,8 +23,10 @@ type EndpointGroup = | 'nodes' | 'ldap' | 'saml' + | 'versionControl' | 'eventBus' - | 'license'; + | 'license' + | 'variables'; export type CredentialPayload = { name: string; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index e8bf676ba7125..9e8e4aa2b1ab8 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -73,14 +73,17 @@ import { v4 as uuid } from 'uuid'; import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { PostHogClient } from '@/posthog'; +import { variablesController } from '@/environments/variables/variables.controller'; import { LdapManager } from '@/Ldap/LdapManager.ee'; -import { LDAP_ENABLED } from '@/Ldap/constants'; import { handleLdapInit } from '@/Ldap/helpers'; import { Push } from '@/push'; import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers'; import { SamlService } from '@/sso/saml/saml.service.ee'; import { SamlController } from '@/sso/saml/routes/saml.controller.ee'; import { EventBusController } from '@/eventbus/eventBus.controller'; +import { License } from '@/License'; +import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee'; +import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee'; export const mockInstance = ( ctor: new (...args: any[]) => T, @@ -151,6 +154,7 @@ export async function initTestServer({ credentials: { controller: credentialsController, path: 'credentials' }, workflows: { controller: workflowsController, path: 'workflows' }, license: { controller: licenseController, path: 'license' }, + variables: { controller: variablesController, path: 'variables' }, }; if (enablePublicAPI) { @@ -186,7 +190,7 @@ export async function initTestServer({ ); break; case 'ldap': - config.set(LDAP_ENABLED, true); + Container.get(License).isLdapEnabled = () => true; await handleLdapInit(); const { service, sync } = LdapManager.getInstance(); registerController( @@ -200,6 +204,14 @@ export async function initTestServer({ const samlService = Container.get(SamlService); registerController(testServer.app, config, new SamlController(samlService)); break; + case 'versionControl': + const versionControlService = Container.get(VersionControlService); + registerController( + testServer.app, + config, + new VersionControlController(versionControlService), + ); + break; case 'nodes': registerController( testServer.app, @@ -268,7 +280,7 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { const routerEndpoints: EndpointGroup[] = []; const functionEndpoints: EndpointGroup[] = []; - const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license']; + const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license', 'variables']; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), @@ -787,11 +799,11 @@ export function installedNodePayload(packageName: string): InstalledNodePayload }; } -export const emptyPackage = () => { +export const emptyPackage = async () => { const installedPackage = new InstalledPackages(); installedPackage.installedNodes = []; - return Promise.resolve(installedPackage); + return installedPackage; }; // ---------------------------------- diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 759d12b8cb42a..966bb6d1694a7 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -31,6 +31,7 @@ let credentialOwnerRole: Role; let owner: User; let authlessAgent: SuperAgentTest; let authOwnerAgent: SuperAgentTest; +let authAgentFor: (user: User) => SuperAgentTest; beforeAll(async () => { const app = await utils.initTestServer({ endpointGroups: ['users'] }); @@ -49,7 +50,8 @@ beforeAll(async () => { owner = await testDb.createUser({ globalRole: globalOwnerRole }); authlessAgent = utils.createAgent(app); - authOwnerAgent = utils.createAuthAgent(app)(owner); + authAgentFor = utils.createAuthAgent(app); + authOwnerAgent = authAgentFor(owner); }); beforeEach(async () => { @@ -69,7 +71,7 @@ afterAll(async () => { }); describe('GET /users', () => { - test('should return all users', async () => { + test('should return all users (for owner)', async () => { await testDb.createUser({ globalRole: globalMemberRole }); const response = await authOwnerAgent.get('/users'); @@ -103,6 +105,14 @@ describe('GET /users', () => { expect(apiKey).not.toBeDefined(); }); }); + + test('should return all users (for member)', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const response = await authAgentFor(member).get('/users'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + }); }); describe('DELETE /users/:id', () => { diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts new file mode 100644 index 0000000000000..46e0814622555 --- /dev/null +++ b/packages/cli/test/integration/variables.test.ts @@ -0,0 +1,379 @@ +import type { Application } from 'express'; + +import type { User } from '@/databases/entities/User'; +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils'; + +import type { AuthAgent } from './shared/types'; +import type { ClassLike, MockedClass } from 'jest-mock'; +import { License } from '@/License'; + +// mock that credentialsSharing is not enabled +let app: Application; +let ownerUser: User; +let memberUser: User; +let authAgent: AuthAgent; +let variablesSpy: jest.SpyInstance; +let licenseLike = { + isVariablesEnabled: jest.fn().mockReturnValue(true), + getVariablesLimit: jest.fn().mockReturnValue(-1), +}; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['variables'] }); + + utils.initConfigFile(); + utils.mockInstance(License, licenseLike); + + ownerUser = await testDb.createOwner(); + memberUser = await testDb.createUser(); + + authAgent = utils.createAuthAgent(app); +}); + +beforeEach(async () => { + await testDb.truncate(['Variables']); + licenseLike.isVariablesEnabled.mockReturnValue(true); + licenseLike.getVariablesLimit.mockReturnValue(-1); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +// ---------------------------------------- +// GET /variables - fetch all variables +// ---------------------------------------- + +test('GET /variables should return all variables for an owner', async () => { + await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response = await authAgent(ownerUser).get('/variables'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); +}); + +test('GET /variables should return all variables for a member', async () => { + await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response = await authAgent(memberUser).get('/variables'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); +}); + +// ---------------------------------------- +// GET /variables/:id - get a single variable +// ---------------------------------------- + +test('GET /variables/:id should return a single variable for an owner', async () => { + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response1 = await authAgent(ownerUser).get(`/variables/${var1.id}`); + expect(response1.statusCode).toBe(200); + expect(response1.body.data.key).toBe('test1'); + + const response2 = await authAgent(ownerUser).get(`/variables/${var2.id}`); + expect(response2.statusCode).toBe(200); + expect(response2.body.data.key).toBe('test2'); +}); + +test('GET /variables/:id should return a single variable for a member', async () => { + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response1 = await authAgent(memberUser).get(`/variables/${var1.id}`); + expect(response1.statusCode).toBe(200); + expect(response1.body.data.key).toBe('test1'); + + const response2 = await authAgent(memberUser).get(`/variables/${var2.id}`); + expect(response2.statusCode).toBe(200); + expect(response2.body.data.key).toBe('test2'); +}); + +// ---------------------------------------- +// POST /variables - create a new variable +// ---------------------------------------- + +test('POST /variables should create a new credential and return it for an owner', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toCreate.key); + expect(response.body.data.value).toBe(toCreate.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toCreate.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toCreate.key); + expect(byId!.value).toBe(toCreate.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toCreate.value); +}); + +test('POST /variables should not create a new credential and return it for a member', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(memberUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(401); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); + + const byKey = await testDb.getVariableByKey(toCreate.key); + expect(byKey).toBeNull(); +}); + +test("POST /variables should not create a new credential and return it if the instance doesn't have a license", async () => { + licenseLike.isVariablesEnabled.mockReturnValue(false); + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); + + const byKey = await testDb.getVariableByKey(toCreate.key); + expect(byKey).toBeNull(); +}); + +test('POST /variables should fail to create a new credential and if one with the same key exists', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + await testDb.createVariable(toCreate.key, toCreate.value); + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(500); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should not fail if variable limit not reached', async () => { + licenseLike.getVariablesLimit.mockReturnValue(5); + let i = 1; + let toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + while (i < 3) { + await testDb.createVariable(toCreate.key, toCreate.value); + i++; + toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + } + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(200); + expect(response.body.data?.key).toBe(toCreate.key); + expect(response.body.data?.value).toBe(toCreate.value); +}); + +test('POST /variables should fail if variable limit reached', async () => { + licenseLike.getVariablesLimit.mockReturnValue(5); + let i = 1; + let toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + while (i < 6) { + await testDb.createVariable(toCreate.key, toCreate.value); + i++; + toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + } + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should fail if key too long', async () => { + const toCreate = { + // 51 'a's + key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + value: 'value', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should fail if value too long', async () => { + const toCreate = { + key: 'key', + // 256 'a's + value: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test("POST /variables should fail if key contain's prohibited characters", async () => { + const toCreate = { + // 51 'a's + key: 'te$t', + value: 'value', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +// ---------------------------------------- +// PATCH /variables/:id - change a variable +// ---------------------------------------- + +test('PATCH /variables/:id should modify existing credential if use is an owner', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toModify.key); + expect(response.body.data.value).toBe(toModify.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toModify.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toModify.key); + expect(byId!.value).toBe(toModify.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toModify.value); +}); + +test('PATCH /variables/:id should modify existing credential if use is an owner', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toModify.key); + expect(response.body.data.value).toBe(toModify.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toModify.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toModify.key); + expect(byId!.value).toBe(toModify.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toModify.value); +}); + +test('PATCH /variables/:id should not modify existing credential if use is a member', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(memberUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(401); + expect(response.body.data?.key).not.toBe(toModify.key); + expect(response.body.data?.value).not.toBe(toModify.value); + + const byId = await testDb.getVariableById(variable.id); + expect(byId).not.toBeNull(); + expect(byId!.key).not.toBe(toModify.key); + expect(byId!.value).not.toBe(toModify.value); +}); + +test('PATCH /variables/:id should not modify existing credential if one with the same key exists', async () => { + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable(toModify.key, toModify.value), + ]); + const response = await authAgent(ownerUser).patch(`/variables/${var1.id}`).send(toModify); + expect(response.statusCode).toBe(500); + expect(response.body.data?.key).not.toBe(toModify.key); + expect(response.body.data?.value).not.toBe(toModify.value); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(var1.key); + expect(byId!.value).toBe(var1.value); +}); + +// ---------------------------------------- +// DELETE /variables/:id - change a variable +// ---------------------------------------- + +test('DELETE /variables/:id should delete a single credential for an owner', async () => { + const [var1, var2, var3] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + testDb.createVariable('test3', 'value3'), + ]); + + const delResponse = await authAgent(ownerUser).delete(`/variables/${var1.id}`); + expect(delResponse.statusCode).toBe(200); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).toBeNull(); + + const getResponse = await authAgent(ownerUser).get('/variables'); + expect(getResponse.body.data.length).toBe(2); +}); + +test('DELETE /variables/:id should not delete a single credential for a member', async () => { + const [var1, var2, var3] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + testDb.createVariable('test3', 'value3'), + ]); + + const delResponse = await authAgent(memberUser).delete(`/variables/${var1.id}`); + expect(delResponse.statusCode).toBe(401); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).not.toBeNull(); + + const getResponse = await authAgent(memberUser).get('/variables'); + expect(getResponse.body.data.length).toBe(3); +}); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index c544416eee6c2..bf8ff9948f2a3 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -1,10 +1,10 @@ +import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; import { v4 as uuid } from 'uuid'; import type { INode } from 'n8n-workflow'; import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import type { User } from '@db/entities/User'; -import config from '@/config'; import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; @@ -12,6 +12,7 @@ import { createWorkflow } from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import { makeWorkflow } from './shared/utils'; import { randomCredentialPayload } from './shared/random'; +import { License } from '@/License'; let owner: User; let member: User; @@ -23,6 +24,7 @@ let saveCredential: SaveCredentialFunction; let sharingSpy: jest.SpyInstance; beforeAll(async () => { + Container.get(License).isSharingEnabled = () => true; const app = await utils.initTestServer({ endpointGroups: ['workflows'] }); const globalOwnerRole = await testDb.getGlobalOwnerRole(); @@ -42,8 +44,6 @@ beforeAll(async () => { sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); await utils.initNodeTypes(); - - config.set('enterprise.features.sharing', true); }); beforeEach(async () => { @@ -303,7 +303,7 @@ describe('GET /workflows/:id', () => { { id: savedCredential.id, name: savedCredential.name, - currentUserHasAccess: false, // although owner can see, he does not have access + currentUserHasAccess: false, // although owner can see, they do not have access }, ]); diff --git a/packages/cli/test/unit/ActiveExecutions.test.ts b/packages/cli/test/unit/ActiveExecutions.test.ts index 19c1d316e6a5b..3f27677052db8 100644 --- a/packages/cli/test/unit/ActiveExecutions.test.ts +++ b/packages/cli/test/unit/ActiveExecutions.test.ts @@ -18,7 +18,7 @@ jest.mock('@/Db', () => { return { collections: { Execution: { - save: jest.fn(async () => Promise.resolve({ id: FAKE_EXECUTION_ID })), + save: jest.fn(async () => ({ id: FAKE_EXECUTION_ID })), update: jest.fn(), }, }, diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index e3290446e0f4f..ab1232cde6e62 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -11,10 +11,10 @@ import { import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import * as Db from '@/Db'; -import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; -import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; -import { Role } from '@/databases/entities/Role'; -import { User } from '@/databases/entities/User'; +import { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import { Role } from '@db/entities/Role'; +import { User } from '@db/entities/User'; import { getLogger } from '@/Logger'; import { randomEmail, randomName } from '../integration/shared/random'; import * as Helpers from './Helpers'; @@ -99,12 +99,11 @@ jest.mock('@/Db', () => { return { collections: { Workflow: { - find: jest.fn(async () => Promise.resolve(generateWorkflows(databaseActiveWorkflowsCount))), + find: jest.fn(async () => generateWorkflows(databaseActiveWorkflowsCount)), findOne: jest.fn(async (searchParams) => { - const foundWorkflow = databaseActiveWorkflowsList.find( + return databaseActiveWorkflowsList.find( (workflow) => workflow.id.toString() === searchParams.where.id.toString(), ); - return Promise.resolve(foundWorkflow); }), update: jest.fn(), createQueryBuilder: jest.fn(() => { @@ -112,7 +111,7 @@ jest.mock('@/Db', () => { update: () => fakeQueryBuilder, set: () => fakeQueryBuilder, where: () => fakeQueryBuilder, - execute: () => Promise.resolve(), + execute: async () => {}, }; return fakeQueryBuilder; }), @@ -121,6 +120,9 @@ jest.mock('@/Db', () => { clear: jest.fn(), delete: jest.fn(), }, + Variables: { + find: jest.fn(() => []), + }, }, }; }); @@ -243,7 +245,7 @@ describe('ActiveWorkflowRunner', () => { const workflow = generateWorkflows(1); const additionalData = await WorkflowExecuteAdditionalData.getBase('fake-user-id'); - workflowRunnerRun.mockImplementationOnce(() => Promise.resolve('invalid-execution-id')); + workflowRunnerRun.mockResolvedValueOnce('invalid-execution-id'); await activeWorkflowRunner.runWorkflow( workflow[0], diff --git a/packages/cli/test/unit/Events.test.ts b/packages/cli/test/unit/Events.test.ts index 25c28973523c6..d68aa19216389 100644 --- a/packages/cli/test/unit/Events.test.ts +++ b/packages/cli/test/unit/Events.test.ts @@ -1,28 +1,34 @@ import { IRun, LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow'; -import { QueryFailedError, Repository } from 'typeorm'; +import { QueryFailedError } from 'typeorm'; import { mock } from 'jest-mock-extended'; import config from '@/config'; import * as Db from '@/Db'; import { User } from '@db/entities/User'; import { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; +import { WorkflowStatisticsRepository } from '@db/repositories'; import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics'; import * as UserManagementHelper from '@/UserManagement/UserManagementHelper'; import { getLogger } from '@/Logger'; import { InternalHooks } from '@/InternalHooks'; import { mockInstance } from '../integration/shared/utils'; +import { UserService } from '@/user/user.service'; -type WorkflowStatisticsRepository = Repository; jest.mock('@/Db', () => { return { collections: { - WorkflowStatistics: mock(), + WorkflowStatistics: mock({ + metadata: { tableName: 'workflow_statistics' }, + }), }, }; }); +jest.spyOn(UserService, 'updateUserSettings').mockImplementation(); + describe('Events', () => { + const dbType = config.getEnv('database.type'); const fakeUser = Object.assign(new User(), { id: 'abcde-fghij' }); const internalHooks = mockInstance(InternalHooks); @@ -44,11 +50,28 @@ describe('Events', () => { }); beforeEach(() => { + if (dbType === 'sqlite') { + workflowStatisticsRepository.findOne.mockClear(); + } else { + workflowStatisticsRepository.query.mockClear(); + } + internalHooks.onFirstProductionWorkflowSuccess.mockClear(); internalHooks.onFirstWorkflowDataLoad.mockClear(); }); - afterEach(() => {}); + const mockDBCall = (count = 1) => { + if (dbType === 'sqlite') { + workflowStatisticsRepository.findOne.mockResolvedValueOnce( + mock({ count }), + ); + } else { + const result = dbType === 'postgresdb' ? [{ count }] : { affectedRows: count }; + workflowStatisticsRepository.query.mockImplementationOnce(async (query) => + query.startsWith('INSERT INTO') ? result : null, + ); + } + }; describe('workflowExecutionCompleted', () => { test('should create metrics for production successes', async () => { @@ -69,6 +92,8 @@ describe('Events', () => { mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), }; + mockDBCall(); + await workflowExecutionCompleted(workflow, runData); expect(internalHooks.onFirstProductionWorkflowSuccess).toBeCalledTimes(1); expect(internalHooks.onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, { @@ -101,9 +126,6 @@ describe('Events', () => { test('should not send metrics for updated entries', async () => { // Call the function with a fail insert, ensure update is called *and* metrics aren't sent - workflowStatisticsRepository.insert.mockImplementationOnce(() => { - throw new QueryFailedError('invalid insert', [], ''); - }); const workflow = { id: '1', name: '', @@ -120,6 +142,7 @@ describe('Events', () => { mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), }; + mockDBCall(2); await workflowExecutionCompleted(workflow, runData); expect(internalHooks.onFirstProductionWorkflowSuccess).toBeCalledTimes(0); }); diff --git a/packages/cli/test/unit/VersionControl.test.ts b/packages/cli/test/unit/VersionControl.test.ts new file mode 100644 index 0000000000000..6e880859ff298 --- /dev/null +++ b/packages/cli/test/unit/VersionControl.test.ts @@ -0,0 +1,11 @@ +import { generateSshKeyPair } from '../../src/environments/versionControl/versionControlHelper'; + +describe('Version Control', () => { + it('should generate an SSH key pair', () => { + const keyPair = generateSshKeyPair(); + expect(keyPair.privateKey).toBeTruthy(); + expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + expect(keyPair.publicKey).toBeTruthy(); + expect(keyPair.publicKey).toContain('ssh-ed25519'); + }); +}); diff --git a/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts b/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts index 9d364bd788939..6007c0dd74b81 100644 --- a/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts +++ b/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts @@ -6,7 +6,7 @@ jest.mock('@/Db', () => { return { collections: { ExecutionMetadata: { - save: jest.fn(async () => Promise.resolve([])), + save: jest.fn(async () => []), }, }, }; diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/test/unit/controllers/me.controller.test.ts index 4acfbaa5092ba..20b8fdfb92d5e 100644 --- a/packages/cli/test/unit/controllers/me.controller.test.ts +++ b/packages/cli/test/unit/controllers/me.controller.test.ts @@ -5,6 +5,7 @@ import { mock, anyObject, captor } from 'jest-mock-extended'; import type { ILogger } from 'n8n-workflow'; import type { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import type { User } from '@db/entities/User'; +import { UserRepository } from '@db/repositories'; import { MeController } from '@/controllers'; import { AUTH_COOKIE_NAME } from '@/constants'; import { BadRequestError } from '@/ResponseHelper'; @@ -15,7 +16,7 @@ describe('MeController', () => { const logger = mock(); const externalHooks = mock(); const internalHooks = mock(); - const userRepository = mock>(); + const userRepository = mock(); const controller = new MeController({ logger, externalHooks, diff --git a/packages/cli/test/unit/controllers/owner.controller.test.ts b/packages/cli/test/unit/controllers/owner.controller.test.ts index a460dc8bc5330..0ef8036e07b8e 100644 --- a/packages/cli/test/unit/controllers/owner.controller.test.ts +++ b/packages/cli/test/unit/controllers/owner.controller.test.ts @@ -1,12 +1,15 @@ -import type { Repository } from 'typeorm'; import type { CookieOptions, Response } from 'express'; import { anyObject, captor, mock } from 'jest-mock-extended'; import type { ILogger } from 'n8n-workflow'; import jwt from 'jsonwebtoken'; -import type { ICredentialsDb, IInternalHooksClass } from '@/Interfaces'; +import type { IInternalHooksClass } from '@/Interfaces'; import type { User } from '@db/entities/User'; -import type { Settings } from '@db/entities/Settings'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { + CredentialsRepository, + SettingsRepository, + UserRepository, + WorkflowRepository, +} from '@db/repositories'; import type { Config } from '@/config'; import { BadRequestError } from '@/ResponseHelper'; import type { OwnerRequest } from '@/requests'; @@ -18,10 +21,10 @@ describe('OwnerController', () => { const config = mock(); const logger = mock(); const internalHooks = mock(); - const userRepository = mock>(); - const settingsRepository = mock>(); - const credentialsRepository = mock>(); - const workflowsRepository = mock>(); + const userRepository = mock(); + const settingsRepository = mock(); + const credentialsRepository = mock(); + const workflowsRepository = mock(); const controller = new OwnerController({ config, logger, diff --git a/packages/cli/test/unit/repositories/role.repository.test.ts b/packages/cli/test/unit/repositories/role.repository.test.ts new file mode 100644 index 0000000000000..ad37f797fc0c7 --- /dev/null +++ b/packages/cli/test/unit/repositories/role.repository.test.ts @@ -0,0 +1,47 @@ +import { Container } from 'typedi'; +import { DataSource, EntityManager } from 'typeorm'; +import { mock } from 'jest-mock-extended'; +import { Role, RoleNames, RoleScopes } from '@db/entities/Role'; +import { RoleRepository } from '@db/repositories/role.repository'; +import { mockInstance } from '../../integration/shared/utils'; +import { randomInteger } from '../../integration/shared/random'; + +describe('RoleRepository', () => { + const entityManager = mockInstance(EntityManager); + const dataSource = mockInstance(DataSource, { manager: entityManager }); + dataSource.getMetadata.mockReturnValue(mock()); + Object.assign(entityManager, { connection: dataSource }); + const roleRepository = Container.get(RoleRepository); + + describe('findRole', () => { + test('should return the role when present', async () => { + entityManager.findOne.mockResolvedValueOnce(createRole('global', 'owner')); + const role = await roleRepository.findRole('global', 'owner'); + expect(role?.name).toEqual('owner'); + expect(role?.scope).toEqual('global'); + }); + + test('should return null otherwise', async () => { + entityManager.findOne.mockResolvedValueOnce(null); + const role = await roleRepository.findRole('global', 'owner'); + expect(role).toEqual(null); + }); + }); + + describe('findRoleOrFail', () => { + test('should return the role when present', async () => { + entityManager.findOneOrFail.mockResolvedValueOnce(createRole('global', 'owner')); + const role = await roleRepository.findRoleOrFail('global', 'owner'); + expect(role?.name).toEqual('owner'); + expect(role?.scope).toEqual('global'); + }); + + test('should throw otherwise', async () => { + entityManager.findOneOrFail.mockRejectedValueOnce(new Error()); + expect(() => roleRepository.findRoleOrFail('global', 'owner')).rejects.toThrow(); + }); + }); + + const createRole = (scope: RoleScopes, name: RoleNames) => + Object.assign(new Role(), { name, scope, id: `${randomInteger()}` }); +}); diff --git a/packages/cli/test/unit/services/role.service.test.ts b/packages/cli/test/unit/services/role.service.test.ts new file mode 100644 index 0000000000000..483be386ae0a6 --- /dev/null +++ b/packages/cli/test/unit/services/role.service.test.ts @@ -0,0 +1,28 @@ +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { Role } from '@db/entities/Role'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import { RoleService } from '@/role/role.service'; +import { mockInstance } from '../../integration/shared/utils'; + +describe('RoleService', () => { + const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); + const roleService = new RoleService(sharedWorkflowRepository); + + const userId = '1'; + const workflowId = '42'; + + describe('getUserRoleForWorkflow', () => { + test('should return the role if a shared workflow is found', async () => { + const sharedWorkflow = Object.assign(new SharedWorkflow(), { role: new Role() }); + sharedWorkflowRepository.findOne.mockResolvedValueOnce(sharedWorkflow); + const role = await roleService.getUserRoleForWorkflow(userId, workflowId); + expect(role).toBe(sharedWorkflow.role); + }); + + test('should return undefined if no shared workflow is found', async () => { + sharedWorkflowRepository.findOne.mockResolvedValueOnce(null); + const role = await roleService.getUserRoleForWorkflow(userId, workflowId); + expect(role).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index a1a1b8155d206..4d68086144d56 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -11,8 +11,6 @@ module.exports = { ignorePatterns: ['bin/*.js'], rules: { - '@typescript-eslint/consistent-type-imports': 'error', - // TODO: Remove this 'import/order': 'off', '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': true }], diff --git a/packages/core/README.md b/packages/core/README.md index 6a2b272e43522..1eeb8579e4856 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -12,4 +12,6 @@ npm install n8n-core n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). +Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) + Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index ba7b020b6afc4..76ceae31fc16d 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -9,25 +9,73 @@ LoggerProxy.init({ warn: console.warn.bind(console), }); +function findReferencedMethods(obj, refs = {}, latestName = '') { + for (const key in obj) { + if (key === 'name' && 'group' in obj) { + latestName = obj[key]; + } + + if (typeof obj[key] === 'object') { + findReferencedMethods(obj[key], refs, latestName); + } + + if (key === 'loadOptionsMethod') { + refs[latestName] = refs[latestName] + ? [...new Set([...refs[latestName], obj[key]])] + : [obj[key]]; + } + } + + return refs; +} + (async () => { const loader = new PackageDirectoryLoader(packageDir); await loader.loadAll(); const credentialTypes = Object.values(loader.credentialTypes).map((data) => data.type); - const nodeTypes = Object.values(loader.nodeTypes) + const loaderNodeTypes = Object.values(loader.nodeTypes); + + const definedMethods = loaderNodeTypes.reduce((acc, cur) => { + NodeHelpers.getVersionedNodeTypeAll(cur.type).forEach((type) => { + const methods = type.description?.__loadOptionsMethods; + + if (!methods) return; + + const { name } = type.description; + + if (acc[name]) { + acc[name] = [...new Set([...acc[name], ...methods])]; + return; + } + + acc[name] = methods; + }); + + return acc; + }, {}); + + const nodeTypes = loaderNodeTypes .map((data) => { const nodeType = NodeHelpers.getVersionedNodeType(data.type); NodeHelpers.applySpecialNodeParameters(nodeType); return data.type; }) .flatMap((nodeData) => { - const allNodeTypes = NodeHelpers.getVersionedNodeTypeAll(nodeData); - return allNodeTypes.map((element) => element.description); + return NodeHelpers.getVersionedNodeTypeAll(nodeData).map((item) => { + const { __loadOptionsMethods, ...rest } = item.description; + + return rest; + }); }); + const referencedMethods = findReferencedMethods(nodeTypes); + await Promise.all([ writeJSON('types/credentials.json', credentialTypes), writeJSON('types/nodes.json', nodeTypes), + writeJSON('methods/defined.json', definedMethods), + writeJSON('methods/referenced.json', referencedMethods), ]); })(); diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 6d9cddf3641eb..a066fe093cf0f 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -1,2 +1,5 @@ /** @type {import('jest').Config} */ -module.exports = require('../../jest.config'); +module.exports = { + ...require('../../jest.config'), + globalSetup: '/test/setup.ts', +}; diff --git a/packages/core/package.json b/packages/core/package.json index 86086ec6d8fde..c1d1a9f30f0b5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.160.0", + "version": "0.164.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index df033569752f1..011a5cd5eedae 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -134,63 +134,58 @@ export class BinaryDataFileSystem implements IBinaryDataManager { const execsAdded: { [key: string]: number } = {}; - const proms = metaFileNames.reduce( - (prev, curr) => { - const [prefix, executionId, ts] = curr.split('_'); + const promises = metaFileNames.reduce>>((prev, curr) => { + const [prefix, executionId, ts] = curr.split('_'); - if (prefix !== filePrefix) { + if (prefix !== filePrefix) { + return prev; + } + + const execTimestamp = parseInt(ts, 10); + + if (execTimestamp < currentTimeValue) { + if (execsAdded[executionId]) { + // do not delete data, only meta file + prev.push(this.deleteMetaFileByPath(path.join(metaPath, curr))); return prev; } - const execTimestamp = parseInt(ts, 10); - - if (execTimestamp < currentTimeValue) { - if (execsAdded[executionId]) { - // do not delete data, only meta file - prev.push(this.deleteMetaFileByPath(path.join(metaPath, curr))); - return prev; - } - - execsAdded[executionId] = 1; - prev.push( - this.deleteBinaryDataByExecutionId(executionId).then(async () => - this.deleteMetaFileByPath(path.join(metaPath, curr)), - ), - ); - } + execsAdded[executionId] = 1; + prev.push( + this.deleteBinaryDataByExecutionId(executionId).then(async () => + this.deleteMetaFileByPath(path.join(metaPath, curr)), + ), + ); + } - return prev; - }, - [Promise.resolve()], - ); + return prev; + }, []); - return Promise.all(proms).then(() => {}); + await Promise.all(promises); } async duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise { const newBinaryDataId = this.generateFileName(prefix); - return fs - .copyFile(this.resolveStoragePath(binaryDataId), this.resolveStoragePath(newBinaryDataId)) - .then(() => newBinaryDataId); + await fs.copyFile( + this.resolveStoragePath(binaryDataId), + this.resolveStoragePath(newBinaryDataId), + ); + return newBinaryDataId; } async deleteBinaryDataByExecutionId(executionId: string): Promise { const regex = new RegExp(`${executionId}_*`); const filenames = await fs.readdir(this.storagePath); - const proms = filenames.reduce( - (allProms, filename) => { - if (regex.test(filename)) { - allProms.push(fs.rm(this.resolveStoragePath(filename))); - } - - return allProms; - }, - [Promise.resolve()], - ); + const promises = filenames.reduce>>((allProms, filename) => { + if (regex.test(filename)) { + allProms.push(fs.rm(this.resolveStoragePath(filename))); + } + return allProms; + }, []); - return Promise.all(proms).then(async () => Promise.resolve()); + await Promise.all(promises); } async deleteBinaryDataByIdentifier(identifier: string): Promise { @@ -198,20 +193,17 @@ export class BinaryDataFileSystem implements IBinaryDataManager { } async persistBinaryDataForExecutionId(executionId: string): Promise { - return fs.readdir(this.getBinaryDataPersistMetaPath()).then(async (metafiles) => { - const proms = metafiles.reduce( - (prev, curr) => { - if (curr.startsWith(`${PREFIX_PERSISTED_METAFILE}_${executionId}_`)) { - prev.push(fs.rm(path.join(this.getBinaryDataPersistMetaPath(), curr))); - return prev; - } - + return fs.readdir(this.getBinaryDataPersistMetaPath()).then(async (metaFiles) => { + const promises = metaFiles.reduce>>((prev, curr) => { + if (curr.startsWith(`${PREFIX_PERSISTED_METAFILE}_${executionId}_`)) { + prev.push(fs.rm(path.join(this.getBinaryDataPersistMetaPath(), curr))); return prev; - }, - [Promise.resolve()], - ); + } + + return prev; + }, []); - return Promise.all(proms).then(() => {}); + await Promise.all(promises); }); } @@ -227,8 +219,8 @@ export class BinaryDataFileSystem implements IBinaryDataManager { return path.join(this.storagePath, 'persistMeta'); } - private async deleteMetaFileByPath(metafilePath: string): Promise { - return fs.rm(metafilePath); + private async deleteMetaFileByPath(metaFilePath: string): Promise { + return fs.rm(metaFilePath); } private async deleteFromLocalStorage(identifier: string) { diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index b3ddb8738d6c6..bed5a08b101d0 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -158,38 +158,30 @@ export class BinaryDataManager { async markDataForDeletionByExecutionId(executionId: string): Promise { if (this.managers[this.binaryDataMode]) { - return this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(executionId); + await this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(executionId); } - - return Promise.resolve(); } async markDataForDeletionByExecutionIds(executionIds: string[]): Promise { if (this.managers[this.binaryDataMode]) { - return Promise.all( + await Promise.all( executionIds.map(async (id) => this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(id), ), - ).then(() => {}); + ); } - - return Promise.resolve(); } async persistBinaryDataForExecutionId(executionId: string): Promise { if (this.managers[this.binaryDataMode]) { - return this.managers[this.binaryDataMode].persistBinaryDataForExecutionId(executionId); + await this.managers[this.binaryDataMode].persistBinaryDataForExecutionId(executionId); } - - return Promise.resolve(); } async deleteBinaryDataByExecutionId(executionId: string): Promise { if (this.managers[this.binaryDataMode]) { - return this.managers[this.binaryDataMode].deleteBinaryDataByExecutionId(executionId); + await this.managers[this.binaryDataMode].deleteBinaryDataByExecutionId(executionId); } - - return Promise.resolve(); } async duplicateBinaryData( @@ -201,7 +193,7 @@ export class BinaryDataManager { async (executionDataArray) => { if (executionDataArray) { return Promise.all( - executionDataArray.map((executionData) => { + executionDataArray.map(async (executionData) => { if (executionData.binary) { return this.duplicateBinaryDataInExecData(executionData, executionId); } @@ -218,7 +210,7 @@ export class BinaryDataManager { return Promise.all(returnInputData); } - return Promise.resolve(inputData as INodeExecutionData[][]); + return inputData as INodeExecutionData[][]; } private generateBinaryId(filename: string) { diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index 296214a029794..0fba8399d33d9 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -100,6 +100,10 @@ export abstract class DirectoryLoader { this.fixIconPath(versionNode.description, filePath); } + for (const version of Object.values(tempNode.nodeVersions)) { + this.addLoadOptionsMethods(version); + } + const currentVersionNode = tempNode.nodeVersions[tempNode.currentVersion]; this.addCodex({ node: currentVersionNode, filePath, isCustom }); nodeVersion = tempNode.currentVersion; @@ -111,6 +115,7 @@ export abstract class DirectoryLoader { ); } } else { + this.addLoadOptionsMethods(tempNode); // Short renaming to avoid type issues nodeVersion = Array.isArray(tempNode.description.version) @@ -244,6 +249,12 @@ export abstract class DirectoryLoader { } } + private addLoadOptionsMethods(node: INodeType) { + if (node?.methods?.loadOptions) { + node.description.__loadOptionsMethods = Object.keys(node.methods.loadOptions); + } + } + private fixIconPath( obj: INodeTypeDescription | INodeTypeBaseDescription | ICredentialType, filePath: string, diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 147cc61fb999d..ec78d8a1dc653 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -77,6 +77,7 @@ import { import pick from 'lodash.pick'; import { Agent } from 'https'; +import { IncomingMessage } from 'http'; import { stringify } from 'qs'; import type { Token } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; @@ -577,7 +578,13 @@ function digestAuthAxiosConfig( return axiosConfig; } -async function proxyRequestToAxios( +type ConfigObject = { + auth?: { sendImmediately: boolean }; + resolveWithFullResponse?: boolean; + simple?: boolean; +}; + +export async function proxyRequestToAxios( workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, node: INode, @@ -597,12 +604,6 @@ async function proxyRequestToAxios( maxBodyLength: Infinity, maxContentLength: Infinity, }; - - type ConfigObject = { - auth?: { sendImmediately: boolean }; - resolveWithFullResponse?: boolean; - simple?: boolean; - }; let configObject: ConfigObject; if (uriOrObject !== undefined && typeof uriOrObject === 'string') { axiosConfig.url = uriOrObject; @@ -706,12 +707,17 @@ async function proxyRequestToAxios( } const message = `${response.status as number} - ${JSON.stringify(responseData)}`; - throw Object.assign(new Error(message, { cause: error }), { - status: response.status, + throw Object.assign(error, { + message, + statusCode: response.status, options: pick(config ?? {}, ['url', 'method', 'data', 'headers']), + error: responseData, + config: undefined, + request: undefined, + response: pick(response, ['headers', 'status', 'statusText']), }); } else { - throw Object.assign(new Error(error.message, { cause: error }), { + throw Object.assign(error, { options: pick(config ?? {}, ['url', 'method', 'data', 'headers']), }); } @@ -819,15 +825,31 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest async function httpRequest( requestOptions: IHttpRequestOptions, ): Promise { - const axiosRequest = convertN8nRequestToAxios(requestOptions); + let axiosRequest = convertN8nRequestToAxios(requestOptions); if ( axiosRequest.data === undefined || (axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET') ) { delete axiosRequest.data; } + let result: AxiosResponse; + try { + result = await axios(axiosRequest); + } catch (error) { + if (requestOptions.auth?.sendImmediately === false) { + const { response } = error; + if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) { + throw error; + } + + const { auth } = axiosRequest; + delete axiosRequest.auth; + axiosRequest = digestAuthAxiosConfig(axiosRequest, response, auth); + result = await axios(axiosRequest); + } + throw error; + } - const result = await axios(axiosRequest); if (requestOptions.returnFullResponse) { return { body: result.data, @@ -836,6 +858,7 @@ async function httpRequest( statusMessage: result.statusText, }; } + return result.data; } @@ -932,15 +955,17 @@ export async function copyBinaryFile( fileExtension = fileTypeData.ext; } } + } - if (!mimeType) { - // Fall back to text - mimeType = 'text/plain'; - } - } else if (!fileExtension) { + if (!fileExtension && mimeType) { fileExtension = extension(mimeType) || undefined; } + if (!mimeType) { + // Fall back to text + mimeType = 'text/plain'; + } + const returnData: IBinaryData = { mimeType, fileType: fileTypeFromMimeType(mimeType), @@ -979,24 +1004,31 @@ async function prepareBinaryData( } } - // TODO: detect filetype from streams - if (!mimeType && Buffer.isBuffer(binaryData)) { - // Use buffer to guess mime type - const fileTypeData = await FileType.fromBuffer(binaryData); - if (fileTypeData) { - mimeType = fileTypeData.mime; - fileExtension = fileTypeData.ext; + if (!mimeType) { + if (Buffer.isBuffer(binaryData)) { + // Use buffer to guess mime type + const fileTypeData = await FileType.fromBuffer(binaryData); + if (fileTypeData) { + mimeType = fileTypeData.mime; + fileExtension = fileTypeData.ext; + } + } else if (binaryData instanceof IncomingMessage) { + mimeType = binaryData.headers['content-type']; + } else { + // TODO: detect filetype from other kind of streams } } + } - if (!mimeType) { - // Fall back to text - mimeType = 'text/plain'; - } - } else if (!fileExtension) { + if (!fileExtension && mimeType) { fileExtension = extension(mimeType) || undefined; } + if (!mimeType) { + // Fall back to text + mimeType = 'text/plain'; + } + const returnData: IBinaryData = { mimeType, fileType: fileTypeFromMimeType(mimeType), @@ -1107,7 +1139,6 @@ export async function requestOAuth2( [oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken, }); } - if (isN8nRequest) { return this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { if (error.response?.status === 401) { @@ -1169,85 +1200,95 @@ export async function requestOAuth2( throw error; }); } - - return this.helpers.request(newRequestOptions).catch(async (error: IResponseError) => { - const statusCodeReturned = - oAuth2Options?.tokenExpiredStatusCode === undefined - ? 401 - : oAuth2Options?.tokenExpiredStatusCode; - - if (error.statusCode === statusCodeReturned) { - // Token is probably not valid anymore. So try refresh it. - - const tokenRefreshOptions: IDataObject = {}; - - if (oAuth2Options?.includeCredentialsOnRefreshOnBody) { - const body: IDataObject = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, - }; - tokenRefreshOptions.body = body; - // Override authorization property so the credentials are not included in it - tokenRefreshOptions.headers = { - Authorization: '', - }; + return this.helpers + .request(newRequestOptions) + .then((response) => { + const requestOptions = newRequestOptions as any; + if ( + requestOptions.resolveWithFullResponse === true && + requestOptions.simple === false && + response.statusCode === + (oAuth2Options?.tokenExpiredStatusCode === undefined + ? 401 + : oAuth2Options?.tokenExpiredStatusCode) + ) { + throw response; } + return response; + }) + .catch(async (error: IResponseError) => { + const statusCodeReturned = + oAuth2Options?.tokenExpiredStatusCode === undefined + ? 401 + : oAuth2Options?.tokenExpiredStatusCode; + if (error.statusCode === statusCodeReturned) { + // Token is probably not valid anymore. So try refresh it. + const tokenRefreshOptions: IDataObject = {}; + if (oAuth2Options?.includeCredentialsOnRefreshOnBody) { + const body: IDataObject = { + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + }; + tokenRefreshOptions.body = body; + // Override authorization property so the credentials are not included in it + tokenRefreshOptions.headers = { + Authorization: '', + }; + } + Logger.debug( + `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, + ); - Logger.debug( - `OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`, - ); - - let newToken; + let newToken; - // if it's OAuth2 with client credentials grant type, get a new token - // instead of refreshing it. - if (OAuth2GrantType.clientCredentials === credentials.grantType) { - newToken = await getClientCredentialsToken(token.client, credentials); - } else { - newToken = await token.refresh(tokenRefreshOptions); - } + // if it's OAuth2 with client credentials grant type, get a new token + // instead of refreshing it. + if (OAuth2GrantType.clientCredentials === credentials.grantType) { + newToken = await getClientCredentialsToken(token.client, credentials); + } else { + newToken = await token.refresh(tokenRefreshOptions); + } + Logger.debug( + `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, + ); - Logger.debug( - `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, - ); + credentials.oauthTokenData = newToken.data; - credentials.oauthTokenData = newToken.data; + // Find the credentials + if (!node.credentials?.[credentialsType]) { + throw new Error( + `The node "${node.name}" does not have credentials of type "${credentialsType}"!`, + ); + } + const nodeCredentials = node.credentials[credentialsType]; - // Find the credentials - if (!node.credentials?.[credentialsType]) { - throw new Error( - `The node "${node.name}" does not have credentials of type "${credentialsType}"!`, + // Save the refreshed token + await additionalData.credentialsHelper.updateCredentials( + nodeCredentials, + credentialsType, + credentials as unknown as ICredentialDataDecryptedObject, ); - } - const nodeCredentials = node.credentials[credentialsType]; - // Save the refreshed token - await additionalData.credentialsHelper.updateCredentials( - nodeCredentials, - credentialsType, - credentials as unknown as ICredentialDataDecryptedObject, - ); + Logger.debug( + `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`, + ); - Logger.debug( - `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`, - ); + // Make the request again with the new token + const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject); + newRequestOptions.headers = newRequestOptions.headers ?? {}; - // Make the request again with the new token - const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject); - newRequestOptions.headers = newRequestOptions.headers ?? {}; + if (oAuth2Options?.keyToIncludeInAccessTokenHeader) { + Object.assign(newRequestOptions.headers, { + [oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken, + }); + } - if (oAuth2Options?.keyToIncludeInAccessTokenHeader) { - Object.assign(newRequestOptions.headers, { - [oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken, - }); + return this.helpers.request(newRequestOptions); } - return this.helpers.request(newRequestOptions); - } - - // Unknown error so simply throw it - throw error; - }); + // Unknown error so simply throw it + throw error; + }); } /** @@ -1634,10 +1675,24 @@ export function getAdditionalKeys( customData: runExecutionData ? { set(key: string, value: string): void { - setWorkflowExecutionMetadata(runExecutionData, key, value); + try { + setWorkflowExecutionMetadata(runExecutionData, key, value); + } catch (e) { + if (mode === 'manual') { + throw e; + } + Logger.verbose(e.message); + } }, setAll(obj: Record): void { - setAllWorkflowExecutionMetadata(runExecutionData, obj); + try { + setAllWorkflowExecutionMetadata(runExecutionData, obj); + } catch (e) { + if (mode === 'manual') { + throw e; + } + Logger.verbose(e.message); + } }, get(key: string): string { return getWorkflowExecutionMetadata(runExecutionData, key); @@ -1648,6 +1703,7 @@ export function getAdditionalKeys( } : undefined, }, + $vars: additionalData.variables, // deprecated $executionId: executionId, @@ -1982,6 +2038,7 @@ const getCommonWorkflowFunctions = ( additionalData: IWorkflowExecuteAdditionalData, ): Omit => ({ logger: Logger, + getExecutionId: () => additionalData.executionId!, getNode: () => deepCopy(node), getWorkflow: () => ({ id: workflow.id, @@ -2287,7 +2344,6 @@ export function getExecuteFunctions( getContext(type: string): IContextObject { return NodeHelpers.getContext(runExecutionData, type, node); }, - getExecutionId: () => additionalData.executionId!, getInputData: (inputIndex = 0, inputName = 'main') => { if (!inputData.hasOwnProperty(inputName)) { // Return empty array because else it would throw error when nothing is connected to input diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 17f09682036b6..00e7a3f84f01c 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -746,7 +746,9 @@ export class WorkflowExecute { const returnPromise = (async () => { try { - await this.executeHook('workflowExecuteBefore', [workflow]); + if (!this.additionalData.restartExecutionId) { + await this.executeHook('workflowExecuteBefore', [workflow]); + } } catch (error) { // Set the error that it can be saved correctly executionError = { @@ -790,7 +792,7 @@ export class WorkflowExecute { } if (gotCancel) { - return Promise.resolve(); + return; } nodeSuccessData = null; @@ -917,7 +919,7 @@ export class WorkflowExecute { for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) { if (gotCancel) { - return Promise.resolve(); + return; } try { if (tryIndex !== 0) { @@ -1173,10 +1175,8 @@ export class WorkflowExecute { outputIndex ]) { if (!workflow.nodes.hasOwnProperty(connectionData.node)) { - return Promise.reject( - new Error( - `The node "${executionNode.name}" connects to not found node "${connectionData.node}"`, - ), + throw new Error( + `The node "${executionNode.name}" connects to not found node "${connectionData.node}"`, ); } @@ -1210,7 +1210,7 @@ export class WorkflowExecute { ]); } - return Promise.resolve(); + return; })() .then(async () => { if (gotCancel && executionError === undefined) { diff --git a/packages/core/src/WorkflowExecutionMetadata.ts b/packages/core/src/WorkflowExecutionMetadata.ts index 146d5418d20d3..a6c186a20dd89 100644 --- a/packages/core/src/WorkflowExecutionMetadata.ts +++ b/packages/core/src/WorkflowExecutionMetadata.ts @@ -1,7 +1,20 @@ import type { IRunExecutionData } from 'n8n-workflow'; +import { LoggerProxy as Logger } from 'n8n-workflow'; export const KV_LIMIT = 10; +export class ExecutionMetadataValidationError extends Error { + constructor( + public type: 'key' | 'value', + key: unknown, + message?: string, + options?: ErrorOptions, + ) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + super(message ?? `Custom data ${type}s must be a string (key "${key}")`, options); + } +} + export function setWorkflowExecutionMetadata( executionData: IRunExecutionData, key: string, @@ -17,16 +30,44 @@ export function setWorkflowExecutionMetadata( ) { return; } - executionData.resultData.metadata[String(key).slice(0, 50)] = String(value).slice(0, 255); + if (typeof key !== 'string') { + throw new ExecutionMetadataValidationError('key', key); + } + if (key.replace(/[A-Za-z0-9_]/g, '').length !== 0) { + throw new ExecutionMetadataValidationError( + 'key', + key, + `Custom date key can only contain characters "A-Za-z0-9_" (key "${key}")`, + ); + } + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') { + throw new ExecutionMetadataValidationError('value', key); + } + const val = String(value); + if (key.length > 50) { + Logger.error('Custom data key over 50 characters long. Truncating to 50 characters.'); + } + if (val.length > 255) { + Logger.error('Custom data value over 255 characters long. Truncating to 255 characters.'); + } + executionData.resultData.metadata[key.slice(0, 50)] = val.slice(0, 255); } export function setAllWorkflowExecutionMetadata( executionData: IRunExecutionData, obj: Record, ) { - Object.entries(obj).forEach(([key, value]) => - setWorkflowExecutionMetadata(executionData, key, value), - ); + const errors: Error[] = []; + Object.entries(obj).forEach(([key, value]) => { + try { + setWorkflowExecutionMetadata(executionData, key, value); + } catch (e) { + errors.push(e as Error); + } + }); + if (errors.length) { + throw errors[0]; + } } export function getAllWorkflowExecutionMetadata( diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index 8c4cb22a8bf70..c38362b8278a6 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -287,12 +287,12 @@ class NodeTypesClass implements INodeTypes { options: [ { name: 'ALL', - description: 'Only if all conditions are meet it goes into "true" branch.', + description: 'Only if all conditions are met it goes into "true" branch.', value: 'all', }, { name: 'ANY', - description: 'If any of the conditions is meet it goes into "true" branch.', + description: 'If any of the conditions is met it goes into "true" branch.', value: 'any', }, ], diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 731db0061afb0..aff7ba036679b 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -1,15 +1,28 @@ +import nock from 'nock'; import { join } from 'path'; import { tmpdir } from 'os'; import { readFileSync, mkdtempSync } from 'fs'; - -import { IBinaryData, ITaskDataConnections } from 'n8n-workflow'; +import { mock } from 'jest-mock-extended'; +import type { + IBinaryData, + INode, + ITaskDataConnections, + IWorkflowExecuteAdditionalData, + Workflow, + WorkflowHooks, +} from 'n8n-workflow'; import { BinaryDataManager } from '@/BinaryDataManager'; -import * as NodeExecuteFunctions from '@/NodeExecuteFunctions'; +import { + setBinaryDataBuffer, + getBinaryDataBuffer, + proxyRequestToAxios, +} from '@/NodeExecuteFunctions'; +import { initLogger } from './utils'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); describe('NodeExecuteFunctions', () => { - describe(`test binary data helper methods`, () => { + describe('test binary data helper methods', () => { // Reset BinaryDataManager for each run. This is a dirty operation, as individual managers are not cleaned. beforeEach(() => { BinaryDataManager.instance = undefined; @@ -27,7 +40,7 @@ describe('NodeExecuteFunctions', () => { // Set our binary data buffer let inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); - let setBinaryDataBufferResponse: IBinaryData = await NodeExecuteFunctions.setBinaryDataBuffer( + let setBinaryDataBufferResponse: IBinaryData = await setBinaryDataBuffer( { mimeType: 'txt', data: 'This should be overwritten by the actual payload in the response', @@ -56,7 +69,7 @@ describe('NodeExecuteFunctions', () => { ]); // Now, lets fetch our data! The item will be item index 0. - let getBinaryDataBufferResponse: Buffer = await NodeExecuteFunctions.getBinaryDataBuffer( + let getBinaryDataBufferResponse: Buffer = await getBinaryDataBuffer( taskDataConnectionsInput, 0, 'data', @@ -78,7 +91,7 @@ describe('NodeExecuteFunctions', () => { // Set our binary data buffer let inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); - let setBinaryDataBufferResponse: IBinaryData = await NodeExecuteFunctions.setBinaryDataBuffer( + let setBinaryDataBufferResponse: IBinaryData = await setBinaryDataBuffer( { mimeType: 'txt', data: 'This should be overwritten with the name of the configured data manager', @@ -114,7 +127,7 @@ describe('NodeExecuteFunctions', () => { ]); // Now, lets fetch our data! The item will be item index 0. - let getBinaryDataBufferResponse: Buffer = await NodeExecuteFunctions.getBinaryDataBuffer( + let getBinaryDataBufferResponse: Buffer = await getBinaryDataBuffer( taskDataConnectionsInput, 0, 'data', @@ -124,4 +137,80 @@ describe('NodeExecuteFunctions', () => { expect(getBinaryDataBufferResponse).toEqual(inputData); }); }); + + describe('proxyRequestToAxios', () => { + const baseUrl = 'http://example.de'; + const workflow = mock(); + const hooks = mock(); + const additionalData = mock({ hooks }); + const node = mock(); + + beforeEach(() => { + initLogger(); + hooks.executeHookFunctions.mockClear(); + }); + + test('should not throw if the response status is 200', async () => { + nock(baseUrl).get('/test').reply(200); + await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`); + expect(hooks.executeHookFunctions).toHaveBeenCalledWith('nodeFetchedData', [ + workflow.id, + node, + ]); + }); + + test('should throw if the response status is 403', async () => { + const headers = { 'content-type': 'text/plain' }; + nock(baseUrl).get('/test').reply(403, 'Forbidden', headers); + try { + await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`); + } catch (error) { + expect(error.statusCode).toEqual(403); + expect(error.request).toBeUndefined(); + expect(error.response).toMatchObject({ headers, status: 403 }); + expect(error.options).toMatchObject({ + headers: { Accept: '*/*' }, + method: 'get', + url: 'http://example.de/test', + }); + expect(error.config).toBeUndefined(); + expect(error.message).toEqual('403 - "Forbidden"'); + } + expect(hooks.executeHookFunctions).not.toHaveBeenCalled(); + }); + + test('should not throw if the response status is 404, but `simple` option is set to `false`', async () => { + nock(baseUrl).get('/test').reply(404, 'Not Found'); + const response = await proxyRequestToAxios(workflow, additionalData, node, { + url: `${baseUrl}/test`, + simple: false, + }); + + expect(response).toEqual('Not Found'); + expect(hooks.executeHookFunctions).toHaveBeenCalledWith('nodeFetchedData', [ + workflow.id, + node, + ]); + }); + + test('should return full response when `resolveWithFullResponse` is set to true', async () => { + nock(baseUrl).get('/test').reply(404, 'Not Found'); + const response = await proxyRequestToAxios(workflow, additionalData, node, { + url: `${baseUrl}/test`, + resolveWithFullResponse: true, + simple: false, + }); + + expect(response).toMatchObject({ + body: 'Not Found', + headers: {}, + statusCode: 404, + statusMessage: null, + }); + expect(hooks.executeHookFunctions).toHaveBeenCalledWith('nodeFetchedData', [ + workflow.id, + node, + ]); + }); + }); }); diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts index b80a7e0a5b8b8..5a79bac72339f 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/test/WorkflowExecute.test.ts @@ -1,17 +1,14 @@ -import { - createDeferredPromise, - IConnections, - ILogger, - INode, - IRun, - LoggerProxy, - Workflow, -} from 'n8n-workflow'; +import { createDeferredPromise, IConnections, INode, IRun, Workflow } from 'n8n-workflow'; import { WorkflowExecute } from '@/WorkflowExecute'; import * as Helpers from './Helpers'; +import { initLogger } from './utils'; describe('WorkflowExecute', () => { + beforeAll(() => { + initLogger(); + }); + describe('run', () => { const tests: Array<{ description: string; @@ -1352,18 +1349,8 @@ describe('WorkflowExecute', () => { }, ]; - const fakeLogger = { - log: () => {}, - debug: () => {}, - verbose: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as ILogger; - const executionMode = 'manual'; const nodeTypes = Helpers.NodeTypes(); - LoggerProxy.init(fakeLogger); for (const testData of tests) { test(testData.description, async () => { diff --git a/packages/core/test/WorkflowExecutionMetadata.test.ts b/packages/core/test/WorkflowExecutionMetadata.test.ts index 1c1ee49bf288b..dc99effede13a 100644 --- a/packages/core/test/WorkflowExecutionMetadata.test.ts +++ b/packages/core/test/WorkflowExecutionMetadata.test.ts @@ -4,8 +4,22 @@ import { KV_LIMIT, setAllWorkflowExecutionMetadata, setWorkflowExecutionMetadata, + ExecutionMetadataValidationError, } from '@/WorkflowExecutionMetadata'; -import type { IRunExecutionData } from 'n8n-workflow'; +import { LoggerProxy } from 'n8n-workflow'; +import type { ILogger, IRunExecutionData } from 'n8n-workflow'; + +beforeAll(() => { + const fakeLogger = { + log: () => {}, + debug: () => {}, + verbose: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as ILogger; + LoggerProxy.init(fakeLogger); +}); describe('Execution Metadata functions', () => { test('setWorkflowExecutionMetadata will set a value', () => { @@ -42,7 +56,33 @@ describe('Execution Metadata functions', () => { }); }); - test('setWorkflowExecutionMetadata should convert values to strings', () => { + test('setWorkflowExecutionMetadata should only convert numbers to strings', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + expect(() => setWorkflowExecutionMetadata(executionData, 'test1', 1234)).not.toThrow( + ExecutionMetadataValidationError, + ); + + expect(metadata).toEqual({ + test1: '1234', + }); + + expect(() => setWorkflowExecutionMetadata(executionData, 'test2', {})).toThrow( + ExecutionMetadataValidationError, + ); + + expect(metadata).not.toEqual({ + test1: '1234', + test2: {}, + }); + }); + + test('setAllWorkflowExecutionMetadata should not convert values to strings and should set other values correctly', () => { const metadata = {}; const executionData = { resultData: { @@ -50,9 +90,34 @@ describe('Execution Metadata functions', () => { }, } as IRunExecutionData; - setWorkflowExecutionMetadata(executionData, 'test1', 1234); + expect(() => + setAllWorkflowExecutionMetadata(executionData, { + test1: {} as unknown as string, + test2: [] as unknown as string, + test3: 'value3', + test4: 'value4', + }), + ).toThrow(ExecutionMetadataValidationError); expect(metadata).toEqual({ + test3: 'value3', + test4: 'value4', + }); + }); + + test('setWorkflowExecutionMetadata should validate key characters', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + expect(() => setWorkflowExecutionMetadata(executionData, 'te$t1$', 1234)).toThrow( + ExecutionMetadataValidationError, + ); + + expect(metadata).not.toEqual({ test1: '1234', }); }); diff --git a/packages/core/test/setup.ts b/packages/core/test/setup.ts new file mode 100644 index 0000000000000..49629bf321304 --- /dev/null +++ b/packages/core/test/setup.ts @@ -0,0 +1,5 @@ +import nock from 'nock'; + +export default async () => { + nock.disableNetConnect(); +}; diff --git a/packages/core/test/utils.ts b/packages/core/test/utils.ts new file mode 100644 index 0000000000000..1d6abbf50efaa --- /dev/null +++ b/packages/core/test/utils.ts @@ -0,0 +1,14 @@ +import { ILogger, LoggerProxy } from 'n8n-workflow'; + +const fakeLogger = { + log: () => {}, + debug: () => {}, + verbose: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} as ILogger; + +export const initLogger = () => { + LoggerProxy.init(fakeLogger); +}; diff --git a/packages/design-system/README.md b/packages/design-system/README.md index ba51ff3dd0996..7bc578ba6819c 100644 --- a/packages/design-system/README.md +++ b/packages/design-system/README.md @@ -50,4 +50,6 @@ pnpm watch:theme n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). +Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) + Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 60ceedd67b3d9..f0e29389e26cd 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,13 +1,14 @@ { "name": "n8n-design-system", - "version": "0.59.0", + "version": "0.62.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { "name": "Mutasem Aldmour", "email": "mutasem@n8n.io" }, - "main": "src/main.js", + "main": "src/main.ts", + "import": "src/main.ts", "repository": { "type": "git", "url": "git+https://github.com/n8n-io/n8n.git" @@ -15,7 +16,7 @@ "scripts": { "clean": "rimraf dist .turbo", "build": "vite build", - "typecheck": "vue-tsc --emitDeclarationOnly", + "typecheck": "vue-tsc --declaration --emitDeclarationOnly", "test": "vitest run --coverage", "test:dev": "vitest", "build:storybook": "storybook build", diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index 57b626ba201b0..a21f6e00d5f75 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -42,9 +42,9 @@ import N8nButton from '../N8nButton'; import N8nHeading from '../N8nHeading'; import N8nText from '../N8nText'; import N8nCallout from '../N8nCallout'; -import Vue from 'vue'; +import { defineComponent } from 'vue'; -export default Vue.extend({ +export default defineComponent({ name: 'n8n-action-box', components: { N8nButton, diff --git a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index 8c31dbe3d4e0b..75a785a590289 100644 --- a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -18,14 +18,7 @@ :disabled="item.disabled" :divided="item.divided" > -
+
@@ -41,7 +34,8 @@ diff --git a/packages/design-system/src/components/N8nPulse/Pulse.vue b/packages/design-system/src/components/N8nPulse/Pulse.vue index f03d4f78ccd30..3e3f2daf2e304 100644 --- a/packages/design-system/src/components/N8nPulse/Pulse.vue +++ b/packages/design-system/src/components/N8nPulse/Pulse.vue @@ -9,9 +9,9 @@ diff --git a/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue b/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue index 7f729df7cb340..221be4b48f3db 100644 --- a/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue +++ b/packages/design-system/src/components/N8nRadioButtons/RadioButton.vue @@ -5,7 +5,7 @@ :class="{ 'n8n-radio-button': true, [$style.container]: true, - [$style.hoverable]: !this.disabled, + [$style.hoverable]: !disabled, }" aria-checked="true" > @@ -26,9 +26,9 @@ diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index a2330f14cbd0d..00444f9453f15 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.187.0", + "version": "0.191.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -29,6 +29,8 @@ "@codemirror/autocomplete": "^6.4.0", "@codemirror/commands": "^6.1.0", "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-sql": "^6.4.1", "@codemirror/language": "^6.2.1", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.1.4", @@ -37,15 +39,17 @@ "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^5.15.3", - "@fortawesome/vue-fontawesome": "^2.0.2", + "@fortawesome/vue-fontawesome": "^2.0.10", "@jsplumb/browser-ui": "^5.13.2", "@jsplumb/common": "^5.13.2", "@jsplumb/connector-bezier": "^5.13.2", "@jsplumb/core": "^5.13.2", "@jsplumb/util": "^5.13.2", "axios": "^0.21.1", + "canvas-confetti": "^1.6.0", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", + "copy-to-clipboard": "^3.3.3", "dateformat": "^3.0.3", "esprima-next": "5.8.4", "fast-json-stable-stringify": "^2.1.0", @@ -56,13 +60,11 @@ "jsonpath": "^1.1.1", "lodash-es": "^4.17.21", "luxon": "^3.3.0", - "monaco-editor": "^0.33.0", "n8n-design-system": "workspace:*", "n8n-workflow": "workspace:*", "normalize-wheel": "^1.0.1", "pinia": "^2.0.22", "prettier": "^2.8.3", - "prismjs": "^1.17.1", "stream-browserify": "^3.0.0", "timeago.js": "^4.0.2", "uuid": "^8.3.2", @@ -73,7 +75,6 @@ "vue-i18n": "^8.26.7", "vue-infinite-loading": "^2.4.5", "vue-json-pretty": "1.9.3", - "vue-prism-editor": "^0.3.0", "vue-router": "^3.6.5", "vue-template-compiler": "^2.7.14", "vue-typed-mixins": "^0.2.0", @@ -85,9 +86,11 @@ "devDependencies": { "@faker-js/faker": "^7.6.0", "@pinia/testing": "^0.0.14", + "@sentry/vite-plugin": "^0.4.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^14.4.3", "@testing-library/vue": "^5.8.3", + "@types/canvas-confetti": "^1.6.0", "@types/dateformat": "^3.0.0", "@types/express": "^4.17.6", "@types/file-saver": "^2.0.1", @@ -99,9 +102,10 @@ "@types/lodash.set": "^4.3.6", "@types/luxon": "^3.2.0", "@types/uuid": "^8.3.2", - "@vitest/coverage-c8": "^0.28.5", "@vitejs/plugin-legacy": "^3.0.1", "@vitejs/plugin-vue2": "^2.2.0", + "@vitest/coverage-c8": "^0.28.5", + "@volar-plugins/eslint": "^0.0.4", "c8": "^7.12.0", "jshint": "^2.9.7", "miragejs": "^0.1.47", @@ -109,7 +113,6 @@ "sass-loader": "^10.1.1", "string-template-parser": "^1.2.6", "vite": "4.0.4", - "vite-plugin-monaco-editor": "^1.0.10", "vitest": "^0.28.5", "vue-tsc": "^1.0.24" } diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 035416a1dab15..2d1e09cf3648a 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -37,7 +37,6 @@ import { showMessage } from '@/mixins/showMessage'; import { userHelpers } from '@/mixins/userHelpers'; import { loadLanguage } from './plugins/i18n'; import useGlobalLinkActions from '@/composables/useGlobalLinkActions'; -import { restApi } from '@/mixins/restApi'; import { mapStores } from 'pinia'; import { useUIStore } from './stores/ui'; import { useSettingsStore } from './stores/settings'; @@ -45,10 +44,11 @@ import { useUsersStore } from './stores/users'; import { useRootStore } from './stores/n8nRootStore'; import { useTemplatesStore } from './stores/templates'; import { useNodeTypesStore } from './stores/nodeTypes'; -import { historyHelper } from '@/mixins/history'; +import { useHistoryHelper } from '@/composables/useHistoryHelper'; import { newVersions } from '@/mixins/newVersions'; +import { useRoute } from 'vue-router/composables'; -export default mixins(newVersions, showMessage, userHelpers, restApi, historyHelper).extend({ +export default mixins(newVersions, showMessage, userHelpers).extend({ name: 'App', components: { LoadingView, @@ -56,10 +56,9 @@ export default mixins(newVersions, showMessage, userHelpers, restApi, historyHel Modals, }, setup() { - const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); return { - registerCustomAction, - unregisterCustomAction, + ...useGlobalLinkActions(), + ...useHistoryHelper(useRoute()), }; }, computed: { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index d6610f7611ce1..cb153e3f1f78d 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1,7 +1,7 @@ -import { CREDENTIAL_EDIT_MODAL_KEY } from './constants'; +import type { CREDENTIAL_EDIT_MODAL_KEY } from './constants'; /* eslint-disable @typescript-eslint/no-explicit-any */ -import { IMenuItem } from 'n8n-design-system'; -import { +import type { IMenuItem } from 'n8n-design-system'; +import type { GenericValue, IConnections, ICredentialsDecrypted, @@ -17,7 +17,6 @@ import { IRun, IRunData, ITaskData, - ITelemetrySettings, IWorkflowSettings as IWorkflowSettingsWorkflow, WorkflowExecuteMode, PublicInstalledPackage, @@ -26,17 +25,23 @@ import { INodeCredentials, INodeListSearchItems, NodeParameterValueType, - INodeActionTypeDescription, IDisplayOptions, IExecutionsSummary, - IAbstractEventMessage, FeatureFlags, ExecutionStatus, ITelemetryTrackProperties, + IN8nUISettings, + IUserManagementSettings, + WorkflowSettings, } from 'n8n-workflow'; -import { SignInType } from './constants'; -import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants'; -import { BulkCommand, Undoable } from '@/models/history'; +import type { SignInType } from './constants'; +import type { + FAKE_DOOR_FEATURES, + TRIGGER_NODE_CREATOR_VIEW, + REGULAR_NODE_CREATOR_VIEW, +} from './constants'; +import type { BulkCommand, Undoable } from '@/models/history'; +import type { PartialBy } from '@/utils/typeHelpers'; export * from 'n8n-design-system/types'; @@ -136,43 +141,6 @@ export interface IExternalHooks { run(eventName: string, metadata?: IDataObject): Promise; } -/** - * @deprecated Do not add methods to this interface. - */ -export interface IRestApi { - getActiveWorkflows(): Promise; - getActivationError(id: string): Promise; - getCurrentExecutions(filter: ExecutionsQueryFilter): Promise; - getPastExecutions( - filter: ExecutionsQueryFilter, - limit: number, - lastId?: string, - firstId?: string, - ): Promise; - stopCurrentExecution(executionId: string): Promise; - makeRestApiRequest(method: string, endpoint: string, data?: any): Promise; - getCredentialTranslation(credentialType: string): Promise; - removeTestWebhook(workflowId: string): Promise; - runWorkflow(runData: IStartRunData): Promise; - createNewWorkflow(sendData: IWorkflowDataUpdate): Promise; - updateWorkflow(id: string, data: IWorkflowDataUpdate, forceSave?: boolean): Promise; - deleteWorkflow(name: string): Promise; - getWorkflow(id: string): Promise; - getWorkflows(filter?: object): Promise; - getWorkflowFromUrl(url: string): Promise; - getExecution(id: string): Promise; - deleteExecutions(sendData: IExecutionDeleteFilter): Promise; - retryExecution(id: string, loadWorkflow?: boolean): Promise; - getTimezones(): Promise; - getBinaryUrl( - dataPath: string, - mode: 'view' | 'download', - fileName?: string, - mimeType?: string, - ): string; - getExecutionEvents(id: string): Promise; -} - export interface INodeTranslationHeaders { data: { [key: string]: { @@ -593,6 +561,12 @@ export interface IUserResponse { personalizationAnswers?: IPersonalizationSurveyVersions | null; isPending: boolean; signInType?: SignInType; + settings?: { + isOnboarded?: boolean; + showUserActivationSurvey?: boolean; + firstSuccessfulWorkflowId?: string; + userActivated?: boolean; + }; } export interface CurrentUserResponse extends IUserResponse { @@ -628,19 +602,12 @@ export interface IN8nPromptResponse { updated: boolean; } -export enum UserManagementAuthenticationMethod { +export const enum UserManagementAuthenticationMethod { Email = 'email', Ldap = 'ldap', Saml = 'saml', } -export interface IUserManagementConfig { - enabled: boolean; - showSetupOnFirstLoad?: boolean; - smtpSetup: boolean; - authenticationMethod: UserManagementAuthenticationMethod; -} - export interface IPermissionGroup { loginStatus?: ILogInStatus[]; role?: IRole[]; @@ -725,93 +692,14 @@ export interface ITemplatesCategory { export type WorkflowCallerPolicyDefaultOption = 'any' | 'none' | 'workflowsFromAList'; -export interface IN8nUISettings { - endpointWebhook: string; - endpointWebhookTest: string; - saveDataErrorExecution: string; - saveDataSuccessExecution: string; - saveManualExecutions: boolean; - workflowCallerPolicyDefaultOption: WorkflowCallerPolicyDefaultOption; - timezone: string; - executionTimeout: number; - maxExecutionTimeout: number; - oauthCallbackUrls: { - oauth1: string; - oauth2: string; - }; - urlBaseEditor: string; - urlBaseWebhook: string; - versionCli: string; - n8nMetadata?: { - [key: string]: string | number | undefined; - }; - versionNotifications: IVersionNotificationSettings; - instanceId: string; - personalizationSurveyEnabled: boolean; - telemetry: ITelemetrySettings; - userManagement: IUserManagementConfig; - defaultLocale: string; - workflowTagsDisabled: boolean; - logLevel: ILogLevel; - hiringBannerEnabled: boolean; - templates: { - enabled: boolean; - host: string; - }; - posthog: { - enabled: boolean; - apiHost: string; - apiKey: string; - autocapture: boolean; - disableSessionRecording: boolean; - debug: boolean; - }; - executionMode: string; - pushBackend: 'sse' | 'websocket'; - communityNodesEnabled: boolean; - isNpmAvailable: boolean; - publicApi: { - enabled: boolean; - latestVersion: number; - path: string; - swaggerUi: { - enabled: boolean; - }; - }; - sso: { - saml: { - loginLabel: string; - loginEnabled: boolean; - }; - ldap: { - loginLabel: string; - loginEnabled: boolean; - }; - }; - onboardingCallPromptEnabled: boolean; - allowedModules: { - builtIn?: string[]; - external?: string[]; - }; - enterprise: Record; - deployment?: { - type: string | 'default' | 'n8n-internal' | 'cloud' | 'desktop_mac' | 'desktop_win'; - }; - hideUsagePage: boolean; - license: { - environment: 'development' | 'production'; - }; -} - export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { errorWorkflow?: string; - saveDataErrorExecution?: string; - saveDataSuccessExecution?: string; saveManualExecutions?: boolean; timezone?: string; executionTimeout?: number; + maxExecutionTimeout?: number; callerIds?: string; - callerPolicy?: WorkflowCallerPolicyDefaultOption; + callerPolicy?: WorkflowSettings.CallerPolicy; } export interface ITimeoutHMS { @@ -822,67 +710,85 @@ export interface ITimeoutHMS { export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR'; -export interface ISubcategoryItemProps { - subcategory: string; - description: string; - key?: string; - iconType: string; +export type ExtractActionKeys = T extends SimplifiedNodeType ? T['name'] : never; + +export type ActionsRecord = { + [K in ExtractActionKeys]: ActionTypeDescription[]; +}; + +export interface SimplifiedNodeType + extends Pick< + INodeTypeDescription, + 'displayName' | 'description' | 'name' | 'group' | 'icon' | 'iconUrl' | 'codex' | 'defaults' + > {} +export interface SubcategoryItemProps { + description?: string; + iconType?: string; icon?: string; + title?: string; + subcategory?: string; defaults?: INodeParameters; + forceIncludeNodes?: string[]; } export interface ViewItemProps { - withTopBorder: boolean; title: string; description: string; icon: string; } - -export interface INodeItemProps { - subcategory: string; - nodeType: INodeTypeDescription; +export interface LabelItemProps { + key: string; } - -export interface IActionItemProps { - subcategory: string; - nodeType: INodeActionTypeDescription; +export interface ActionTypeDescription extends SimplifiedNodeType { + displayOptions?: IDisplayOptions; + values?: IDataObject; + actionKey: string; + codex: { + label: string; + categories: string[]; + }; } -export interface ICategoryItemProps { - expanded: boolean; - category: string; +export interface CategoryItemProps { name: string; + count: number; } export interface CreateElementBase { + uuid?: string; key: string; - includedByTrigger?: boolean; - includedByRegular?: boolean; } export interface NodeCreateElement extends CreateElementBase { type: 'node'; - category?: string[]; - properties: INodeItemProps; + subcategory: string; + properties: SimplifiedNodeType; } export interface CategoryCreateElement extends CreateElementBase { type: 'category'; - properties: ICategoryItemProps; + subcategory: string; + properties: CategoryItemProps; } export interface SubcategoryCreateElement extends CreateElementBase { type: 'subcategory'; - properties: ISubcategoryItemProps; + properties: SubcategoryItemProps; } export interface ViewCreateElement extends CreateElementBase { type: 'view'; properties: ViewItemProps; } +export interface LabelCreateElement extends CreateElementBase { + type: 'label'; + subcategory: string; + properties: LabelItemProps; +} + export interface ActionCreateElement extends CreateElementBase { type: 'action'; - category: string; - properties: IActionItemProps; + subcategory: string; + properties: ActionTypeDescription; } export type INodeCreateElement = @@ -890,18 +796,12 @@ export type INodeCreateElement = | CategoryCreateElement | SubcategoryCreateElement | ViewCreateElement + | LabelCreateElement | ActionCreateElement; -export interface ICategoriesWithNodes { - [category: string]: { - [subcategory: string]: { - regularCount: number; - triggerCount: number; - nodes: INodeCreateElement[]; - }; - }; +export interface SubcategorizedNodeTypes { + [subcategory: string]: INodeCreateElement[]; } - export interface ITag { id: string; name: string; @@ -984,6 +884,7 @@ export interface WorkflowsState { export interface RootState { baseUrl: string; + restEndpoint: string; defaultLocale: string; endpointWebhook: string; endpointWebhookTest: string; @@ -1168,9 +1069,6 @@ export interface UIState { addFirstStepOnLoad: boolean; executionSidebarAutoRefresh: boolean; } - -export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose'; - export type IFakeDoor = { id: FAKE_DOOR_FEATURES; featureName: string; @@ -1189,7 +1087,7 @@ export type IFakeDoorLocation = | 'credentialsModal' | 'workflowShareModal'; -export type INodeFilterType = typeof REGULAR_NODE_FILTER | typeof TRIGGER_NODE_FILTER; +export type NodeFilterType = typeof REGULAR_NODE_CREATOR_VIEW | typeof TRIGGER_NODE_CREATOR_VIEW; export type NodeCreatorOpenSource = | '' @@ -1204,15 +1102,15 @@ export type NodeCreatorOpenSource = export interface INodeCreatorState { itemsFilter: string; showScrim: boolean; - rootViewHistory: INodeFilterType[]; - selectedView: INodeFilterType; + rootViewHistory: NodeFilterType[]; + selectedView: NodeFilterType; openSource: NodeCreatorOpenSource; } export interface ISettingsState { settings: IN8nUISettings; promptsData: IN8nPrompts; - userManagement: IUserManagementConfig; + userManagement: IUserManagementSettings; templatesEndpointHealthy: boolean; api: { enabled: boolean; @@ -1461,6 +1359,16 @@ export type NodeAuthenticationOption = { displayOptions?: IDisplayOptions; }; +export interface EnvironmentVariable { + id: number; + key: string; + value: string; +} + +export interface TemporaryEnvironmentVariable extends Omit { + id: string; +} + export type ExecutionFilterMetadata = { key: string; value: string; @@ -1484,3 +1392,43 @@ export type ExecutionsQueryFilter = { startedAfter?: string; startedBefore?: string; }; + +export type SamlAttributeMapping = { + email: string; + firstName: string; + lastName: string; + userPrincipalName: string; +}; + +export type SamlLoginBinding = 'post' | 'redirect'; + +export type SamlSignatureConfig = { + prefix: 'ds'; + location: { + reference: '/samlp:Response/saml:Issuer'; + action: 'after'; + }; +}; + +export type SamlPreferencesLoginEnabled = { + loginEnabled: boolean; +}; + +export type SamlPreferences = { + mapping?: SamlAttributeMapping; + metadata?: string; + metadataUrl?: string; + ignoreSSL?: boolean; + loginBinding?: SamlLoginBinding; + acsBinding?: SamlLoginBinding; + authnRequestsSigned?: boolean; + loginLabel?: string; + wantAssertionsSigned?: boolean; + wantMessageSigned?: boolean; + signatureConfig?: SamlSignatureConfig; +} & PartialBy; + +export type SamlPreferencesExtractedData = { + entityID: string; + returnUrl: string; +}; diff --git a/packages/editor-ui/src/__tests__/permissions.spec.ts b/packages/editor-ui/src/__tests__/permissions.spec.ts index 770cffa228770..8ee7edde431a5 100644 --- a/packages/editor-ui/src/__tests__/permissions.spec.ts +++ b/packages/editor-ui/src/__tests__/permissions.spec.ts @@ -1,5 +1,5 @@ import { parsePermissionsTable } from '@/permissions'; -import { IUser } from '@/Interface'; +import type { IUser } from '@/Interface'; describe('parsePermissionsTable()', () => { const user: IUser = { diff --git a/packages/editor-ui/src/__tests__/server/endpoints/credential.ts b/packages/editor-ui/src/__tests__/server/endpoints/credential.ts index 949004b487f27..7aba2d99d40c6 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/credential.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/credential.ts @@ -1,5 +1,6 @@ -import { Response, Server } from 'miragejs'; -import { AppSchema } from '../types'; +import type { Server } from 'miragejs'; +import { Response } from 'miragejs'; +import type { AppSchema } from '../types'; export function routesForCredentials(server: Server) { server.get('/rest/credentials', (schema: AppSchema) => { diff --git a/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts b/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts index 3f9ee6eaafec3..b50d6270e2ce7 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts @@ -1,5 +1,6 @@ -import { Response, Server } from 'miragejs'; -import { AppSchema } from '../types'; +import type { Server } from 'miragejs'; +import { Response } from 'miragejs'; +import type { AppSchema } from '../types'; export function routesForCredentialTypes(server: Server) { server.get('/types/credentials.json', (schema: AppSchema) => { diff --git a/packages/editor-ui/src/__tests__/server/endpoints/index.ts b/packages/editor-ui/src/__tests__/server/endpoints/index.ts index 0b82fa233b53c..4ba3315e425fb 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/index.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/index.ts @@ -1,12 +1,16 @@ +import type { Server } from 'miragejs'; import { routesForUsers } from './user'; import { routesForCredentials } from './credential'; -import { Server } from 'miragejs'; -import { routesForCredentialTypes } from '@/__tests__/server/endpoints/credentialType'; +import { routesForCredentialTypes } from './credentialType'; +import { routesForVariables } from './variable'; +import { routesForSettings } from './settings'; const endpoints: Array<(server: Server) => void> = [ routesForCredentials, routesForCredentialTypes, routesForUsers, + routesForVariables, + routesForSettings, ]; export { endpoints }; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts new file mode 100644 index 0000000000000..0ffed3e14a6b1 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts @@ -0,0 +1,90 @@ +import type { Server } from 'miragejs'; +import { Response } from 'miragejs'; +import type { AppSchema } from '../types'; +import type { IN8nUISettings } from 'n8n-workflow'; + +const defaultSettings: IN8nUISettings = { + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + sharing: false, + ldap: false, + saml: false, + logStreaming: false, + advancedExecutionFilters: false, + variables: true, + versionControl: false, + }, + executionMode: 'regular', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'development' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'websocket', + saveDataErrorExecution: 'DEFAULT', + saveDataSuccessExecution: 'DEFAULT', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { + enabled: false, + }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: true, + showSetupOnFirstLoad: true, + smtpSetup: true, + authenticationMethod: 'email', + }, + versionCli: '', + versionNotifications: { + enabled: true, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, + variables: { + limit: -1, + }, + userActivationSurveyEnabled: false, + deployment: { + type: 'default', + }, +}; + +export function routesForSettings(server: Server) { + server.get('/rest/settings', (schema: AppSchema) => { + return new Response( + 200, + {}, + { + data: defaultSettings, + }, + ); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/endpoints/user.ts b/packages/editor-ui/src/__tests__/server/endpoints/user.ts index 7822231bac02d..57bfdc8e1faa1 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/user.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/user.ts @@ -1,5 +1,6 @@ -import { Response, Server } from 'miragejs'; -import { AppSchema } from '../types'; +import type { Server } from 'miragejs'; +import { Response } from 'miragejs'; +import type { AppSchema } from '../types'; export function routesForUsers(server: Server) { server.get('/rest/users', (schema: AppSchema) => { @@ -7,4 +8,12 @@ export function routesForUsers(server: Server) { return new Response(200, {}, { data }); }); + + server.get('/rest/login', (schema: AppSchema) => { + const model = schema.findBy('user', { + isDefaultUser: true, + }); + + return new Response(200, {}, { data: model?.attrs }); + }); } diff --git a/packages/editor-ui/src/__tests__/server/endpoints/variable.ts b/packages/editor-ui/src/__tests__/server/endpoints/variable.ts new file mode 100644 index 0000000000000..c2e017c2a9392 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/variable.ts @@ -0,0 +1,42 @@ +import type { Request, Server } from 'miragejs'; +import { Response } from 'miragejs'; +import type { AppSchema } from '../types'; +import { jsonParse } from 'n8n-workflow'; +import type { EnvironmentVariable } from '@/Interface'; + +export function routesForVariables(server: Server) { + server.get('/rest/variables', (schema: AppSchema) => { + const { models: data } = schema.all('variable'); + + return new Response(200, {}, { data }); + }); + + server.post('/rest/variables', (schema: AppSchema, request: Request) => { + const data = schema.create('variable', jsonParse(request.requestBody)); + + return new Response(200, {}, { data }); + }); + + server.patch('/rest/variables/:id', (schema: AppSchema, request: Request) => { + const data: EnvironmentVariable = jsonParse(request.requestBody); + const id = request.params.id; + + const model = schema.find('variable', id); + if (model) { + model.update(data); + } + + return new Response(200, {}, { data: model?.attrs }); + }); + + server.delete('/rest/variables/:id', (schema: AppSchema, request: Request) => { + const id = request.params.id; + + const model = schema.find('variable', id); + if (model) { + model.destroy(); + } + + return new Response(200, {}, {}); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/factories/credentialType.ts b/packages/editor-ui/src/__tests__/server/factories/credentialType.ts index 517e4e7207a8f..7fdd221cf8b7d 100644 --- a/packages/editor-ui/src/__tests__/server/factories/credentialType.ts +++ b/packages/editor-ui/src/__tests__/server/factories/credentialType.ts @@ -1,5 +1,4 @@ import { Factory } from 'miragejs'; -import { faker } from '@faker-js/faker'; import type { ICredentialType } from 'n8n-workflow'; const credentialTypes = [ diff --git a/packages/editor-ui/src/__tests__/server/factories/index.ts b/packages/editor-ui/src/__tests__/server/factories/index.ts index 181ff9b9a1153..9b5155c28f1e8 100644 --- a/packages/editor-ui/src/__tests__/server/factories/index.ts +++ b/packages/editor-ui/src/__tests__/server/factories/index.ts @@ -1,13 +1,16 @@ import { userFactory } from './user'; import { credentialFactory } from './credential'; import { credentialTypeFactory } from './credentialType'; +import { variableFactory } from './variable'; export * from './user'; export * from './credential'; export * from './credentialType'; +export * from './variable'; export const factories = { credential: credentialFactory, credentialType: credentialTypeFactory, user: userFactory, + variable: variableFactory, }; diff --git a/packages/editor-ui/src/__tests__/server/factories/variable.ts b/packages/editor-ui/src/__tests__/server/factories/variable.ts new file mode 100644 index 0000000000000..948e2578888fd --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/variable.ts @@ -0,0 +1,15 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import type { EnvironmentVariable } from '@/Interface'; + +export const variableFactory = Factory.extend({ + id(i: number) { + return i; + }, + key() { + return `${faker.lorem.word()}`.toUpperCase(); + }, + value() { + return faker.internet.password(10); + }, +}); diff --git a/packages/editor-ui/src/__tests__/server/index.ts b/packages/editor-ui/src/__tests__/server/index.ts index edff6894e7acc..7eb78caacad29 100644 --- a/packages/editor-ui/src/__tests__/server/index.ts +++ b/packages/editor-ui/src/__tests__/server/index.ts @@ -10,6 +10,8 @@ export function setupServer() { seeds(server) { server.createList('credentialType', 8); server.create('user', { + firstName: 'Nathan', + lastName: 'Doe', isDefaultUser: true, }); }, diff --git a/packages/editor-ui/src/__tests__/server/models/credential.ts b/packages/editor-ui/src/__tests__/server/models/credential.ts index 17d4f45dcf984..8020afc06d0d8 100644 --- a/packages/editor-ui/src/__tests__/server/models/credential.ts +++ b/packages/editor-ui/src/__tests__/server/models/credential.ts @@ -1,4 +1,4 @@ -import { ICredentialsResponse } from '@/Interface'; +import type { ICredentialsResponse } from '@/Interface'; import { Model } from 'miragejs'; import type { ModelDefinition } from 'miragejs/-types'; diff --git a/packages/editor-ui/src/__tests__/server/models/index.ts b/packages/editor-ui/src/__tests__/server/models/index.ts index 310b832a4d00d..6b9f39327b9bf 100644 --- a/packages/editor-ui/src/__tests__/server/models/index.ts +++ b/packages/editor-ui/src/__tests__/server/models/index.ts @@ -1,9 +1,11 @@ import { UserModel } from './user'; import { CredentialModel } from './credential'; import { CredentialTypeModel } from './credentialType'; +import { VariableModel } from './variable'; export const models = { credential: CredentialModel, credentialType: CredentialTypeModel, user: UserModel, + variable: VariableModel, }; diff --git a/packages/editor-ui/src/__tests__/server/models/user.ts b/packages/editor-ui/src/__tests__/server/models/user.ts index cef64c360cf9d..2c4d22e3dbd16 100644 --- a/packages/editor-ui/src/__tests__/server/models/user.ts +++ b/packages/editor-ui/src/__tests__/server/models/user.ts @@ -1,4 +1,4 @@ -import { IUser } from '@/Interface'; +import type { IUser } from '@/Interface'; import { Model } from 'miragejs'; import type { ModelDefinition } from 'miragejs/-types'; diff --git a/packages/editor-ui/src/__tests__/server/models/variable.ts b/packages/editor-ui/src/__tests__/server/models/variable.ts new file mode 100644 index 0000000000000..27a2a9b238e49 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/variable.ts @@ -0,0 +1,5 @@ +import type { EnvironmentVariable } from '@/Interface'; +import { Model } from 'miragejs'; +import type { ModelDefinition } from 'miragejs/-types'; + +export const VariableModel: ModelDefinition = Model.extend({}); diff --git a/packages/editor-ui/src/__tests__/server/types.ts b/packages/editor-ui/src/__tests__/server/types.ts index bc3e75e4f3bad..ec32f7c97cede 100644 --- a/packages/editor-ui/src/__tests__/server/types.ts +++ b/packages/editor-ui/src/__tests__/server/types.ts @@ -1,10 +1,10 @@ -import { Registry } from 'miragejs'; +import type { Registry } from 'miragejs'; // eslint-disable-next-line import/no-unresolved -import Schema from 'miragejs/orm/schema'; +import type Schema from 'miragejs/orm/schema'; -import { models } from './models'; -import { factories } from './factories'; +import type { models } from './models'; +import type { factories } from './factories'; type AppRegistry = Registry; export type AppSchema = Schema; diff --git a/packages/editor-ui/src/__tests__/setup.ts b/packages/editor-ui/src/__tests__/setup.ts index 350dbc992a6cb..28fef7c5d7849 100644 --- a/packages/editor-ui/src/__tests__/setup.ts +++ b/packages/editor-ui/src/__tests__/setup.ts @@ -1,8 +1,11 @@ import '@testing-library/jest-dom'; +import { configure } from '@testing-library/vue'; import Vue from 'vue'; import '../plugins'; import { I18nPlugin } from '@/plugins/i18n'; +configure({ testIdAttribute: 'data-test-id' }); + Vue.config.productionTip = false; Vue.config.devtools = false; diff --git a/packages/editor-ui/src/__tests__/utils.ts b/packages/editor-ui/src/__tests__/utils.ts index 49834e3854e13..c6d167f06c3ee 100644 --- a/packages/editor-ui/src/__tests__/utils.ts +++ b/packages/editor-ui/src/__tests__/utils.ts @@ -1,3 +1,8 @@ +import type { ISettingsState } from '@/Interface'; +import { UserManagementAuthenticationMethod } from '@/Interface'; +import { render } from '@testing-library/vue'; +import { PiniaVuePlugin } from 'pinia'; + export const retry = (assertion: () => any, { interval = 20, timeout = 200 } = {}) => { return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -15,3 +20,112 @@ export const retry = (assertion: () => any, { interval = 20, timeout = 200 } = { tryAgain(); }); }; + +type RenderParams = Parameters; +export const renderComponent = (Component: RenderParams[0], renderOptions: RenderParams[1] = {}) => + render(Component, renderOptions, (vue) => { + vue.use(PiniaVuePlugin); + }); + +export const waitAllPromises = () => new Promise((resolve) => setTimeout(resolve)); + +export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { + settings: { + userActivationSurveyEnabled: false, + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + advancedExecutionFilters: false, + sharing: false, + ldap: false, + saml: false, + logStreaming: false, + }, + executionMode: '', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'production' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'sse', + saveDataErrorExecution: '', + saveDataSuccessExecution: '', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { enabled: false }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + versionCli: '', + versionNotifications: { + enabled: false, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, + deployment: { + type: 'default', + }, + }, + promptsData: { + message: '', + title: '', + showContactPrompt: false, + showValueSurvey: false, + }, + userManagement: { + enabled: false, + showSetupOnFirstLoad: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + templatesEndpointHealthy: false, + api: { + enabled: false, + latestVersion: 0, + path: '/', + swaggerUi: { + enabled: false, + }, + }, + ldap: { + loginLabel: '', + loginEnabled: false, + }, + saml: { + loginLabel: '', + loginEnabled: false, + }, + onboardingCallPromptEnabled: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + saveManualExecutions: false, +}; diff --git a/packages/editor-ui/src/api/api-keys.ts b/packages/editor-ui/src/api/api-keys.ts index 20a72895f60b8..f864ffbe17364 100644 --- a/packages/editor-ui/src/api/api-keys.ts +++ b/packages/editor-ui/src/api/api-keys.ts @@ -1,4 +1,4 @@ -import { IRestApiContext } from '@/Interface'; +import type { IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; export function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { diff --git a/packages/editor-ui/src/api/communityNodes.ts b/packages/editor-ui/src/api/communityNodes.ts index 453217aa83074..ec8212bff36ba 100644 --- a/packages/editor-ui/src/api/communityNodes.ts +++ b/packages/editor-ui/src/api/communityNodes.ts @@ -1,5 +1,5 @@ -import { IRestApiContext } from '@/Interface'; -import { PublicInstalledPackage } from 'n8n-workflow'; +import type { IRestApiContext } from '@/Interface'; +import type { PublicInstalledPackage } from 'n8n-workflow'; import { get, post, makeRestApiRequest } from '@/utils'; export async function getInstalledCommunityNodes( diff --git a/packages/editor-ui/src/api/credentials.ee.ts b/packages/editor-ui/src/api/credentials.ee.ts index 5a9b5b6196b8b..3067b906888d4 100644 --- a/packages/editor-ui/src/api/credentials.ee.ts +++ b/packages/editor-ui/src/api/credentials.ee.ts @@ -1,6 +1,6 @@ -import { ICredentialsResponse, IRestApiContext, IShareCredentialsPayload } from '@/Interface'; +import type { ICredentialsResponse, IRestApiContext, IShareCredentialsPayload } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; -import { IDataObject } from 'n8n-workflow'; +import type { IDataObject } from 'n8n-workflow'; export async function setCredentialSharedWith( context: IRestApiContext, diff --git a/packages/editor-ui/src/api/credentials.ts b/packages/editor-ui/src/api/credentials.ts index cf9d13932b94a..3bef8e9e9fe41 100644 --- a/packages/editor-ui/src/api/credentials.ts +++ b/packages/editor-ui/src/api/credentials.ts @@ -1,6 +1,10 @@ -import { ICredentialsDecryptedResponse, ICredentialsResponse, IRestApiContext } from '@/Interface'; +import type { + ICredentialsDecryptedResponse, + ICredentialsResponse, + IRestApiContext, +} from '@/Interface'; import { makeRestApiRequest } from '@/utils'; -import { +import type { ICredentialsDecrypted, ICredentialType, IDataObject, diff --git a/packages/editor-ui/src/api/curlHelper.ts b/packages/editor-ui/src/api/curlHelper.ts index c1927b3b82f88..7089201eb2e70 100644 --- a/packages/editor-ui/src/api/curlHelper.ts +++ b/packages/editor-ui/src/api/curlHelper.ts @@ -1,4 +1,4 @@ -import { CurlToJSONResponse, IRestApiContext } from '@/Interface'; +import type { CurlToJSONResponse, IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; export function getCurlToJson( diff --git a/packages/editor-ui/src/api/environments.ee.ts b/packages/editor-ui/src/api/environments.ee.ts new file mode 100644 index 0000000000000..f5a386a059319 --- /dev/null +++ b/packages/editor-ui/src/api/environments.ee.ts @@ -0,0 +1,40 @@ +import type { EnvironmentVariable, IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils'; +import type { IDataObject } from 'n8n-workflow'; + +export async function getVariables(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/variables'); +} + +export async function getVariable( + context: IRestApiContext, + { id }: { id: EnvironmentVariable['id'] }, +): Promise { + return await makeRestApiRequest(context, 'GET', `/variables/${id}`); +} + +export async function createVariable( + context: IRestApiContext, + data: Omit, +) { + return await makeRestApiRequest(context, 'POST', '/variables', data as unknown as IDataObject); +} + +export async function updateVariable( + context: IRestApiContext, + { id, ...data }: EnvironmentVariable, +) { + return await makeRestApiRequest( + context, + 'PATCH', + `/variables/${id}`, + data as unknown as IDataObject, + ); +} + +export async function deleteVariable( + context: IRestApiContext, + { id }: { id: EnvironmentVariable['id'] }, +) { + return await makeRestApiRequest(context, 'DELETE', `/variables/${id}`); +} diff --git a/packages/editor-ui/src/api/eventbus.ee.ts b/packages/editor-ui/src/api/eventbus.ee.ts index 1b00f84630064..a979a63fe92c2 100644 --- a/packages/editor-ui/src/api/eventbus.ee.ts +++ b/packages/editor-ui/src/api/eventbus.ee.ts @@ -1,6 +1,6 @@ -import { IRestApiContext } from '@/Interface'; +import type { IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; -import { IDataObject, MessageEventBusDestinationOptions } from 'n8n-workflow'; +import type { IDataObject, MessageEventBusDestinationOptions } from 'n8n-workflow'; export async function saveDestinationToDb( context: IRestApiContext, diff --git a/packages/editor-ui/src/api/ldap.ts b/packages/editor-ui/src/api/ldap.ts index 06bf040f7549e..2daa45905c782 100644 --- a/packages/editor-ui/src/api/ldap.ts +++ b/packages/editor-ui/src/api/ldap.ts @@ -1,6 +1,6 @@ -import { ILdapConfig, ILdapSyncData, IRestApiContext } from '@/Interface'; +import type { ILdapConfig, ILdapSyncData, IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; -import { IDataObject } from 'n8n-workflow'; +import type { IDataObject } from 'n8n-workflow'; export function getLdapConfig(context: IRestApiContext): Promise { return makeRestApiRequest(context, 'GET', '/ldap/config'); diff --git a/packages/editor-ui/src/api/settings.ts b/packages/editor-ui/src/api/settings.ts index 9bd87f28f7259..cdd19d976a1f8 100644 --- a/packages/editor-ui/src/api/settings.ts +++ b/packages/editor-ui/src/api/settings.ts @@ -1,12 +1,12 @@ -import { +import type { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, - IN8nUISettings, IN8nPromptResponse, } from '../Interface'; import { makeRestApiRequest, get, post } from '@/utils'; import { N8N_IO_BASE_URL, NPM_COMMUNITY_NODE_SEARCH_API_URL } from '@/constants'; +import type { IN8nUISettings } from 'n8n-workflow'; export function getSettings(context: IRestApiContext): Promise { return makeRestApiRequest(context, 'GET', '/settings'); diff --git a/packages/editor-ui/src/api/sso.ts b/packages/editor-ui/src/api/sso.ts index 5019335d35eb3..867a7799f9a63 100644 --- a/packages/editor-ui/src/api/sso.ts +++ b/packages/editor-ui/src/api/sso.ts @@ -1,6 +1,39 @@ import { makeRestApiRequest } from '@/utils'; -import { IRestApiContext } from '@/Interface'; +import type { + IRestApiContext, + SamlPreferencesLoginEnabled, + SamlPreferences, + SamlPreferencesExtractedData, +} from '@/Interface'; export const initSSO = (context: IRestApiContext): Promise => { return makeRestApiRequest(context, 'GET', '/sso/saml/initsso'); }; + +export const getSamlMetadata = (context: IRestApiContext): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/metadata'); +}; + +export const getSamlConfig = ( + context: IRestApiContext, +): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/config'); +}; + +export const saveSamlConfig = ( + context: IRestApiContext, + data: SamlPreferences, +): Promise => { + return makeRestApiRequest(context, 'POST', '/sso/saml/config', data); +}; + +export const toggleSamlConfig = ( + context: IRestApiContext, + data: SamlPreferencesLoginEnabled, +): Promise => { + return makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data); +}; + +export const testSamlConfig = (context: IRestApiContext): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/config/test'); +}; diff --git a/packages/editor-ui/src/api/tags.ts b/packages/editor-ui/src/api/tags.ts index 45392781251bb..88937cf2e857a 100644 --- a/packages/editor-ui/src/api/tags.ts +++ b/packages/editor-ui/src/api/tags.ts @@ -1,4 +1,4 @@ -import { IRestApiContext, ITag } from '@/Interface'; +import type { IRestApiContext, ITag } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; export async function getTags(context: IRestApiContext, withUsageCount = false): Promise { diff --git a/packages/editor-ui/src/api/templates.ts b/packages/editor-ui/src/api/templates.ts index 870f962d48741..46b4bf2f3e9e5 100644 --- a/packages/editor-ui/src/api/templates.ts +++ b/packages/editor-ui/src/api/templates.ts @@ -1,4 +1,4 @@ -import { +import type { ITemplatesCategory, ITemplatesCollection, ITemplatesQuery, @@ -7,7 +7,7 @@ import { ITemplatesWorkflowResponse, IWorkflowTemplate, } from '@/Interface'; -import { IDataObject } from 'n8n-workflow'; +import type { IDataObject } from 'n8n-workflow'; import { get } from '@/utils'; function stringifyArray(arr: number[]) { diff --git a/packages/editor-ui/src/api/usage.ts b/packages/editor-ui/src/api/usage.ts index 5266e5a82b5b9..8d79f59e7761b 100644 --- a/packages/editor-ui/src/api/usage.ts +++ b/packages/editor-ui/src/api/usage.ts @@ -1,5 +1,5 @@ import { makeRestApiRequest } from '@/utils'; -import { IRestApiContext, UsageState } from '@/Interface'; +import type { IRestApiContext, UsageState } from '@/Interface'; export const getLicense = (context: IRestApiContext): Promise => { return makeRestApiRequest(context, 'GET', '/license'); diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 50b7b9043e2d4..1b693f4fbd760 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -1,11 +1,11 @@ -import { +import type { CurrentUserResponse, IInviteResponse, IPersonalizationLatestVersion, IRestApiContext, IUserResponse, } from '@/Interface'; -import { IDataObject } from 'n8n-workflow'; +import type { IDataObject } from 'n8n-workflow'; import { makeRestApiRequest } from '@/utils/apiUtils'; export function loginCurrentUser(context: IRestApiContext): Promise { @@ -89,11 +89,23 @@ export async function changePassword( export function updateCurrentUser( context: IRestApiContext, - params: { id: string; firstName: string; lastName: string; email: string }, + params: { + id?: string; + firstName?: string; + lastName?: string; + email: string; + }, ): Promise { return makeRestApiRequest(context, 'PATCH', '/me', params as unknown as IDataObject); } +export function updateCurrentUserSettings( + context: IRestApiContext, + settings: IUserResponse['settings'], +): Promise { + return makeRestApiRequest(context, 'PATCH', '/me/settings', settings); +} + export function updateCurrentUserPassword( context: IRestApiContext, params: { newPassword: string; currentPassword: string }, diff --git a/packages/editor-ui/src/api/versions.ts b/packages/editor-ui/src/api/versions.ts index e6842342fd3bd..4896318bfa283 100644 --- a/packages/editor-ui/src/api/versions.ts +++ b/packages/editor-ui/src/api/versions.ts @@ -1,4 +1,4 @@ -import { IVersion } from '@/Interface'; +import type { IVersion } from '@/Interface'; import { INSTANCE_ID_HEADER } from '@/constants'; import { get } from '@/utils'; diff --git a/packages/editor-ui/src/api/workflow-webhooks.ts b/packages/editor-ui/src/api/workflow-webhooks.ts index b31192f5da1f2..601073a7b05c6 100644 --- a/packages/editor-ui/src/api/workflow-webhooks.ts +++ b/packages/editor-ui/src/api/workflow-webhooks.ts @@ -1,4 +1,4 @@ -import { IOnboardingCallPrompt, IOnboardingCallPromptResponse, IUser } from '@/Interface'; +import type { IOnboardingCallPrompt, IUser } from '@/Interface'; import { get, post } from '@/utils'; const N8N_API_BASE_URL = 'https://api.n8n.io/api'; diff --git a/packages/editor-ui/src/api/workflows.ee.ts b/packages/editor-ui/src/api/workflows.ee.ts index 499fad502cb5f..1282a8ce86b56 100644 --- a/packages/editor-ui/src/api/workflows.ee.ts +++ b/packages/editor-ui/src/api/workflows.ee.ts @@ -1,6 +1,6 @@ -import { IRestApiContext, IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface'; +import type { IRestApiContext, IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface'; import { makeRestApiRequest } from '@/utils'; -import { IDataObject } from 'n8n-workflow'; +import type { IDataObject } from 'n8n-workflow'; export async function setWorkflowSharedWith( context: IRestApiContext, diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index 7e109b631a665..7b2d1133cb664 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -1,5 +1,5 @@ -import { IRestApiContext } from '@/Interface'; -import { IDataObject } from 'n8n-workflow'; +import type { IExecutionsCurrentSummaryExtended, IRestApiContext } from '@/Interface'; +import type { ExecutionFilters, ExecutionOptions, IDataObject } from 'n8n-workflow'; import { makeRestApiRequest } from '@/utils'; export async function getNewWorkflow(context: IRestApiContext, name?: string) { @@ -30,8 +30,12 @@ export async function getCurrentExecutions(context: IRestApiContext, filter: IDa return await makeRestApiRequest(context, 'GET', '/executions-current', { filter }); } -export async function getFinishedExecutions(context: IRestApiContext, filter: IDataObject) { - return await makeRestApiRequest(context, 'GET', '/executions', { filter }); +export async function getExecutions( + context: IRestApiContext, + filter?: ExecutionFilters, + options?: ExecutionOptions, +): Promise<{ count: number; results: IExecutionsCurrentSummaryExtended[]; estimated: boolean }> { + return await makeRestApiRequest(context, 'GET', '/executions', { filter, ...options }); } export async function getExecutionData(context: IRestApiContext, executionId: string) { diff --git a/packages/editor-ui/src/components/AboutModal.vue b/packages/editor-ui/src/components/AboutModal.vue index de35589f64bd1..19b57c1fd0bb7 100644 --- a/packages/editor-ui/src/components/AboutModal.vue +++ b/packages/editor-ui/src/components/AboutModal.vue @@ -54,14 +54,15 @@ + + diff --git a/packages/editor-ui/src/components/Banner.vue b/packages/editor-ui/src/components/Banner.vue index 7ce1710f6c2c2..8b48fe6883af4 100644 --- a/packages/editor-ui/src/components/Banner.vue +++ b/packages/editor-ui/src/components/Banner.vue @@ -36,9 +36,9 @@ - - diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index d01804660dd83..c775580dddebc 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -1,38 +1,78 @@ - + diff --git a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts index cf1e972d8d7e8..1d0c4872df6a8 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts @@ -15,30 +15,36 @@ import { insertNewlineAndIndent, toggleComment, redo, + deleteCharBackward, } from '@codemirror/commands'; import { lintGutter } from '@codemirror/lint'; +import type { Extension } from '@codemirror/state'; import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler'; -export const baseExtensions = [ +export const readOnlyEditorExtensions: readonly Extension[] = [ lineNumbers(), - highlightActiveLineGutter(), + EditorView.lineWrapping, highlightSpecialChars(), +]; + +export const writableEditorExtensions: readonly Extension[] = [ history(), - foldGutter(), lintGutter(), + foldGutter(), codeInputHandler(), dropCursor(), indentOnInput(), bracketMatching(), highlightActiveLine(), + highlightActiveLineGutter(), keymap.of([ { key: 'Enter', run: insertNewlineAndIndent }, { key: 'Tab', run: acceptCompletion }, { key: 'Enter', run: acceptCompletion }, { key: 'Mod-/', run: toggleComment }, { key: 'Mod-Shift-z', run: redo }, + { key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward }, indentWithTab, ]), - EditorView.lineWrapping, ]; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts index d37e979499837..efebc8e76d7ac 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts @@ -13,6 +13,7 @@ import { luxonCompletions } from './completions/luxon.completions'; import { itemIndexCompletions } from './completions/itemIndex.completions'; import { itemFieldCompletions } from './completions/itemField.completions'; import { jsonFieldCompletions } from './completions/jsonField.completions'; +import { variablesCompletions } from './completions/variables.completions'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; @@ -24,6 +25,7 @@ export const completerExtension = mixins( requireCompletions, executionCompletions, workflowCompletions, + variablesCompletions, prevNodeCompletions, luxonCompletions, itemIndexCompletions, @@ -49,6 +51,7 @@ export const completerExtension = mixins( this.nodeSelectorCompletions, this.prevNodeCompletions, this.workflowCompletions, + this.variablesCompletions, this.executionCompletions, // luxon @@ -167,6 +170,7 @@ export const completerExtension = mixins( // core if (value === '$execution') return this.executionCompletions(context, variable); + if (value === '$vars') return this.variablesCompletions(context, variable); if (value === '$workflow') return this.workflowCompletions(context, variable); if (value === '$prevNode') return this.prevNodeCompletions(context, variable); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts index 284a5594c40a4..a9401714ebce3 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts @@ -67,6 +67,10 @@ export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({ label: '$workflow', info: this.$locale.baseText('codeNodeEditor.completer.$workflow'), }, + { + label: '$vars', + info: this.$locale.baseText('codeNodeEditor.completer.$vars'), + }, { label: '$now', info: this.$locale.baseText('codeNodeEditor.completer.$now'), @@ -79,6 +83,18 @@ export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({ label: '$jmespath()', info: this.$locale.baseText('codeNodeEditor.completer.$jmespath'), }, + { + label: '$if()', + info: this.$locale.baseText('codeNodeEditor.completer.$if'), + }, + { + label: '$min()', + info: this.$locale.baseText('codeNodeEditor.completer.$min'), + }, + { + label: '$max()', + info: this.$locale.baseText('codeNodeEditor.completer.$max'), + }, { label: '$runIndex', info: this.$locale.baseText('codeNodeEditor.completer.$runIndex'), diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts new file mode 100644 index 0000000000000..dc60e9d96359d --- /dev/null +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { addVarType } from '../utils'; +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import type { CodeNodeEditorMixin } from '../types'; +import { useEnvironmentsStore } from '@/stores'; + +const escape = (str: string) => str.replace('$', '\\$'); + +export const variablesCompletions = (Vue as CodeNodeEditorMixin).extend({ + methods: { + /** + * Complete `$workflow.` to `.id .name .active`. + */ + variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const environmentsStore = useEnvironmentsStore(); + const options: Completion[] = environmentsStore.variables.map((variable) => ({ + label: `${matcher}.${variable.key}`, + info: variable.value, + })); + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }, + }, +}); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/constants.ts b/packages/editor-ui/src/components/CodeNodeEditor/constants.ts index 20ce31b03b617..b5da1da6b7575 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/constants.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/constants.ts @@ -51,3 +51,6 @@ $input.item.json.myNewField = 1; return $input.item; `.trim(); + +export const CODE_LANGUAGES = ['javaScript', 'json'] as const; +export const CODE_MODES = ['runOnceForAllItems', 'runOnceForEachItem'] as const; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/linter.ts b/packages/editor-ui/src/components/CodeNodeEditor/linter.ts index 96fe8d397be02..6226021b84a08 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/linter.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/linter.ts @@ -1,5 +1,7 @@ import Vue from 'vue'; -import { Diagnostic, linter as createLinter } from '@codemirror/lint'; +import type { Diagnostic } from '@codemirror/lint'; +import { linter as createLinter } from '@codemirror/lint'; +import { jsonParseLinter } from '@codemirror/lang-json'; import * as esprima from 'esprima-next'; import { @@ -11,12 +13,18 @@ import { walk } from './utils'; import type { EditorView } from '@codemirror/view'; import type { Node } from 'estree'; -import type { CodeNodeEditorMixin, RangeNode } from './types'; +import type { CodeLanguage, CodeNodeEditorMixin, RangeNode } from './types'; export const linterExtension = (Vue as CodeNodeEditorMixin).extend({ methods: { - linterExtension() { - return createLinter(this.lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS }); + createLinter(language: CodeLanguage) { + switch (language) { + case 'javaScript': + return createLinter(this.lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS }); + case 'json': + return createLinter(jsonParseLinter()); + } + return undefined; }, lintSource(editorView: EditorView): Diagnostic[] { @@ -133,7 +141,7 @@ export const linterExtension = (Vue as CodeNodeEditorMixin).extend({ } /** - * Lint for `.item` unavailable in `runOnceForAllItems` mode + * Lint for `.item` unavailable in `$input` in `runOnceForAllItems` mode * * $input.item -> */ @@ -141,13 +149,15 @@ export const linterExtension = (Vue as CodeNodeEditorMixin).extend({ if (this.mode === 'runOnceForAllItems') { type TargetNode = RangeNode & { property: RangeNode }; - const isUnavailableItemAccess = (node: Node) => + const isUnavailableInputItemAccess = (node: Node) => node.type === 'MemberExpression' && node.computed === false && + node.object.type === 'Identifier' && + node.object.name === '$input' && node.property.type === 'Identifier' && node.property.name === 'item'; - walk(ast, isUnavailableItemAccess).forEach((node) => { + walk(ast, isUnavailableInputItemAccess).forEach((node) => { const [start, end] = this.getRange(node.property); lintings.push({ diff --git a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts index 8dabe33098411..166d214be89ac 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts @@ -29,7 +29,11 @@ const BASE_STYLING = { const cssStyleDeclaration = getComputedStyle(document.documentElement); -export const CODE_NODE_EDITOR_THEME = [ +interface ThemeSettings { + isReadOnly?: boolean; +} + +export const codeNodeEditorTheme = ({ isReadOnly }: ThemeSettings) => [ EditorView.theme({ '&': { 'font-size': BASE_STYLING.fontSize, @@ -37,6 +41,7 @@ export const CODE_NODE_EDITOR_THEME = [ borderRadius: cssStyleDeclaration.getPropertyValue('--border-radius-base'), backgroundColor: 'var(--color-code-background)', color: 'var(--color-code-foreground)', + height: '100%', }, '.cm-content': { fontFamily: BASE_STYLING.fontFamily, @@ -48,6 +53,13 @@ export const CODE_NODE_EDITOR_THEME = [ '&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': { backgroundColor: 'var(--color-code-selection)', }, + '&.cm-editor': { + ...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}), + }, + '&.cm-editor.cm-focused': { + outline: 'none', + borderColor: 'var(--color-secondary)', + }, '.cm-activeLine': { backgroundColor: 'var(--color-code-lineHighlight)', }, @@ -55,8 +67,11 @@ export const CODE_NODE_EDITOR_THEME = [ backgroundColor: 'var(--color-code-lineHighlight)', }, '.cm-gutters': { - backgroundColor: 'var(--color-code-gutterBackground)', + backgroundColor: isReadOnly + ? 'var(--color-code-background-readonly)' + : 'var(--color-code-gutterBackground)', color: 'var(--color-code-gutterForeground)', + borderRadius: 'var(--border-radius-base)', }, '.cm-tooltip': { maxWidth: BASE_STYLING.tooltip.maxWidth, @@ -64,7 +79,8 @@ export const CODE_NODE_EDITOR_THEME = [ }, '.cm-scroller': { overflow: 'auto', - maxHeight: BASE_STYLING.maxHeight, + maxHeight: '100%', + ...(isReadOnly ? {} : { minHeight: '10em' }), }, '.cm-diagnosticAction': { backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor, diff --git a/packages/editor-ui/src/components/CodeNodeEditor/types.ts b/packages/editor-ui/src/components/CodeNodeEditor/types.ts index 38d5e669b89b4..04747cf153bd6 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/types.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/types.ts @@ -2,6 +2,7 @@ import type { EditorView } from '@codemirror/view'; import type { I18nClass } from '@/plugins/i18n'; import type { Workflow } from 'n8n-workflow'; import type { Node } from 'estree'; +import type { CODE_LANGUAGES, CODE_MODES } from './constants'; export type CodeNodeEditorMixin = Vue.VueConstructor< Vue & { @@ -13,3 +14,6 @@ export type CodeNodeEditorMixin = Vue.VueConstructor< >; export type RangeNode = Node & { range: [number, number] }; + +export type CodeLanguage = (typeof CODE_LANGUAGES)[number]; +export type CodeMode = (typeof CODE_MODES)[number]; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts index b6bd5b473038b..be8560a61e352 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts @@ -1,4 +1,4 @@ -import * as esprima from 'esprima-next'; +import type * as esprima from 'esprima-next'; import type { Completion } from '@codemirror/autocomplete'; import type { Node } from 'estree'; import type { RangeNode } from './types'; diff --git a/packages/editor-ui/src/components/CollectionParameter.vue b/packages/editor-ui/src/components/CollectionParameter.vue index edcadf6472933..919da56fd74e5 100644 --- a/packages/editor-ui/src/components/CollectionParameter.vue +++ b/packages/editor-ui/src/components/CollectionParameter.vue @@ -46,16 +46,17 @@ @@ -140,19 +153,19 @@ onBeforeMount(() => { type="tertiary" size="medium" :active="!!countSelectedFilterProps" - data-testid="executions-filter-button" + data-test-id="executions-filter-button" > {{ countSelectedFilterProps }} {{ $locale.baseText('executionsList.filters') }} -
+
@@ -260,9 +273,9 @@ onBeforeMount(() => { @@ -277,7 +290,7 @@ onBeforeMount(() => { :placeholder="$locale.baseText('executionsFilter.savedDataKeyPlaceholder')" :value="filter.metadata[0]?.key" @input="onFilterMetaChange(0, 'key', $event)" - data-testid="execution-filter-saved-data-key-input" + data-test-id="execution-filter-saved-data-key-input" />