diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 7702fbff92..c3548a0cda 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -128,19 +128,19 @@ jobs: - name: Trigger a release note run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}' - merge-back-into-master: - name: Merge back into master - needs: [publish-to-npm, create-github-release] - if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4.1.1 - with: - fetch-depth: 0 - - run: | - git checkout --track origin/master - git config user.name "github-actions[bot]" - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }} - git push origin master - git push origin :${{github.event.pull_request.base.ref}} + # merge-back-into-master: + # name: Merge back into master + # needs: [publish-to-npm, create-github-release] + # if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }} + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4.1.1 + # with: + # fetch-depth: 0 + # - run: | + # git checkout --track origin/master + # git config user.name "github-actions[bot]" + # git config user.email 41898282+github-actions[bot]@users.noreply.github.com + # git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }} + # git push origin master + # git push origin :${{github.event.pull_request.base.ref}} diff --git a/CHANGELOG.md b/CHANGELOG.md index fba31697f5..219c7b1726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +# [1.61.0](https://github.com/n8n-io/n8n/compare/n8n@1.60.0...n8n@1.61.0) (2024-09-25) + + +### Bug Fixes + +* **core:** Add executionData to expressions in pagination code ([#10926](https://github.com/n8n-io/n8n/issues/10926)) ([eac103e](https://github.com/n8n-io/n8n/commit/eac103e367d59a532b9ba12db78a0dd10aee62fb)) +* **core:** Fix webhook binary data max size configuration ([#10897](https://github.com/n8n-io/n8n/issues/10897)) ([693fb7e](https://github.com/n8n-io/n8n/commit/693fb7e580b7e030c86977bff6d319bbee4fcd62)) +* **core:** Remove subworkflow license check ([#10893](https://github.com/n8n-io/n8n/issues/10893)) ([0290e38](https://github.com/n8n-io/n8n/commit/0290e38f990275074eb7e7ccd0b41f1ae0215dd2)) +* **editor:** Credentials scopes and n8n scopes mix up ([#10930](https://github.com/n8n-io/n8n/issues/10930)) ([e069608](https://github.com/n8n-io/n8n/commit/e0696080227aee7ccb50d51a82873e8a1ba4667d)) +* **editor:** Fix design system form component sizing ([#10961](https://github.com/n8n-io/n8n/issues/10961)) ([cf153ea](https://github.com/n8n-io/n8n/commit/cf153ea085165115ee523fbb1bd32080dde47eda)) +* **editor:** Fix modal overflow when AI is enabled in code node ([#10887](https://github.com/n8n-io/n8n/issues/10887)) ([f9f303f](https://github.com/n8n-io/n8n/commit/f9f303f562084db8c8956da267680b1f935aa2df)) +* **editor:** Fix source control push modal checkboxes ([#10910](https://github.com/n8n-io/n8n/issues/10910)) ([8db8817](https://github.com/n8n-io/n8n/commit/8db88178511749b19a5878816ef062092fd9f2be)) +* **editor:** Fix styling and typography in AI Assistant chat ([#10895](https://github.com/n8n-io/n8n/issues/10895)) ([57ff3cc](https://github.com/n8n-io/n8n/commit/57ff3cc27b9470bfbe2486c3c1831c57f5a4075f)) +* **editor:** Prevent clipboard xss injection ([#10894](https://github.com/n8n-io/n8n/issues/10894)) ([e20ab59](https://github.com/n8n-io/n8n/commit/e20ab59c1dcf9da19a30268ce19930bfa7e38992)) +* **editor:** Prevent node name input in NDV to expand unnecessarily ([#10922](https://github.com/n8n-io/n8n/issues/10922)) ([a2237d1](https://github.com/n8n-io/n8n/commit/a2237d128ff6a4d65cd30325b6b9d9b765ca7be6)) +* **editor:** Update gird size when opening credentials support chat ([#10882](https://github.com/n8n-io/n8n/issues/10882)) ([b86fd80](https://github.com/n8n-io/n8n/commit/b86fd80fc9fe06011367ca04a75e4b52533db1fe)) +* **editor:** Use `:focus-visible` instead for `:focus` for buttons ([#10921](https://github.com/n8n-io/n8n/issues/10921)) ([bf28d09](https://github.com/n8n-io/n8n/commit/bf28d0965c46620a106c87037bafd2cf936f1050)) +* **editor:** Use correct output for connected nodes in schema view ([#10928](https://github.com/n8n-io/n8n/issues/10928)) ([ad60d49](https://github.com/n8n-io/n8n/commit/ad60d49b4251138a7c69cb5e9f00c3ef875486e0)) +* Enable Assistant on other credential views ([#10931](https://github.com/n8n-io/n8n/issues/10931)) ([557db9c](https://github.com/n8n-io/n8n/commit/557db9c170a89447ec9cc14aa1af51e5fd11dd92)) +* Ensure user id for early track events ([#10885](https://github.com/n8n-io/n8n/issues/10885)) ([23c09ea](https://github.com/n8n-io/n8n/commit/23c09eae4223545c717270a5cd305d2e57e1ad5b)) +* **Google Sheets Node:** Insert data if sheet is empty instead of error ([#10942](https://github.com/n8n-io/n8n/issues/10942)) ([c75990e](https://github.com/n8n-io/n8n/commit/c75990e0632c581384542610a886ef89621a9403)) +* Hide assistant button when showing Click to connect ([#10932](https://github.com/n8n-io/n8n/issues/10932)) ([d74cff2](https://github.com/n8n-io/n8n/commit/d74cff20301f285588f93207f29660d25fdbc8da)) +* **HTTP Request Node:** Do not modify request object when sanitizing message for UI ([#10923](https://github.com/n8n-io/n8n/issues/10923)) ([8cc10cc](https://github.com/n8n-io/n8n/commit/8cc10cc2c1869b9abcafd157e41be65ce2b6f499)) +* **MQTT Node:** Close connection if connection attempt fails ([#10873](https://github.com/n8n-io/n8n/issues/10873)) ([ee7147c](https://github.com/n8n-io/n8n/commit/ee7147c6b3b053ac8fc317319ab257204e599f16)) +* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#10965](https://github.com/n8n-io/n8n/issues/10965)) ([62159bd](https://github.com/n8n-io/n8n/commit/62159bd71c9a0303b597a68113e0ac50473ee8d4)) +* **Notion Node:** Allow UUID v8 in notion id checks ([#10938](https://github.com/n8n-io/n8n/issues/10938)) ([46beda0](https://github.com/n8n-io/n8n/commit/46beda05f6771c31bcf0b6a781976d8261079a66)) + + +### Features + +* **Brandfetch Node:** Update to use new API ([#10877](https://github.com/n8n-io/n8n/issues/10877)) ([08ba9a3](https://github.com/n8n-io/n8n/commit/08ba9a36a43b6c84f69bb04fa4d6419a7a4adddf)) +* **editor:** Setup Sentry integration ([#10945](https://github.com/n8n-io/n8n/issues/10945)) ([6de4dff](https://github.com/n8n-io/n8n/commit/6de4dfff87e4da888567081a9928d9682bdea11d)) +* **editor:** Show a notice before deleting annotated executions ([#10934](https://github.com/n8n-io/n8n/issues/10934)) ([dcc1c72](https://github.com/n8n-io/n8n/commit/dcc1c72fc4b56c3252183541b22da801804d4f79)) +* Page size 1 option ([#10957](https://github.com/n8n-io/n8n/issues/10957)) ([bdc0622](https://github.com/n8n-io/n8n/commit/bdc0622f59e98c9e6c542f5cb59a2dbd9008ba96)) +* **Slack Node:** Add option to hide workflow link on message update ([#10927](https://github.com/n8n-io/n8n/issues/10927)) ([422c946](https://github.com/n8n-io/n8n/commit/422c9463c8d931a728615a1fe5a10f05a96ecaa2)) + + +### Performance Improvements + +* **editor:** Use virtual scrolling in `RunDataJson.vue` ([#10838](https://github.com/n8n-io/n8n/issues/10838)) ([f5474ff](https://github.com/n8n-io/n8n/commit/f5474ff79198a2f5a145d0a9df1bb651ea677ec5)) + + + # [1.60.0](https://github.com/n8n-io/n8n/compare/n8n@1.59.0...n8n@1.60.0) (2024-09-18) diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 19256f3bf9..5788af171c 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -229,6 +229,35 @@ describe('Workflow Executions', () => { cy.getByTestId('executions-filter-reset-button').should('be.visible').click(); executionsTab.getters.executionListItems().eq(11).should('be.visible'); }); + + it('should redirect back to editor after seeing a couple of execution using browser back button', () => { + createMockExecutions(); + cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); + + executionsTab.actions.switchToExecutionsTab(); + + cy.wait(['@getExecutions']); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + + executionsTab.getters.executionListItems().eq(2).click(); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + executionsTab.getters.executionListItems().eq(4).click(); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + executionsTab.getters.executionListItems().eq(6).click(); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + + cy.go('back'); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + cy.go('back'); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + cy.go('back'); + executionsTab.getters.workflowExecutionPreviewIframe().should('exist'); + cy.go('back'); + + cy.url().should('not.include', '/executions'); + cy.url().should('include', '/workflow/'); + workflowPage.getters.nodeViewRoot().should('be.visible'); + }); }); describe('when new workflow is not saved', () => { diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 4608b5eefc..a591d62895 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -674,6 +674,23 @@ describe('NDV', () => { ndv.getters.parameterInput('operation').find('input').should('have.value', 'Delete'); }); + it('Should show a notice when remote options cannot be fetched because of missing credentials', () => { + cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 403 }).as( + 'parameterOptions', + ); + + workflowPage.actions.addInitialNodeToCanvas(NOTION_NODE_NAME, { + keepNdvOpen: true, + action: 'Update a database page', + }); + + ndv.actions.addItemToFixedCollection('propertiesUi'); + ndv.getters + .parameterInput('key') + .find('input') + .should('have.value', 'Set up credential to see options'); + }); + it('Should show error state when remote options cannot be fetched', () => { cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as( 'parameterOptions', @@ -684,6 +701,11 @@ describe('NDV', () => { action: 'Update a database page', }); + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + ndv.actions.addItemToFixedCollection('propertiesUi'); ndv.getters .parameterInput('key') diff --git a/package.json b/package.json index 589b515f44..8253d86dfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.60.0", + "version": "1.61.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 726b0f908e..ec2bf1bd32 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.2.0", + "version": "0.3.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index 9a2451dcc4..f0b11ddeb2 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-benchmark", - "version": "1.4.0", + "version": "1.5.0", "description": "Cli for running benchmark tests for n8n", "main": "dist/index", "scripts": { diff --git a/packages/@n8n/benchmark/scripts/run-for-n8n-setup.mjs b/packages/@n8n/benchmark/scripts/run-for-n8n-setup.mjs index d3389f1e7b..8c8ea5f6dd 100755 --- a/packages/@n8n/benchmark/scripts/run-for-n8n-setup.mjs +++ b/packages/@n8n/benchmark/scripts/run-for-n8n-setup.mjs @@ -105,9 +105,8 @@ async function main() { console.error(error.message); console.error(''); await printContainerStatus(dockerComposeClient); - console.error(''); - await dumpLogs(dockerComposeClient); } finally { + await dumpLogs(dockerComposeClient); await dockerComposeClient.$('down'); } } @@ -118,7 +117,7 @@ async function printContainerStatus(dockerComposeClient) { } async function dumpLogs(dockerComposeClient) { - console.error('Container logs:'); + console.info('Container logs:'); await dockerComposeClient.$('logs'); } diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 75be87b674..e1ac104f04 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.10.0", + "version": "1.11.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts index 8249cbd90c..dfbdb3e9d1 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts @@ -10,10 +10,21 @@ import { import { RetrievalQAChain } from 'langchain/chains'; import type { BaseLanguageModel } from '@langchain/core/language_models/base'; import type { BaseRetriever } from '@langchain/core/retrievers'; +import { + ChatPromptTemplate, + SystemMessagePromptTemplate, + HumanMessagePromptTemplate, + PromptTemplate, +} from '@langchain/core/prompts'; import { getTemplateNoticeField } from '../../../utils/sharedFields'; -import { getPromptInputByType } from '../../../utils/helpers'; +import { getPromptInputByType, isChatInstance } from '../../../utils/helpers'; import { getTracingConfig } from '../../../utils/tracing'; +const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question. +If you don't know the answer, just say that you don't know, don't try to make up an answer. +---------------- +{context}`; + export class ChainRetrievalQa implements INodeType { description: INodeTypeDescription = { displayName: 'Question and Answer Chain', @@ -137,6 +148,26 @@ export class ChainRetrievalQa implements INodeType { }, }, }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'System Prompt Template', + name: 'systemPromptTemplate', + type: 'string', + default: SYSTEM_PROMPT_TEMPLATE, + description: + 'Template string used for the system prompt. This should include the variable `{context}` for the provided context. For text completion models, you should also include the variable `{question}` for the user’s query.', + typeOptions: { + rows: 6, + }, + }, + ], + }, ], }; @@ -154,7 +185,6 @@ export class ChainRetrievalQa implements INodeType { )) as BaseRetriever; const items = this.getInputData(); - const chain = RetrievalQAChain.fromLLM(model, retriever); const returnData: INodeExecutionData[] = []; @@ -178,6 +208,35 @@ export class ChainRetrievalQa implements INodeType { throw new NodeOperationError(this.getNode(), 'The ‘query‘ parameter is empty.'); } + const options = this.getNodeParameter('options', itemIndex, {}) as { + systemPromptTemplate?: string; + }; + + const chainParameters = {} as { + prompt?: PromptTemplate | ChatPromptTemplate; + }; + + if (options.systemPromptTemplate !== undefined) { + if (isChatInstance(model)) { + const messages = [ + SystemMessagePromptTemplate.fromTemplate(options.systemPromptTemplate), + HumanMessagePromptTemplate.fromTemplate('{question}'), + ]; + const chatPromptTemplate = ChatPromptTemplate.fromMessages(messages); + + chainParameters.prompt = chatPromptTemplate; + } else { + const completionPromptTemplate = new PromptTemplate({ + template: options.systemPromptTemplate, + inputVariables: ['context', 'question'], + }); + + chainParameters.prompt = completionPromptTemplate; + } + } + + const chain = RetrievalQAChain.fromLLM(model, retriever, chainParameters); + const response = await chain.withConfig(getTracingConfig(this)).invoke({ query }); returnData.push({ json: { response } }); } catch (error) { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 421e85e1b5..32f6be42e7 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -275,7 +275,11 @@ export class ToolHttpRequest implements INodeType { method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods, url: this.getNodeParameter('url', itemIndex) as string, qs: {}, - headers: {}, + headers: { + // FIXME: This is a workaround to prevent the node from sending a default User-Agent (`n8n`) when the header is not set. + // Needs to be replaced with a proper fix after NODE-1777 is resolved + 'User-Agent': undefined, + }, body: {}, }; diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 7c0178f60e..ec7001286d 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.60.0", + "version": "1.61.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index a3a1e612ab..6002da5caa 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -71,5 +71,11 @@ module.exports = { ], }, }, + { + files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'], + rules: { + 'n8n-local-rules/no-dynamic-import-template': 'off', + }, + }, ], }; diff --git a/packages/cli/bin/n8n b/packages/cli/bin/n8n index 6eca060851..c4b593ccc9 100755 --- a/packages/cli/bin/n8n +++ b/packages/cli/bin/n8n @@ -43,6 +43,13 @@ require('express-async-errors'); require('source-map-support').install(); require('reflect-metadata'); +// Skip loading dotenv in e2e tests. +// Also, do not use `inE2ETests` from constants here, because that'd end up code that might read from `process.env` before the values are loaded from an `.env` file. +if (process.env.E2E_TESTS !== 'true') { + // Loading dotenv early ensures that `process.env` is up-to-date everywhere in code + require('dotenv').config(); +} + if (process.env.NODEJS_PREFER_IPV4 === 'true') { require('dns').setDefaultResultOrder('ipv4first'); } diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d108c30d5..6d11b9cf55 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.60.0", + "version": "1.61.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 6661057b9a..0c799aa8a5 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -1,6 +1,5 @@ import { GlobalConfig } from '@n8n/config'; import convict from 'convict'; -import dotenv from 'dotenv'; import { flatten } from 'flat'; import { readFileSync } from 'fs'; import merge from 'lodash/merge'; @@ -22,8 +21,6 @@ if (inE2ETests) { process.env.N8N_PUBLIC_API_DISABLED = 'true'; process.env.SKIP_STATISTICS_EVENTS = 'true'; process.env.N8N_SECURE_COOKIE = 'false'; -} else { - dotenv.config(); } // Load schema after process.env has been overwritten diff --git a/packages/cli/src/controllers/__tests__/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts index 3c9af48689..7f5f861b0e 100644 --- a/packages/cli/src/controllers/__tests__/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -2,11 +2,14 @@ import { UserUpdateRequestDto } from '@n8n/api-types'; import type { Response } from 'express'; import { mock, anyObject } from 'jest-mock-extended'; import jwt from 'jsonwebtoken'; +import { randomString } from 'n8n-workflow'; import { Container } from 'typedi'; import { AUTH_COOKIE_NAME } from '@/constants'; -import { API_KEY_PREFIX, MeController } from '@/controllers/me.controller'; +import { MeController } from '@/controllers/me.controller'; +import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -18,6 +21,7 @@ import type { PublicUser } from '@/interfaces'; import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; import type { AuthenticatedRequest, MeRequest } from '@/requests'; +import { API_KEY_PREFIX } from '@/services/public-api-key.service'; import { UserService } from '@/services/user.service'; import { mockInstance } from '@test/mocking'; import { badPasswords } from '@test/test-data'; @@ -30,6 +34,7 @@ describe('MeController', () => { const userService = mockInstance(UserService); const userRepository = mockInstance(UserRepository); const mockMfaService = mockInstance(MfaService); + const apiKeysRepository = mockInstance(ApiKeyRepository); mockInstance(AuthUserRepository); mockInstance(InvalidAuthTokenRepository); mockInstance(License).isWithinUsersLimit.mockReturnValue(true); @@ -412,27 +417,63 @@ describe('MeController', () => { describe('API Key methods', () => { let req: AuthenticatedRequest; beforeAll(() => { - req = mock({ user: mock>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) }); + req = mock({ user: mock({ id: '123' }) }); }); describe('createAPIKey', () => { it('should create and save an API key', async () => { - const { apiKey } = await controller.createAPIKey(req); - expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey }); + const apiKeyData = { + id: '123', + userId: '123', + label: 'My API Key', + apiKey: `${API_KEY_PREFIX}${randomString(42)}`, + createdAt: new Date(), + } as ApiKey; + + apiKeysRepository.upsert.mockImplementation(); + + apiKeysRepository.findOneByOrFail.mockResolvedValue(apiKeyData); + + const newApiKey = await controller.createAPIKey(req); + + expect(apiKeysRepository.upsert).toHaveBeenCalled(); + expect(apiKeyData).toEqual(newApiKey); }); }); - describe('getAPIKey', () => { - it('should return the users api key redacted', async () => { - const { apiKey } = await controller.getAPIKey(req); - expect(apiKey).not.toEqual(req.user.apiKey); + describe('getAPIKeys', () => { + it('should return the users api keys redacted', async () => { + const apiKeyData = { + id: '123', + userId: '123', + label: 'My API Key', + apiKey: `${API_KEY_PREFIX}${randomString(42)}`, + createdAt: new Date(), + } as ApiKey; + + apiKeysRepository.findBy.mockResolvedValue([apiKeyData]); + + const apiKeys = await controller.getAPIKeys(req); + expect(apiKeys[0].apiKey).not.toEqual(apiKeyData.apiKey); + expect(apiKeysRepository.findBy).toHaveBeenCalledWith({ userId: req.user.id }); }); }); describe('deleteAPIKey', () => { it('should delete the API key', async () => { + const user = mock({ + id: '123', + password: 'password', + authIdentities: [], + role: 'global:member', + mfaEnabled: false, + }); + const req = mock({ user, params: { id: user.id } }); await controller.deleteAPIKey(req); - expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey: null }); + expect(apiKeysRepository.delete).toHaveBeenCalledWith({ + userId: req.user.id, + id: req.params.id, + }); }); }); }); diff --git a/packages/cli/src/controllers/annotation-tags.controller.ts b/packages/cli/src/controllers/annotation-tags.controller.ee.ts similarity index 99% rename from packages/cli/src/controllers/annotation-tags.controller.ts rename to packages/cli/src/controllers/annotation-tags.controller.ee.ts index ff43c2ef7e..ed6ff8c52a 100644 --- a/packages/cli/src/controllers/annotation-tags.controller.ts +++ b/packages/cli/src/controllers/annotation-tags.controller.ee.ts @@ -1,6 +1,6 @@ import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators'; import { AnnotationTagsRequest } from '@/requests'; -import { AnnotationTagService } from '@/services/annotation-tag.service'; +import { AnnotationTagService } from '@/services/annotation-tag.service.ee'; @RestController('/annotation-tags') export class AnnotationTagsController { diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 0e1bbb37a6..aac1b48833 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -4,7 +4,6 @@ import { UserUpdateRequestDto, } from '@n8n/api-types'; import { plainToInstance } from 'class-transformer'; -import { randomBytes } from 'crypto'; import { type RequestHandler, Response } from 'express'; import { AuthService } from '@/auth/auth.service'; @@ -22,13 +21,12 @@ import { MfaService } from '@/mfa/mfa.service'; import { isApiEnabled } from '@/public-api'; import { AuthenticatedRequest, MeRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { UserService } from '@/services/user.service'; import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; -export const API_KEY_PREFIX = 'n8n_api_'; - export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { if (isApiEnabled()) { next(); @@ -48,6 +46,7 @@ export class MeController { private readonly userRepository: UserRepository, private readonly eventService: EventService, private readonly mfaService: MfaService, + private readonly publicApiKeyService: PublicApiKeyService, ) {} /** @@ -219,34 +218,32 @@ export class MeController { } /** - * Creates an API Key + * Create an API Key */ - @Post('/api-key', { middlewares: [isApiEnabledMiddleware] }) + @Post('/api-keys', { middlewares: [isApiEnabledMiddleware] }) async createAPIKey(req: AuthenticatedRequest) { - const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; - - await this.userService.update(req.user.id, { apiKey }); + const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user); this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false }); - return { apiKey }; + return newApiKey; } /** - * Get an API Key + * Get API keys */ - @Get('/api-key', { middlewares: [isApiEnabledMiddleware] }) - async getAPIKey(req: AuthenticatedRequest) { - const apiKey = this.redactApiKey(req.user.apiKey); - return { apiKey }; + @Get('/api-keys', { middlewares: [isApiEnabledMiddleware] }) + async getAPIKeys(req: AuthenticatedRequest) { + const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user); + return apiKeys; } /** - * Deletes an API Key + * Delete an API Key */ - @Delete('/api-key', { middlewares: [isApiEnabledMiddleware] }) - async deleteAPIKey(req: AuthenticatedRequest) { - await this.userService.update(req.user.id, { apiKey: null }); + @Delete('/api-keys/:id', { middlewares: [isApiEnabledMiddleware] }) + async deleteAPIKey(req: MeRequest.DeleteAPIKey) { + await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id); this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false }); @@ -273,14 +270,4 @@ export class MeController { return user.settings; } - - private redactApiKey(apiKey: string | null) { - if (!apiKey) return; - const keepLength = 5; - return ( - API_KEY_PREFIX + - apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) + - '*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength) - ); - } } diff --git a/packages/cli/src/databases/entities/__tests__/user.entity.test.ts b/packages/cli/src/databases/entities/__tests__/user.entity.test.ts index 5901d0218b..5bd8b0f2cb 100644 --- a/packages/cli/src/databases/entities/__tests__/user.entity.test.ts +++ b/packages/cli/src/databases/entities/__tests__/user.entity.test.ts @@ -8,7 +8,6 @@ describe('User Entity', () => { firstName: 'Don', lastName: 'Joe', password: '123456789', - apiKey: '123', }); expect(JSON.stringify(user)).toEqual( '{"email":"test@example.com","firstName":"Don","lastName":"Joe"}', diff --git a/packages/cli/src/databases/entities/annotation-tag-entity.ts b/packages/cli/src/databases/entities/annotation-tag-entity.ee.ts similarity index 93% rename from packages/cli/src/databases/entities/annotation-tag-entity.ts rename to packages/cli/src/databases/entities/annotation-tag-entity.ee.ts index e89c75a5a8..4aed8df8af 100644 --- a/packages/cli/src/databases/entities/annotation-tag-entity.ts +++ b/packages/cli/src/databases/entities/annotation-tag-entity.ee.ts @@ -1,8 +1,8 @@ import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm'; import { IsString, Length } from 'class-validator'; -import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; -import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; +import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee'; +import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee'; import { WithTimestampsAndStringId } from './abstract-entity'; diff --git a/packages/cli/src/databases/entities/annotation-tag-mapping.ts b/packages/cli/src/databases/entities/annotation-tag-mapping.ee.ts similarity index 98% rename from packages/cli/src/databases/entities/annotation-tag-mapping.ts rename to packages/cli/src/databases/entities/annotation-tag-mapping.ee.ts index 2fa4fc79c1..5b1b9e1bf2 100644 --- a/packages/cli/src/databases/entities/annotation-tag-mapping.ts +++ b/packages/cli/src/databases/entities/annotation-tag-mapping.ee.ts @@ -1,7 +1,7 @@ import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; -import type { AnnotationTagEntity } from './annotation-tag-entity'; -import type { ExecutionAnnotation } from './execution-annotation'; +import type { AnnotationTagEntity } from './annotation-tag-entity.ee'; +import type { ExecutionAnnotation } from './execution-annotation.ee'; /** * This entity represents the junction table between the execution annotations and the tags diff --git a/packages/cli/src/databases/entities/api-key.ts b/packages/cli/src/databases/entities/api-key.ts new file mode 100644 index 0000000000..6e2df2d00c --- /dev/null +++ b/packages/cli/src/databases/entities/api-key.ts @@ -0,0 +1,25 @@ +import { Column, Entity, Index, ManyToOne, Unique } from '@n8n/typeorm'; + +import { WithTimestampsAndStringId } from './abstract-entity'; +import { User } from './user'; + +@Entity('user_api_keys') +@Unique(['userId', 'label']) +export class ApiKey extends WithTimestampsAndStringId { + @ManyToOne( + () => User, + (user) => user.id, + { onDelete: 'CASCADE' }, + ) + user: User; + + @Column({ type: String }) + userId: string; + + @Column({ type: String }) + label: string; + + @Index({ unique: true }) + @Column({ type: String }) + apiKey: string; +} diff --git a/packages/cli/src/databases/entities/execution-annotation.ts b/packages/cli/src/databases/entities/execution-annotation.ee.ts similarity index 98% rename from packages/cli/src/databases/entities/execution-annotation.ts rename to packages/cli/src/databases/entities/execution-annotation.ee.ts index 33943f6330..59820c83b5 100644 --- a/packages/cli/src/databases/entities/execution-annotation.ts +++ b/packages/cli/src/databases/entities/execution-annotation.ee.ts @@ -12,8 +12,8 @@ import { } from '@n8n/typeorm'; import type { AnnotationVote } from 'n8n-workflow'; -import type { AnnotationTagEntity } from './annotation-tag-entity'; -import type { AnnotationTagMapping } from './annotation-tag-mapping'; +import type { AnnotationTagEntity } from './annotation-tag-entity.ee'; +import type { AnnotationTagMapping } from './annotation-tag-mapping.ee'; import { ExecutionEntity } from './execution-entity'; @Entity({ name: 'execution_annotations' }) diff --git a/packages/cli/src/databases/entities/execution-entity.ts b/packages/cli/src/databases/entities/execution-entity.ts index d44cb6c3f0..f481bb97f4 100644 --- a/packages/cli/src/databases/entities/execution-entity.ts +++ b/packages/cli/src/databases/entities/execution-entity.ts @@ -12,7 +12,7 @@ import { } from '@n8n/typeorm'; import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; -import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; +import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee'; import { datetimeColumnType } from './abstract-entity'; import type { ExecutionData } from './execution-data'; diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 8ae29ababd..1993d20a75 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -1,11 +1,12 @@ -import { AnnotationTagEntity } from './annotation-tag-entity'; -import { AnnotationTagMapping } from './annotation-tag-mapping'; +import { AnnotationTagEntity } from './annotation-tag-entity.ee'; +import { AnnotationTagMapping } from './annotation-tag-mapping.ee'; +import { ApiKey } from './api-key'; import { AuthIdentity } from './auth-identity'; import { AuthProviderSyncHistory } from './auth-provider-sync-history'; import { AuthUser } from './auth-user'; import { CredentialsEntity } from './credentials-entity'; import { EventDestinations } from './event-destinations'; -import { ExecutionAnnotation } from './execution-annotation'; +import { ExecutionAnnotation } from './execution-annotation.ee'; import { ExecutionData } from './execution-data'; import { ExecutionEntity } from './execution-entity'; import { ExecutionMetadata } from './execution-metadata'; @@ -54,4 +55,5 @@ export const entities = { WorkflowHistory, Project, ProjectRelation, + ApiKey, }; diff --git a/packages/cli/src/databases/entities/user.ts b/packages/cli/src/databases/entities/user.ts index 32b257fcd1..b75bec757c 100644 --- a/packages/cli/src/databases/entities/user.ts +++ b/packages/cli/src/databases/entities/user.ts @@ -23,6 +23,7 @@ import { NoUrl } from '@/validators/no-url.validator'; import { NoXss } from '@/validators/no-xss.validator'; import { WithTimestamps, jsonColumnType } from './abstract-entity'; +import type { ApiKey } from './api-key'; import type { AuthIdentity } from './auth-identity'; import type { ProjectRelation } from './project-relation'; import type { SharedCredentials } from './shared-credentials'; @@ -89,6 +90,9 @@ export class User extends WithTimestamps implements IUser { @OneToMany('AuthIdentity', 'user') authIdentities: AuthIdentity[]; + @OneToMany('ApiKey', 'user') + apiKeys: ApiKey[]; + @OneToMany('SharedWorkflow', 'user') sharedWorkflows: SharedWorkflow[]; @@ -107,10 +111,6 @@ export class User extends WithTimestamps implements IUser { this.email = this.email?.toLowerCase() ?? null; } - @Column({ type: String, nullable: true }) - @Index({ unique: true }) - apiKey: string | null; - @Column({ type: Boolean, default: false }) mfaEnabled: boolean; @@ -151,7 +151,7 @@ export class User extends WithTimestamps implements IUser { } toJSON() { - const { password, apiKey, ...rest } = this; + const { password, ...rest } = this; return rest; } diff --git a/packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts b/packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts new file mode 100644 index 0000000000..547be1a8fb --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1724951148974-AddApiKeysTable.ts @@ -0,0 +1,58 @@ +import type { ApiKey } from '@/databases/entities/api-key'; +import type { MigrationContext } from '@/databases/types'; +import { generateNanoId } from '@/databases/utils/generators'; + +export class AddApiKeysTable1724951148974 { + async up({ + queryRunner, + escape, + runQuery, + schemaBuilder: { createTable, column }, + }: MigrationContext) { + const userTable = escape.tableName('user'); + const userApiKeysTable = escape.tableName('user_api_keys'); + const userIdColumn = escape.columnName('userId'); + const apiKeyColumn = escape.columnName('apiKey'); + const labelColumn = escape.columnName('label'); + const idColumn = escape.columnName('id'); + + // Create the new table + await createTable('user_api_keys') + .withColumns( + column('id').varchar(36).primary, + column('userId').uuid.notNull, + column('label').varchar(100).notNull, + column('apiKey').varchar().notNull, + ) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn(['userId', 'label'], true) + .withIndexOn(['apiKey'], true).withTimestamps; + + const usersWithApiKeys = (await queryRunner.query( + `SELECT ${idColumn}, ${apiKeyColumn} FROM ${userTable} WHERE ${apiKeyColumn} IS NOT NULL`, + )) as Array>; + + // Move the apiKey from the users table to the new table + await Promise.all( + usersWithApiKeys.map( + async (user: { id: string; apiKey: string }) => + await runQuery( + `INSERT INTO ${userApiKeysTable} (${idColumn}, ${userIdColumn}, ${apiKeyColumn}, ${labelColumn}) VALUES (:id, :userId, :apiKey, :label)`, + { + id: generateNanoId(), + userId: user.id, + apiKey: user.apiKey, + label: 'My API Key', + }, + ), + ), + ); + + // Drop apiKey column on user's table + await queryRunner.query(`ALTER TABLE ${userTable} DROP COLUMN ${apiKeyColumn};`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index f3660f905d..288f18edbe 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101 import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; +import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -128,4 +129,5 @@ export const mysqlMigrations: Migration[] = [ CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, CreateAnnotationTables1724753530828, + AddApiKeysTable1724951148974, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index e3ad5afa57..077d686b7e 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101 import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; +import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -128,4 +129,5 @@ export const postgresMigrations: Migration[] = [ CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, CreateAnnotationTables1724753530828, + AddApiKeysTable1724951148974, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts b/packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts new file mode 100644 index 0000000000..19d27aeb85 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1724951148974-AddApiKeysTable.ts @@ -0,0 +1,77 @@ +import type { ApiKey } from '@/databases/entities/api-key'; +import type { MigrationContext } from '@/databases/types'; +import { generateNanoId } from '@/databases/utils/generators'; + +export class AddApiKeysTable1724951148974 { + async up({ queryRunner, tablePrefix, runQuery }: MigrationContext) { + const tableName = `${tablePrefix}user_api_keys`; + + // Create the table + await queryRunner.query(` + CREATE TABLE ${tableName} ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + "userId" VARCHAR NOT NULL, + "label" VARCHAR(100) NOT NULL, + "apiKey" VARCHAR NOT NULL, + "createdAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + "updatedAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY ("userId") REFERENCES user(id) ON DELETE CASCADE, + UNIQUE ("userId", label), + UNIQUE("apiKey") + ); + `); + + const usersWithApiKeys = (await queryRunner.query( + `SELECT id, "apiKey" FROM ${tablePrefix}user WHERE "apiKey" IS NOT NULL`, + )) as Array>; + + // Move the apiKey from the users table to the new table + await Promise.all( + usersWithApiKeys.map( + async (user: { id: string; apiKey: string }) => + await runQuery( + `INSERT INTO ${tableName} ("id", "userId", "apiKey", "label") VALUES (:id, :userId, :apiKey, :label)`, + { + id: generateNanoId(), + userId: user.id, + apiKey: user.apiKey, + label: 'My API Key', + }, + ), + ), + ); + + // Create temporary table to store the users dropping the api key column + await queryRunner.query(` + CREATE TABLE users_new ( + id varchar PRIMARY KEY, + email VARCHAR(255) UNIQUE, + "firstName" VARCHAR(32), + "lastName" VARCHAR(32), + password VARCHAR, + "personalizationAnswers" TEXT, + "createdAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + "updatedAt" DATETIME(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + settings TEXT, + disabled BOOLEAN DEFAULT FALSE NOT NULL, + "mfaEnabled" BOOLEAN DEFAULT FALSE NOT NULL, + "mfaSecret" TEXT, + "mfaRecoveryCodes" TEXT, + role TEXT NOT NULL + ); + `); + + // Copy the data from the original users table + await queryRunner.query(` + INSERT INTO users_new ("id", "email", "firstName", "lastName", "password", "personalizationAnswers", "createdAt", "updatedAt", "settings", "disabled", "mfaEnabled", "mfaSecret", "mfaRecoveryCodes", "role") + SELECT "id", "email", "firstName", "lastName", "password", "personalizationAnswers", "createdAt", "updatedAt", "settings", "disabled", "mfaEnabled", "mfaSecret", "mfaRecoveryCodes", "role" + FROM ${tablePrefix}user; + `); + + // Drop table with apiKey column + await queryRunner.query(`DROP TABLE ${tablePrefix}user;`); + + // Rename the temporary table to users + await queryRunner.query('ALTER TABLE users_new RENAME TO user;'); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index a1739ae4a4..62fda4b7d0 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -37,6 +37,7 @@ import { AddMfaColumns1690000000030 } from './1690000000040-AddMfaColumns'; import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; +import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; @@ -122,6 +123,7 @@ const sqliteMigrations: Migration[] = [ CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, CreateAnnotationTables1724753530828, + AddApiKeysTable1724951148974, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ts b/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts similarity index 97% rename from packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ts rename to packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts index c8c4a80d31..07bb79815b 100644 --- a/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ts +++ b/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ee.ts @@ -1,7 +1,7 @@ import { DataSource, Repository } from '@n8n/typeorm'; import { Service } from 'typedi'; -import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; +import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee'; @Service() export class AnnotationTagMappingRepository extends Repository { diff --git a/packages/cli/src/databases/repositories/annotation-tag.repository.ts b/packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts similarity index 94% rename from packages/cli/src/databases/repositories/annotation-tag.repository.ts rename to packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts index 2f4b847ba6..e3aa993460 100644 --- a/packages/cli/src/databases/repositories/annotation-tag.repository.ts +++ b/packages/cli/src/databases/repositories/annotation-tag.repository.ee.ts @@ -1,7 +1,7 @@ import { DataSource, Repository } from '@n8n/typeorm'; import { Service } from 'typedi'; -import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; +import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; @Service() export class AnnotationTagRepository extends Repository { diff --git a/packages/cli/src/databases/repositories/api-key.repository.ts b/packages/cli/src/databases/repositories/api-key.repository.ts new file mode 100644 index 0000000000..21ad2c3e40 --- /dev/null +++ b/packages/cli/src/databases/repositories/api-key.repository.ts @@ -0,0 +1,11 @@ +import { DataSource, Repository } from '@n8n/typeorm'; +import { Service } from 'typedi'; + +import { ApiKey } from '../entities/api-key'; + +@Service() +export class ApiKeyRepository extends Repository { + constructor(dataSource: DataSource) { + super(ApiKey, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/execution-annotation.repository.ts b/packages/cli/src/databases/repositories/execution-annotation.repository.ts index 81d4917173..97ca972733 100644 --- a/packages/cli/src/databases/repositories/execution-annotation.repository.ts +++ b/packages/cli/src/databases/repositories/execution-annotation.repository.ts @@ -1,7 +1,7 @@ import { DataSource, Repository } from '@n8n/typeorm'; import { Service } from 'typedi'; -import { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; +import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee'; @Service() export class ExecutionAnnotationRepository extends Repository { diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 52c7fd65f3..d76d78c99a 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -36,9 +36,9 @@ import type { import { Service } from 'typedi'; import config from '@/config'; -import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; -import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; -import { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; +import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; +import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee'; +import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee'; import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error'; import type { ExecutionSummaries } from '@/executions/execution.types'; import type { @@ -54,6 +54,8 @@ import { ExecutionDataRepository } from './execution-data.repository'; import type { ExecutionData } from '../entities/execution-data'; import { ExecutionEntity } from '../entities/execution-entity'; import { ExecutionMetadata } from '../entities/execution-metadata'; +import { SharedWorkflow } from '../entities/shared-workflow'; +import { WorkflowEntity } from '../entities/workflow-entity'; export interface IGetExecutionsQueryFilter { id?: FindOperator | string; @@ -874,6 +876,7 @@ export class ExecutionRepository extends Repository { metadata, annotationTags, vote, + projectId, } = query; const fields = Object.keys(this.summaryFields) @@ -945,6 +948,12 @@ export class ExecutionRepository extends Repository { } } + if (projectId) { + qb.innerJoin(WorkflowEntity, 'w', 'w.id = execution.workflowId') + .innerJoin(SharedWorkflow, 'sw', 'sw.workflowId = w.id') + .where('sw.projectId = :projectId', { projectId }); + } + return qb; } diff --git a/packages/cli/src/error-reporting.ts b/packages/cli/src/error-reporting.ts index d1ecd39198..e429bdbd30 100644 --- a/packages/cli/src/error-reporting.ts +++ b/packages/cli/src/error-reporting.ts @@ -1,6 +1,7 @@ import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { QueryFailedError } from '@n8n/typeorm'; +import { AxiosError } from 'axios'; import { createHash } from 'crypto'; import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow'; import Container from 'typedi'; @@ -67,6 +68,8 @@ export const initErrorHandling = async () => { beforeSend(event, { originalException }) { if (!originalException) return null; + if (originalException instanceof AxiosError) return null; + if ( originalException instanceof QueryFailedError && ['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg)) diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index a04c10e45f..53023fce9a 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -21,7 +21,7 @@ import { ActiveExecutions } from '@/active-executions'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import config from '@/config'; import type { User } from '@/databases/entities/user'; -import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository'; +import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository.ee'; import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { IGetExecutionsQueryFilter } from '@/databases/repositories/execution.repository'; diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts index ba574199a0..04d68d8197 100644 --- a/packages/cli/src/executions/execution.types.ts +++ b/packages/cli/src/executions/execution.types.ts @@ -80,6 +80,7 @@ export namespace ExecutionSummaries { startedBefore: string; annotationTags: string[]; // tag IDs vote: AnnotationVote; + projectId: string; }>; type AccessFields = { diff --git a/packages/cli/src/generic-helpers.ts b/packages/cli/src/generic-helpers.ts index 47ef1a796b..e5978bb34a 100644 --- a/packages/cli/src/generic-helpers.ts +++ b/packages/cli/src/generic-helpers.ts @@ -1,6 +1,6 @@ import { validate } from 'class-validator'; -import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { User } from '@/databases/entities/user'; diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index 629925e8dc..4d767862bb 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -26,7 +26,7 @@ import type { import type PCancelable from 'p-cancelable'; import type { ActiveWorkflowManager } from '@/active-workflow-manager'; -import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; import type { AuthProviderType } from '@/databases/entities/auth-identity'; import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { TagEntity } from '@/databases/entities/tag-entity'; diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index e2daaa0e76..871913afba 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -18,6 +18,7 @@ import type { } from 'n8n-workflow'; import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import path from 'path'; +import picocolors from 'picocolors'; import { Container, Service } from 'typedi'; import { @@ -146,6 +147,7 @@ export class LoadNodesAndCredentials { path.join(nodeModulesDir, packagePath), ); } catch (error) { + this.logger.error((error as Error).message); ErrorReporter.error(error); } } @@ -258,6 +260,13 @@ export class LoadNodesAndCredentials { dir: string, ) { const loader = new constructor(dir, this.excludeNodes, this.includeNodes); + if (loader.packageName in this.loaders) { + throw new ApplicationError( + picocolors.red( + `nodes package ${loader.packageName} is already loaded.\n Please delete this second copy at path ${dir}`, + ), + ); + } await loader.loadAll(); this.loaders[loader.packageName] = loader; return loader; diff --git a/packages/cli/src/public-api/index.ts b/packages/cli/src/public-api/index.ts index c240a3efa3..1264f57496 100644 --- a/packages/cli/src/public-api/index.ts +++ b/packages/cli/src/public-api/index.ts @@ -10,10 +10,10 @@ import { Container } from 'typedi'; import validator from 'validator'; import YAML from 'yamljs'; -import { UserRepository } from '@/databases/repositories/user.repository'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; import type { AuthenticatedRequest } from '@/requests'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { UrlService } from '@/services/url.service'; async function createApiRouter( @@ -90,10 +90,9 @@ async function createApiRouter( _scopes: unknown, schema: OpenAPIV3.ApiKeySecurityScheme, ): Promise => { - const apiKey = req.headers[schema.name.toLowerCase()] as string; - const user = await Container.get(UserRepository).findOne({ - where: { apiKey }, - }); + const providedApiKey = req.headers[schema.name.toLowerCase()] as string; + + const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey); if (!user) return false; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index b69acff8d9..e2d22eac2c 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -84,11 +84,7 @@ export declare namespace WorkflowRequest { type Activate = Get; type GetTags = Get; type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>; - type Transfer = AuthenticatedRequest< - { workflowId: string }, - {}, - { destinationProjectId: string } - >; + type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>; } export declare namespace UserRequest { diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index f46b177c61..b0956a15c1 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -73,11 +73,13 @@ export = { transferWorkflow: [ projectScope('workflow:move', 'workflow'), async (req: WorkflowRequest.Transfer, res: express.Response) => { + const { id: workflowId } = req.params; + const body = z.object({ destinationProjectId: z.string() }).parse(req.body); await Container.get(EnterpriseWorkflowService).transferOne( req.user, - req.params.workflowId, + workflowId, body.destinationProjectId, ); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 5afe97f31a..ab4c32ad19 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -186,6 +186,7 @@ export declare namespace CredentialRequest { export declare namespace MeRequest { export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>; + export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>; } export interface UserSetupPayload { diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 27ac3b09a1..0840714b6a 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -35,7 +35,7 @@ import type { FrontendService } from '@/services/frontend.service'; import { OrchestrationService } from '@/services/orchestration.service'; import '@/controllers/active-workflows.controller'; -import '@/controllers/annotation-tags.controller'; +import '@/controllers/annotation-tags.controller.ee'; import '@/controllers/auth.controller'; import '@/controllers/binary-data.controller'; import '@/controllers/curl.controller'; diff --git a/packages/cli/src/services/annotation-tag.service.ts b/packages/cli/src/services/annotation-tag.service.ee.ts similarity index 96% rename from packages/cli/src/services/annotation-tag.service.ts rename to packages/cli/src/services/annotation-tag.service.ee.ts index 27c93041b5..671395168c 100644 --- a/packages/cli/src/services/annotation-tag.service.ts +++ b/packages/cli/src/services/annotation-tag.service.ee.ts @@ -1,7 +1,7 @@ import { Service } from 'typedi'; -import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; -import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository'; +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; +import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee'; import { validateEntity } from '@/generic-helpers'; import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces'; diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts new file mode 100644 index 0000000000..e689f3c019 --- /dev/null +++ b/packages/cli/src/services/public-api-key.service.ts @@ -0,0 +1,80 @@ +import { randomBytes } from 'node:crypto'; +import Container, { Service } from 'typedi'; + +import { ApiKey } from '@/databases/entities/api-key'; +import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; + +export const API_KEY_PREFIX = 'n8n_api_'; + +@Service() +export class PublicApiKeyService { + constructor(private readonly apiKeyRepository: ApiKeyRepository) {} + + /** + * Creates a new public API key for the specified user. + * @param user - The user for whom the API key is being created. + * @returns A promise that resolves to the newly created API key. + */ + async createPublicApiKeyForUser(user: User) { + const apiKey = this.createApiKeyString(); + await this.apiKeyRepository.upsert( + this.apiKeyRepository.create({ + userId: user.id, + apiKey, + label: 'My API Key', + }), + ['apiKey'], + ); + + return await this.apiKeyRepository.findOneByOrFail({ apiKey }); + } + + /** + * Retrieves and redacts API keys for a given user. + * @param user - The user for whom to retrieve and redact API keys. + * @returns A promise that resolves to an array of objects containing redacted API keys. + */ + async getRedactedApiKeysForUser(user: User) { + const apiKeys = await this.apiKeyRepository.findBy({ userId: user.id }); + return apiKeys.map((apiKeyRecord) => ({ + ...apiKeyRecord, + apiKey: this.redactApiKey(apiKeyRecord.apiKey), + })); + } + + async deleteApiKeyForUser(user: User, apiKeyId: string) { + await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); + } + + async getUserForApiKey(apiKey: string) { + return await Container.get(UserRepository) + .createQueryBuilder('user') + .innerJoin(ApiKey, 'apiKey', 'apiKey.userId = user.id') + .where('apiKey.apiKey = :apiKey', { apiKey }) + .select('user') + .getOne(); + } + + /** + * Redacts an API key by keeping the first few characters and replacing the rest with asterisks. + * @param apiKey - The API key to be redacted. If null, the function returns undefined. + * @returns The redacted API key with a fixed prefix and asterisks replacing the rest of the characters. + * @example + * ```typescript + * const redactedKey = PublicApiKeyService.redactApiKey('12345-abcdef-67890'); + * console.log(redactedKey); // Output: '12345-*****' + * ``` + */ + redactApiKey(apiKey: string) { + const keepLength = 5; + return ( + API_KEY_PREFIX + + apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) + + '*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength) + ); + } + + createApiKeyString = () => `${API_KEY_PREFIX}${randomBytes(40).toString('hex')}`; +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 259a30666c..3d8fde6c00 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -58,7 +58,7 @@ export class UserService { withScopes?: boolean; }, ) { - const { password, updatedAt, apiKey, authIdentities, ...rest } = user; + const { password, updatedAt, authIdentities, ...rest } = user; const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); diff --git a/packages/cli/test/integration/controllers/invitation/assertions.ts b/packages/cli/test/integration/controllers/invitation/assertions.ts index 3e24a53222..daa40586f2 100644 --- a/packages/cli/test/integration/controllers/invitation/assertions.ts +++ b/packages/cli/test/integration/controllers/invitation/assertions.ts @@ -10,7 +10,6 @@ export function assertReturnedUserProps(user: User) { expect(user.personalizationAnswers).toBeNull(); expect(user.password).toBeUndefined(); expect(user.isPending).toBe(false); - expect(user.apiKey).not.toBeDefined(); expect(user.globalScopes).toBeDefined(); expect(user.globalScopes).not.toHaveLength(0); } diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 05061e536e..15d97f69ab 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -6,6 +6,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { ExecutionService } from '@/executions/execution.service'; import type { ExecutionSummaries } from '@/executions/execution.types'; +import { createTeamProject } from '@test-integration/db/projects'; import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions'; import { createWorkflow } from './shared/db/workflows'; @@ -294,6 +295,37 @@ describe('ExecutionService', () => { }); }); + test('should filter executions by `projectId`', async () => { + const firstProject = await createTeamProject(); + const secondProject = await createTeamProject(); + + const firstWorkflow = await createWorkflow(undefined, firstProject); + const secondWorkflow = await createWorkflow(undefined, secondProject); + + await createExecution({ status: 'success' }, firstWorkflow); + await createExecution({ status: 'success' }, firstWorkflow); + await createExecution({ status: 'success' }, secondWorkflow); // to filter out + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [firstWorkflow.id], + projectId: firstProject.id, + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output).toEqual({ + count: 2, + estimated: false, + results: expect.arrayContaining([ + expect.objectContaining({ workflowId: firstWorkflow.id }), + expect.objectContaining({ workflowId: firstWorkflow.id }), + // execution for workflow in second project was filtered out + ]), + }); + }); + test('should exclude executions by inaccessible `workflowId`', async () => { const accessibleWorkflow = await createWorkflow(); const inaccessibleWorkflow = await createWorkflow(); diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index df9b7c48b6..2fc9b07870 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -1,22 +1,29 @@ import { GlobalConfig } from '@n8n/config'; -import { IsNull } from '@n8n/typeorm'; import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow'; import { Container } from 'typedi'; import validator from 'validator'; +import type { ApiKey } from '@/databases/entities/api-key'; import type { User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; +import { PublicApiKeyService } from '@/services/public-api-key.service'; import { mockInstance } from '@test/mocking'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; -import { addApiKey, createOwner, createUser, createUserShell } from './shared/db/users'; -import { randomApiKey, randomEmail, randomName, randomValidPassword } from './shared/random'; +import { createOwnerWithApiKey, createUser, createUserShell } from './shared/db/users'; +import { randomEmail, randomName, randomValidPassword } from './shared/random'; import * as testDb from './shared/test-db'; import type { SuperAgentTest } from './shared/types'; import * as utils from './shared/utils/'; const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); +let publicApiKeyService: PublicApiKeyService; + +beforeAll(() => { + publicApiKeyService = Container.get(PublicApiKeyService); +}); beforeEach(async () => { await testDb.truncate(['User']); @@ -28,22 +35,22 @@ describe('When public API is disabled', () => { let authAgent: SuperAgentTest; beforeEach(async () => { - owner = await createOwner(); - await addApiKey(owner); + owner = await createOwnerWithApiKey(); + authAgent = testServer.authAgentFor(owner); mockInstance(GlobalConfig, { publicApi: { disabled: true } }); }); - test('POST /me/api-key should 404', async () => { - await authAgent.post('/me/api-key').expect(404); + test('POST /me/api-keys should 404', async () => { + await authAgent.post('/me/api-keys').expect(404); }); - test('GET /me/api-key should 404', async () => { - await authAgent.get('/me/api-key').expect(404); + test('GET /me/api-keys should 404', async () => { + await authAgent.get('/me/api-keys').expect(404); }); - test('DELETE /me/api-key should 404', async () => { - await authAgent.delete('/me/api-key').expect(404); + test('DELETE /me/api-key/:id should 404', async () => { + await authAgent.delete(`/me/api-keys/${1}`).expect(404); }); }); @@ -53,7 +60,6 @@ describe('Owner shell', () => { beforeEach(async () => { ownerShell = await createUserShell('global:owner'); - await addApiKey(ownerShell); authOwnerShellAgent = testServer.authAgentFor(ownerShell); }); @@ -63,17 +69,8 @@ describe('Owner shell', () => { expect(response.statusCode).toBe(200); - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - role, - password, - isPending, - apiKey, - } = response.body.data; + const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } = + response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(validPayload.email.toLowerCase()); @@ -83,7 +80,6 @@ describe('Owner shell', () => { expect(password).toBeUndefined(); expect(isPending).toBe(false); expect(role).toBe('global:owner'); - expect(apiKey).toBeUndefined(); const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id }); @@ -161,37 +157,56 @@ describe('Owner shell', () => { } }); - test('POST /me/api-key should create an api key', async () => { - const response = await authOwnerShellAgent.post('/me/api-key'); + test('POST /me/api-keys should create an api key', async () => { + const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys'); - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).toBeDefined(); - expect(response.body.data.apiKey).not.toBeNull(); + const newApiKey = newApiKeyResponse.body.data as ApiKey; - const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ - where: { email: IsNull() }, + expect(newApiKeyResponse.statusCode).toBe(200); + expect(newApiKey).toBeDefined(); + + const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ + userId: ownerShell.id, }); - expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey); - }); - - test('GET /me/api-key should fetch the api key redacted', async () => { - const response = await authOwnerShellAgent.get('/me/api-key'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).not.toEqual(ownerShell.apiKey); - }); - - test('DELETE /me/api-key should delete the api key', async () => { - const response = await authOwnerShellAgent.delete('/me/api-key'); - - expect(response.statusCode).toBe(200); - - const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ - where: { email: IsNull() }, + expect(newStoredApiKey).toEqual({ + id: expect.any(String), + label: 'My API Key', + userId: ownerShell.id, + apiKey: newApiKey.apiKey, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), }); + }); - expect(storedShellOwner.apiKey).toBeNull(); + test('GET /me/api-keys should fetch the api key redacted', async () => { + const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys'); + + const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys'); + + expect(retrieveAllApiKeysResponse.statusCode).toBe(200); + + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + id: newApiKeyResponse.body.data.id, + label: 'My API Key', + userId: ownerShell.id, + apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + test('DELETE /me/api-keys/:id should delete the api key', async () => { + const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys'); + + const deleteApiKeyResponse = await authOwnerShellAgent.delete( + `/me/api-keys/${newApiKeyResponse.body.data.id}`, + ); + + const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys'); + + expect(deleteApiKeyResponse.body.data.success).toBe(true); + expect(retrieveAllApiKeysResponse.body.data.length).toBe(0); }); }); @@ -204,10 +219,8 @@ describe('Member', () => { member = await createUser({ password: memberPassword, role: 'global:member', - apiKey: randomApiKey(), }); authMemberAgent = testServer.authAgentFor(member); - await utils.setInstanceOwnerSetUp(true); }); @@ -215,17 +228,8 @@ describe('Member', () => { for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - role, - password, - isPending, - apiKey, - } = response.body.data; + const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } = + response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(validPayload.email.toLowerCase()); @@ -235,7 +239,6 @@ describe('Member', () => { expect(password).toBeUndefined(); expect(isPending).toBe(false); expect(role).toBe('global:member'); - expect(apiKey).toBeUndefined(); const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); @@ -275,6 +278,7 @@ describe('Member', () => { }; const response = await authMemberAgent.patch('/me/password').send(validPayload); + expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); @@ -315,33 +319,59 @@ describe('Member', () => { } }); - test('POST /me/api-key should create an api key', async () => { - const response = await testServer.authAgentFor(member).post('/me/api-key'); + test('POST /me/api-keys should create an api key', async () => { + const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys'); - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).toBeDefined(); - expect(response.body.data.apiKey).not.toBeNull(); + expect(newApiKeyResponse.statusCode).toBe(200); + expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); + expect(newApiKeyResponse.body.data.apiKey).not.toBeNull(); - const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id }); + const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ + userId: member.id, + }); - expect(storedMember.apiKey).toEqual(response.body.data.apiKey); + expect(newStoredApiKey).toEqual({ + id: expect.any(String), + label: 'My API Key', + userId: member.id, + apiKey: newApiKeyResponse.body.data.apiKey, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); }); - test('GET /me/api-key should fetch the api key redacted', async () => { - const response = await testServer.authAgentFor(member).get('/me/api-key'); + test('GET /me/api-keys should fetch the api key redacted', async () => { + const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys'); - expect(response.statusCode).toBe(200); - expect(response.body.data.apiKey).not.toEqual(member.apiKey); + const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys'); + + expect(retrieveAllApiKeysResponse.statusCode).toBe(200); + + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + id: newApiKeyResponse.body.data.id, + label: 'My API Key', + userId: member.id, + apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + + expect(newApiKeyResponse.body.data.apiKey).not.toEqual( + retrieveAllApiKeysResponse.body.data[0].apiKey, + ); }); - test('DELETE /me/api-key should delete the api key', async () => { - const response = await testServer.authAgentFor(member).delete('/me/api-key'); + test('DELETE /me/api-keys/:id should delete the api key', async () => { + const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys'); - expect(response.statusCode).toBe(200); + const deleteApiKeyResponse = await testServer + .authAgentFor(member) + .delete(`/me/api-keys/${newApiKeyResponse.body.data.id}`); - const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id }); + const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys'); - expect(storedMember.apiKey).toBeNull(); + expect(deleteApiKeyResponse.body.data.success).toBe(true); + expect(retrieveAllApiKeysResponse.body.data.length).toBe(0); }); }); diff --git a/packages/cli/test/integration/public-api/credentials.test.ts b/packages/cli/test/integration/public-api/credentials.test.ts index 7323de391e..5574d4f3bf 100644 --- a/packages/cli/test/integration/public-api/credentials.test.ts +++ b/packages/cli/test/integration/public-api/credentials.test.ts @@ -7,8 +7,8 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre import { createTeamProject } from '@test-integration/db/projects'; import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; -import { addApiKey, createUser, createUserShell } from '../shared/db/users'; -import { randomApiKey, randomName } from '../shared/random'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; +import { randomName } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import type { SuperAgentTest } from '../shared/types'; @@ -24,8 +24,8 @@ let saveCredential: SaveCredentialFunction; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - owner = await addApiKey(await createUserShell('global:owner')); - member = await createUser({ role: 'global:member', apiKey: randomApiKey() }); + owner = await createOwnerWithApiKey(); + member = await createMemberWithApiKey(); authOwnerAgent = testServer.publicApiAgentFor(owner); authMemberAgent = testServer.publicApiAgentFor(member); @@ -156,10 +156,7 @@ describe('DELETE /credentials/:id', () => { }); test('should delete owned cred for member but leave others untouched', async () => { - const anotherMember = await createUser({ - role: 'global:member', - apiKey: randomApiKey(), - }); + const anotherMember = await createMemberWithApiKey(); const savedCredential = await saveCredential(dbCredential(), { user: member }); const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member }); diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index 019f69adc5..13324ed55c 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -12,13 +12,12 @@ import { createSuccessfulExecution, createWaitingExecution, } from '../shared/db/executions'; -import { createUser } from '../shared/db/users'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; import { createManyWorkflows, createWorkflow, shareWorkflowWithUsers, } from '../shared/db/workflows'; -import { randomApiKey } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -36,9 +35,9 @@ mockInstance(Telemetry); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - owner = await createUser({ role: 'global:owner', apiKey: randomApiKey() }); - user1 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); - user2 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); + owner = await createOwnerWithApiKey(); + user1 = await createMemberWithApiKey(); + user2 = await createMemberWithApiKey(); // TODO: mock BinaryDataService instead await utils.initBinaryDataService(); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 2bc8e9346b..f815d9d07b 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -2,7 +2,7 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; -import { createMember, createOwner } from '@test-integration/db/users'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import * as testDb from '../shared/test-db'; @@ -26,7 +26,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const projects = await Promise.all([ createTeamProject(), createTeamProject(), @@ -53,15 +53,10 @@ describe('Projects in Public API', () => { }); it('if not authenticated, should reject', async () => { - /** - * Arrange - */ - const owner = await createOwner({ withApiKey: false }); - /** * Act */ - const response = await testServer.publicApiAgentFor(owner).get('/projects'); + const response = await testServer.publicApiAgentWithoutApiKey().get('/projects'); /** * Assert @@ -74,7 +69,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); /** * Act @@ -97,12 +92,12 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).get('/projects'); + const response = await testServer.publicApiAgentFor(member).get('/projects'); /** * Assert @@ -119,7 +114,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const projectPayload = { name: 'some-project' }; /** @@ -150,14 +145,13 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const projectPayload = { name: 'some-project' }; /** * Act */ const response = await testServer - .publicApiAgentFor(owner) + .publicApiAgentWithoutApiKey() .post('/projects') .send(projectPayload); @@ -172,7 +166,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const projectPayload = { name: 'some-project' }; /** @@ -199,7 +193,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const member = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const projectPayload = { name: 'some-project' }; /** @@ -225,7 +219,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject(); /** @@ -244,13 +238,14 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const project = await createTeamProject(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + const response = await testServer + .publicApiAgentWithoutApiKey() + .delete(`/projects/${project.id}`); /** * Assert @@ -263,7 +258,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject(); /** @@ -287,13 +282,13 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const member = await createMember({ withApiKey: true }); + const owner = await createMemberWithApiKey(); const project = await createTeamProject(); /** * Act */ - const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); /** * Assert @@ -310,7 +305,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject('old-name'); /** @@ -332,14 +327,13 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const project = await createTeamProject(); /** * Act */ const response = await testServer - .publicApiAgentFor(owner) + .publicApiAgentWithoutApiKey() .put(`/projects/${project.id}`) .send({ name: 'new-name' }); @@ -354,7 +348,7 @@ describe('Projects in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const project = await createTeamProject(); /** @@ -381,7 +375,7 @@ describe('Projects in Public API', () => { */ testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.enable('feat:projectRole:admin'); - const member = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const project = await createTeamProject(); /** diff --git a/packages/cli/test/integration/public-api/tags.test.ts b/packages/cli/test/integration/public-api/tags.test.ts index 776d79d368..c2e25cc3f6 100644 --- a/packages/cli/test/integration/public-api/tags.test.ts +++ b/packages/cli/test/integration/public-api/tags.test.ts @@ -4,8 +4,7 @@ import type { User } from '@/databases/entities/user'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { createTag } from '../shared/db/tags'; -import { createUser } from '../shared/db/users'; -import { randomApiKey } from '../shared/random'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -18,15 +17,8 @@ let authMemberAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - - member = await createUser({ - role: 'global:member', - apiKey: randomApiKey(), - }); + owner = await createOwnerWithApiKey(); + member = await createMemberWithApiKey(); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/public-api/users.ee.test.ts b/packages/cli/test/integration/public-api/users.ee.test.ts index 04649403d9..08161a41d8 100644 --- a/packages/cli/test/integration/public-api/users.ee.test.ts +++ b/packages/cli/test/integration/public-api/users.ee.test.ts @@ -6,8 +6,13 @@ import { License } from '@/license'; import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; import { mockInstance } from '../../shared/mocking'; -import { createOwner, createUser, createUserShell } from '../shared/db/users'; -import { randomApiKey } from '../shared/random'; +import { + createMember, + createMemberWithApiKey, + createOwnerWithApiKey, + createUser, + createUserShell, +} from '../shared/db/users'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -25,32 +30,23 @@ beforeEach(async () => { describe('With license unlimited quota:users', () => { describe('GET /users', () => { test('should fail due to missing API Key', async () => { - const owner = await createUser({ role: 'global:owner' }); - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const authOwnerAgent = testServer.publicApiAgentWithoutApiKey(); await authOwnerAgent.get('/users').expect(401); }); test('should fail due to invalid API Key', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - owner.apiKey = 'invalid-key'; - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key'); await authOwnerAgent.get('/users').expect(401); }); test('should fail due to member trying to access owner only endpoint', async () => { - const member = await createUser({ apiKey: randomApiKey() }); + const member = await createMemberWithApiKey(); const authMemberAgent = testServer.publicApiAgentFor(member); await authMemberAgent.get('/users').expect(403); }); test('should return all users', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); @@ -92,10 +88,10 @@ describe('With license unlimited quota:users', () => { * Arrange */ const [owner, firstMember, secondMember, thirdMember] = await Promise.all([ - createOwner({ withApiKey: true }), - createUser({ role: 'global:member' }), - createUser({ role: 'global:member' }), - createUser({ role: 'global:member' }), + createOwnerWithApiKey(), + createMember(), + createMember(), + createMember(), ]); const [firstProject, secondProject] = await Promise.all([ @@ -130,40 +126,30 @@ describe('With license unlimited quota:users', () => { describe('GET /users/:id', () => { test('should fail due to missing API Key', async () => { - const owner = await createUser({ role: 'global:owner' }); - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const owner = await createOwnerWithApiKey(); + const authOwnerAgent = testServer.publicApiAgentWithoutApiKey(); await authOwnerAgent.get(`/users/${owner.id}`).expect(401); }); test('should fail due to invalid API Key', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - owner.apiKey = 'invalid-key'; - const authOwnerAgent = testServer.publicApiAgentFor(owner); + const owner = await createOwnerWithApiKey(); + const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key'); await authOwnerAgent.get(`/users/${owner.id}`).expect(401); }); test('should fail due to member trying to access owner only endpoint', async () => { - const member = await createUser({ apiKey: randomApiKey() }); + const member = await createMemberWithApiKey(); const authMemberAgent = testServer.publicApiAgentFor(member); await authMemberAgent.get(`/users/${member.id}`).expect(403); }); test('should return 404 for non-existing id ', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); await authOwnerAgent.get(`/users/${uuid()}`).expect(404); }); test('should return a pending user', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const { id: memberId } = await createUserShell('global:member'); @@ -199,20 +185,13 @@ describe('With license unlimited quota:users', () => { describe('GET /users/:email', () => { test('with non-existing email should return 404', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404); }); test('should return a user', async () => { - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); - + const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200); @@ -249,10 +228,7 @@ describe('With license without quota:users', () => { beforeEach(async () => { mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) }); - const owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + const owner = await createOwnerWithApiKey(); authOwnerAgent = testServer.publicApiAgentFor(owner); }); diff --git a/packages/cli/test/integration/public-api/users.test.ts b/packages/cli/test/integration/public-api/users.test.ts index 48003d838c..0abfee9b1f 100644 --- a/packages/cli/test/integration/public-api/users.test.ts +++ b/packages/cli/test/integration/public-api/users.test.ts @@ -1,7 +1,12 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; -import { createMember, createOwner, getUserById } from '@test-integration/db/users'; +import { + createMember, + createMemberWithApiKey, + createOwnerWithApiKey, + getUserById, +} from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import * as testDb from '../shared/test-db'; @@ -23,13 +28,12 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const payload = { email: 'test@test.com', role: 'global:admin' }; /** * Act */ - const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload); /** * Assert @@ -42,7 +46,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const member = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; /** @@ -62,7 +66,8 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); + await createOwnerWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; /** @@ -99,13 +104,12 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const member = await createMember(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`); /** * Assert @@ -118,14 +122,14 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const firstMember = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const secondMember = await createMember(); /** * Act */ const response = await testServer - .publicApiAgentFor(firstMember) + .publicApiAgentFor(member) .delete(`/users/${secondMember.id}`); /** @@ -140,7 +144,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); /** @@ -161,13 +165,14 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: false }); const member = await createMember(); /** * Act */ - const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); + const response = await testServer + .publicApiAgentWithApiKey('') + .patch(`/users/${member.id}/role`); /** * Assert @@ -179,7 +184,7 @@ describe('Users in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; @@ -206,7 +211,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const firstMember = await createMember({ withApiKey: true }); + const member = await createMemberWithApiKey(); const secondMember = await createMember(); const payload = { newRoleName: 'global:admin' }; @@ -214,7 +219,7 @@ describe('Users in Public API', () => { * Act */ const response = await testServer - .publicApiAgentFor(firstMember) + .publicApiAgentFor(member) .patch(`/users/${secondMember.id}/role`) .send(payload); @@ -230,7 +235,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'invalid' }; @@ -253,7 +258,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; diff --git a/packages/cli/test/integration/public-api/variables.test.ts b/packages/cli/test/integration/public-api/variables.test.ts index c7f6ba341c..61f75d4641 100644 --- a/packages/cli/test/integration/public-api/variables.test.ts +++ b/packages/cli/test/integration/public-api/variables.test.ts @@ -1,5 +1,5 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; -import { createOwner } from '@test-integration/db/users'; +import { createOwnerWithApiKey } from '@test-integration/db/users'; import { createVariable, getVariableOrFail } from '@test-integration/db/variables'; import { setupTestServer } from '@test-integration/utils'; @@ -22,7 +22,7 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variables = await Promise.all([createVariable(), createVariable(), createVariable()]); /** @@ -48,7 +48,8 @@ describe('Variables in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + + const owner = await createOwnerWithApiKey(); /** * Act @@ -72,7 +73,7 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variablePayload = { key: 'key', value: 'value' }; /** @@ -96,7 +97,7 @@ describe('Variables in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variablePayload = { key: 'key', value: 'value' }; /** @@ -124,7 +125,7 @@ describe('Variables in Public API', () => { * Arrange */ testServer.license.enable('feat:variables'); - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variable = await createVariable(); /** @@ -145,7 +146,7 @@ describe('Variables in Public API', () => { /** * Arrange */ - const owner = await createOwner({ withApiKey: true }); + const owner = await createOwnerWithApiKey(); const variable = await createVariable(); /** diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 5bb661eaa3..687b29da6a 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -17,9 +17,8 @@ import { createTeamProject } from '@test-integration/db/projects'; import { mockInstance } from '../../shared/mocking'; import { createTag } from '../shared/db/tags'; -import { createUser } from '../shared/db/users'; +import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users'; import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows'; -import { randomApiKey } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -40,18 +39,13 @@ const license = testServer.license; mockInstance(ExecutionService); beforeAll(async () => { - owner = await createUser({ - role: 'global:owner', - apiKey: randomApiKey(), - }); + owner = await createOwnerWithApiKey(); ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( owner.id, ); - member = await createUser({ - role: 'global:member', - apiKey: randomApiKey(), - }); + member = await createMemberWithApiKey(); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( member.id, ); @@ -1518,6 +1512,10 @@ describe('PUT /workflows/:id/transfer', () => { const secondProject = await createTeamProject('second-project', member); const workflow = await createWorkflow({}, firstProject); + // Make data more similar to real world scenario by injecting additional records into the database + await createTeamProject('third-project', member); + await createWorkflow({}, firstProject); + /** * Act */ @@ -1529,6 +1527,13 @@ describe('PUT /workflows/:id/transfer', () => { * Assert */ expect(response.statusCode).toBe(204); + + const workflowsInProjectResponse = await authMemberAgent + .get(`/workflows?projectId=${secondProject.id}`) + .send(); + + expect(workflowsInProjectResponse.statusCode).toBe(200); + expect(workflowsInProjectResponse.body.data[0].id).toBe(workflow.id); }); test('if no destination project, should reject', async () => { diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index dac3124681..e09ca44dfb 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -4,7 +4,7 @@ import Container from 'typedi'; import type { ExecutionData } from '@/databases/entities/execution-data'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; -import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository'; +import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee'; import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 83192822f9..62f9f39a05 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,8 +1,10 @@ import { hash } from 'bcryptjs'; +import { randomString } from 'n8n-workflow'; import Container from 'typedi'; import { AuthIdentity } from '@/databases/entities/auth-identity'; import { type GlobalRole, type User } from '@/databases/entities/user'; +import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -79,19 +81,38 @@ export async function createUserWithMfaEnabled( }; } -export async function createOwner({ withApiKey } = { withApiKey: false }) { - if (withApiKey) { - return await addApiKey(await createUser({ role: 'global:owner' })); - } +const createApiKeyEntity = (user: User) => { + const apiKey = randomApiKey(); + return Container.get(ApiKeyRepository).create({ + userId: user.id, + label: randomString(10), + apiKey, + }); +}; +export const addApiKey = async (user: User) => { + return await Container.get(ApiKeyRepository).save(createApiKeyEntity(user)); +}; + +export async function createOwnerWithApiKey() { + const owner = await createOwner(); + const apiKey = await addApiKey(owner); + owner.apiKeys = [apiKey]; + return owner; +} + +export async function createMemberWithApiKey() { + const member = await createMember(); + const apiKey = await addApiKey(member); + member.apiKeys = [apiKey]; + return member; +} + +export async function createOwner() { return await createUser({ role: 'global:owner' }); } -export async function createMember({ withApiKey } = { withApiKey: false }) { - if (withApiKey) { - return await addApiKey(await createUser({ role: 'global:member' })); - } - +export async function createMember() { return await createUser({ role: 'global:member' }); } @@ -128,11 +149,6 @@ export async function createManyUsers( return result.map((result) => result.user); } -export async function addApiKey(user: User): Promise { - user.apiKey = randomApiKey(); - return await Container.get(UserRepository).save(user); -} - export const getAllUsers = async () => await Container.get(UserRepository).find({ relations: ['authIdentities'], diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 365bc81fa8..0d9b1672e1 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -80,6 +80,7 @@ const repositories = [ 'WorkflowHistory', 'WorkflowStatistics', 'WorkflowTagMapping', + 'ApiKey', ] as const; /** @@ -87,9 +88,18 @@ const repositories = [ */ export async function truncate(names: Array<(typeof repositories)[number]>) { for (const name of names) { - const RepositoryClass: Class> = - // eslint-disable-next-line n8n-local-rules/no-dynamic-import-template - (await import(`@/databases/repositories/${kebabCase(name)}.repository`))[`${name}Repository`]; + let RepositoryClass: Class>; + + try { + RepositoryClass = (await import(`@/databases/repositories/${kebabCase(name)}.repository`))[ + `${name}Repository` + ]; + } catch (e) { + RepositoryClass = (await import(`@/databases/repositories/${kebabCase(name)}.repository.ee`))[ + `${name}Repository` + ]; + } + await Container.get(RepositoryClass).delete({}); } } diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 66ca2d016f..87f349fb79 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -55,6 +55,8 @@ export interface TestServer { httpServer: Server; authAgentFor: (user: User) => TestAgent; publicApiAgentFor: (user: User) => TestAgent; + publicApiAgentWithApiKey: (apiKey: string) => TestAgent; + publicApiAgentWithoutApiKey: () => TestAgent; authlessAgent: TestAgent; restlessAgent: TestAgent; license: LicenseMocker; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index f16bbf9833..cb66b7868d 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -62,17 +62,30 @@ function createAgent( return agent; } -function publicApiAgent( +const userDoesNotHaveApiKey = (user: User) => { + return !user.apiKeys || !Array.from(user.apiKeys) || user.apiKeys.length === 0; +}; + +const publicApiAgent = ( app: express.Application, - { user, version = 1 }: { user: User; version?: number }, -) { + { user, apiKey, version = 1 }: { user?: User; apiKey?: string; version?: number }, +) => { + if (user && apiKey) { + throw new Error('Cannot provide both user and API key'); + } + + if (user && userDoesNotHaveApiKey(user)) { + throw new Error('User does not have an API key'); + } + + const agentApiKey = apiKey ?? user?.apiKeys[0].apiKey; + const agent = request.agent(app); void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`)); - if (user.apiKey) { - void agent.set({ 'X-N8N-API-KEY': user.apiKey }); - } + if (!user && !apiKey) return agent; + void agent.set({ 'X-N8N-API-KEY': agentApiKey }); return agent; -} +}; export const setupTestServer = ({ endpointGroups, @@ -100,6 +113,8 @@ export const setupTestServer = ({ authlessAgent: createAgent(app), restlessAgent: createAgent(app, { auth: false, noRest: true }), publicApiAgentFor: (user) => publicApiAgent(app, { user }), + publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }), + publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}), license: new LicenseMocker(), }; @@ -140,7 +155,7 @@ export const setupTestServer = ({ for (const group of endpointGroups) { switch (group) { case 'annotationTags': - await import('@/controllers/annotation-tags.controller'); + await import('@/controllers/annotation-tags.controller.ee'); break; case 'credentials': diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index 8cecb6b054..f73ca87a15 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -30,18 +30,6 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { const loader = new PackageDirectoryLoader(packageDir); await loader.loadAll(); - const knownCredentials = loader.known.credentials; - const credentialTypes = Object.values(loader.credentialTypes).map((data) => { - const credentialType = data.type; - if ( - knownCredentials[credentialType.name].supportedNodes?.length > 0 && - credentialType.httpRequestNode - ) { - credentialType.httpRequestNode.hidden = true; - } - return credentialType; - }); - const loaderNodeTypes = Object.values(loader.nodeTypes); const definedMethods = loaderNodeTypes.reduce((acc, cur) => { @@ -76,6 +64,36 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { }), ); + const knownCredentials = loader.known.credentials; + const credentialTypes = Object.values(loader.credentialTypes).map((data) => { + const credentialType = data.type; + const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? []; + if (supportedNodes.length > 0 && credentialType.httpRequestNode) { + credentialType.httpRequestNode.hidden = true; + } + + credentialType.supportedNodes = supportedNodes; + + if (!credentialType.iconUrl && !credentialType.icon) { + for (const supportedNode of supportedNodes) { + const nodeType = loader.nodeTypes[supportedNode]?.type.description; + + if (!nodeType) continue; + if (nodeType.icon) { + credentialType.icon = nodeType.icon; + credentialType.iconColor = nodeType.iconColor; + break; + } + if (nodeType.iconUrl) { + credentialType.iconUrl = nodeType.iconUrl; + break; + } + } + } + + return credentialType; + }); + const referencedMethods = findReferencedMethods(nodeTypes); await Promise.all([ diff --git a/packages/core/package.json b/packages/core/package.json index 0656929291..74e3483943 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.60.0", + "version": "1.61.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index 717edd5359..cef3db4068 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -1,5 +1,6 @@ import glob from 'fast-glob'; -import { readFile } from 'fs/promises'; +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import type { CodexData, DocumentationLink, @@ -350,18 +351,11 @@ export class CustomDirectoryLoader extends DirectoryLoader { * e.g. /nodes-base or community packages. */ export class PackageDirectoryLoader extends DirectoryLoader { - packageName = ''; + packageJson: n8n.PackageJson = this.readJSONSync('package.json'); - packageJson!: n8n.PackageJson; - - async readPackageJson() { - this.packageJson = await this.readJSON('package.json'); - this.packageName = this.packageJson.name; - } + packageName = this.packageJson.name; override async loadAll() { - await this.readPackageJson(); - const { n8n } = this.packageJson; if (!n8n) return; @@ -391,6 +385,17 @@ export class PackageDirectoryLoader extends DirectoryLoader { }); } + protected readJSONSync(file: string): T { + const filePath = this.resolvePath(file); + const fileString = readFileSync(filePath, 'utf8'); + + try { + return jsonParse(fileString); + } catch (error) { + throw new ApplicationError('Failed to parse JSON', { extra: { filePath } }); + } + } + protected async readJSON(file: string): Promise { const filePath = this.resolvePath(file); const fileString = await readFile(filePath, 'utf8'); @@ -408,8 +413,6 @@ export class PackageDirectoryLoader extends DirectoryLoader { */ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { override async loadAll() { - await this.readPackageJson(); - try { const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json'); for (const nodeName in knownNodes) { diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 18493521f2..526a9e43c4 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.50.0", + "version": "1.51.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { diff --git a/packages/design-system/src/components/N8nMarkdown/Markdown.vue b/packages/design-system/src/components/N8nMarkdown/Markdown.vue index 7f55c0c6f1..1074d584bf 100644 --- a/packages/design-system/src/components/N8nMarkdown/Markdown.vue +++ b/packages/design-system/src/components/N8nMarkdown/Markdown.vue @@ -136,7 +136,7 @@ const htmlContent = computed(() => { }); const emit = defineEmits<{ - 'markdown-click': [link: string, e: MouseEvent]; + 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]; 'update-content': [content: string]; }>(); @@ -154,7 +154,7 @@ const onClick = (event: MouseEvent) => { } } if (clickedLink) { - emit('markdown-click', clickedLink?.href, event); + emit('markdown-click', clickedLink, event); } }; diff --git a/packages/design-system/src/components/N8nResizeableSticky/ResizeableSticky.vue b/packages/design-system/src/components/N8nResizeableSticky/ResizeableSticky.vue index ea72c506b7..5219efe2f4 100644 --- a/packages/design-system/src/components/N8nResizeableSticky/ResizeableSticky.vue +++ b/packages/design-system/src/components/N8nResizeableSticky/ResizeableSticky.vue @@ -21,6 +21,7 @@ const emit = defineEmits<{ resize: [values: ResizeData]; resizestart: []; resizeend: []; + 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]; }>(); const attrs = useAttrs(); @@ -42,6 +43,10 @@ const onResizeEnd = () => { isResizing.value = false; emit('resizeend'); }; + +const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => { + emit('markdown-click', link, event); +}; diff --git a/packages/design-system/src/components/N8nSticky/Sticky.vue b/packages/design-system/src/components/N8nSticky/Sticky.vue index 73aa4933a3..569cd15974 100644 --- a/packages/design-system/src/components/N8nSticky/Sticky.vue +++ b/packages/design-system/src/components/N8nSticky/Sticky.vue @@ -13,7 +13,7 @@ const props = withDefaults(defineProps(), defaultStickyProps); const emit = defineEmits<{ edit: [editing: boolean]; 'update:modelValue': [value: string]; - 'markdown-click': [link: string, e: Event]; + 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]; }>(); const { t } = useI18n(); @@ -63,7 +63,7 @@ const onUpdateModelValue = (value: string) => { emit('update:modelValue', value); }; -const onMarkdownClick = (link: string, event: Event) => { +const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => { emit('markdown-click', link, event); }; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 500b96affd..2bd69ffb30 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.60.0", + "version": "1.61.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index f6587e8e3b..972593bceb 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit { - return await makeRestApiRequest(context, 'GET', '/me/api-key'); +export async function getApiKeys(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/me/api-keys'); } -export async function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { - return await makeRestApiRequest(context, 'POST', '/me/api-key'); +export async function createApiKey(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'POST', '/me/api-keys'); } -export async function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> { - return await makeRestApiRequest(context, 'DELETE', '/me/api-key'); +export async function deleteApiKey( + context: IRestApiContext, + id: string, +): Promise<{ success: boolean }> { + return await makeRestApiRequest(context, 'DELETE', `/me/api-keys/${id}`); } diff --git a/packages/editor-ui/src/components/AnnotationTagsContainer.vue b/packages/editor-ui/src/components/AnnotationTagsContainer.ee.vue similarity index 100% rename from packages/editor-ui/src/components/AnnotationTagsContainer.vue rename to packages/editor-ui/src/components/AnnotationTagsContainer.ee.vue diff --git a/packages/editor-ui/src/components/AnnotationTagsDropdown.vue b/packages/editor-ui/src/components/AnnotationTagsDropdown.ee.vue similarity index 100% rename from packages/editor-ui/src/components/AnnotationTagsDropdown.vue rename to packages/editor-ui/src/components/AnnotationTagsDropdown.ee.vue diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 381380cdfc..3ef1782b05 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -10,7 +10,7 @@ import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow'; import { format } from 'prettier'; import jsParser from 'prettier/plugins/babel'; import * as estree from 'prettier/plugins/estree'; -import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'; import { CODE_NODE_TYPE } from '@/constants'; import { codeNodeEditorEventBus } from '@/event-bus'; @@ -26,6 +26,7 @@ import { useLinter } from './linter'; import { codeNodeEditorTheme } from './theme'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; +import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; type Props = { mode: CodeExecutionMode; @@ -51,6 +52,7 @@ const emit = defineEmits<{ const message = useMessage(); const editor = ref(null) as Ref; const languageCompartment = ref(new Compartment()); +const dragAndDropCompartment = ref(new Compartment()); const linterCompartment = ref(new Compartment()); const isEditorHovered = ref(false); const isEditorFocused = ref(false); @@ -95,6 +97,7 @@ onMounted(() => { extensions.push( ...writableEditorExtensions, + dragAndDropCompartment.value.of(dragAndDropExtension.value), EditorView.domEventHandlers({ focus: () => { isEditorFocused.value = true; @@ -151,6 +154,12 @@ const placeholder = computed(() => { return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? ''; }); +const dragAndDropEnabled = computed(() => { + return !props.isReadOnly && props.mode === 'runOnceForEachItem'; +}); + +const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : [])); + // eslint-disable-next-line vue/return-in-computed-property const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => { switch (props.language) { @@ -188,6 +197,12 @@ watch( }, ); +watch(dragAndDropExtension, (extension) => { + editor.value?.dispatch({ + effects: dragAndDropCompartment.value.reconfigure(extension), + }); +}); + watch( () => props.language, (_newLanguage, previousLanguage: CodeNodeEditorLanguage) => { @@ -202,7 +217,6 @@ watch( reloadLinter(); }, ); - watch( aiEnabled, async (isEnabled) => { @@ -361,6 +375,12 @@ function onAiLoadStart() { function onAiLoadEnd() { isLoadingAIResponse.value = false; } + +async function onDrop(value: string, event: MouseEvent) { + if (!editor.value) return; + + await dropInCodeEditor(toRaw(editor.value), event, value); +}