Merge remote-tracking branch 'origin/master' into node-1608-credential-parameters-tech-debt-project

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-09-27 12:30:57 +02:00
commit 6534b614e0
No known key found for this signature in database
133 changed files with 2016 additions and 951 deletions

View file

@ -128,19 +128,19 @@ jobs:
- name: Trigger a release note - 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 }}"}' 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: # merge-back-into-master:
name: Merge back into master # name: Merge back into master
needs: [publish-to-npm, create-github-release] # needs: [publish-to-npm, create-github-release]
if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }} # if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- uses: actions/checkout@v4.1.1 # - uses: actions/checkout@v4.1.1
with: # with:
fetch-depth: 0 # fetch-depth: 0
- run: | # - run: |
git checkout --track origin/master # git checkout --track origin/master
git config user.name "github-actions[bot]" # git config user.name "github-actions[bot]"
git config user.email 41898282+github-actions[bot]@users.noreply.github.com # git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }} # git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
git push origin master # git push origin master
git push origin :${{github.event.pull_request.base.ref}} # git push origin :${{github.event.pull_request.base.ref}}

View file

@ -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) # [1.60.0](https://github.com/n8n-io/n8n/compare/n8n@1.59.0...n8n@1.60.0) (2024-09-18)

View file

@ -229,6 +229,35 @@ describe('Workflow Executions', () => {
cy.getByTestId('executions-filter-reset-button').should('be.visible').click(); cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
executionsTab.getters.executionListItems().eq(11).should('be.visible'); 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', () => { describe('when new workflow is not saved', () => {

View file

@ -674,6 +674,23 @@ describe('NDV', () => {
ndv.getters.parameterInput('operation').find('input').should('have.value', 'Delete'); 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', () => { it('Should show error state when remote options cannot be fetched', () => {
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as( cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as(
'parameterOptions', 'parameterOptions',
@ -684,6 +701,11 @@ describe('NDV', () => {
action: 'Update a database page', action: 'Update a database page',
}); });
clickCreateNewCredential();
setCredentialValues({
apiKey: 'sk_test_123',
});
ndv.actions.addItemToFixedCollection('propertiesUi'); ndv.actions.addItemToFixedCollection('propertiesUi');
ndv.getters ndv.getters
.parameterInput('key') .parameterInput('key')

View file

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

View file

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

View file

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

View file

@ -105,9 +105,8 @@ async function main() {
console.error(error.message); console.error(error.message);
console.error(''); console.error('');
await printContainerStatus(dockerComposeClient); await printContainerStatus(dockerComposeClient);
console.error('');
await dumpLogs(dockerComposeClient);
} finally { } finally {
await dumpLogs(dockerComposeClient);
await dockerComposeClient.$('down'); await dockerComposeClient.$('down');
} }
} }
@ -118,7 +117,7 @@ async function printContainerStatus(dockerComposeClient) {
} }
async function dumpLogs(dockerComposeClient) { async function dumpLogs(dockerComposeClient) {
console.error('Container logs:'); console.info('Container logs:');
await dockerComposeClient.$('logs'); await dockerComposeClient.$('logs');
} }

View file

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

View file

@ -10,10 +10,21 @@ import {
import { RetrievalQAChain } from 'langchain/chains'; import { RetrievalQAChain } from 'langchain/chains';
import type { BaseLanguageModel } from '@langchain/core/language_models/base'; import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseRetriever } from '@langchain/core/retrievers'; import type { BaseRetriever } from '@langchain/core/retrievers';
import {
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate,
PromptTemplate,
} from '@langchain/core/prompts';
import { getTemplateNoticeField } from '../../../utils/sharedFields'; import { getTemplateNoticeField } from '../../../utils/sharedFields';
import { getPromptInputByType } from '../../../utils/helpers'; import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
import { getTracingConfig } from '../../../utils/tracing'; 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 { export class ChainRetrievalQa implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Question and Answer Chain', 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 users query.',
typeOptions: {
rows: 6,
},
},
],
},
], ],
}; };
@ -154,7 +185,6 @@ export class ChainRetrievalQa implements INodeType {
)) as BaseRetriever; )) as BaseRetriever;
const items = this.getInputData(); const items = this.getInputData();
const chain = RetrievalQAChain.fromLLM(model, retriever);
const returnData: INodeExecutionData[] = []; const returnData: INodeExecutionData[] = [];
@ -178,6 +208,35 @@ export class ChainRetrievalQa implements INodeType {
throw new NodeOperationError(this.getNode(), 'The query parameter is empty.'); 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 }); const response = await chain.withConfig(getTracingConfig(this)).invoke({ query });
returnData.push({ json: { response } }); returnData.push({ json: { response } });
} catch (error) { } catch (error) {

View file

@ -275,7 +275,11 @@ export class ToolHttpRequest implements INodeType {
method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods, method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods,
url: this.getNodeParameter('url', itemIndex) as string, url: this.getNodeParameter('url', itemIndex) as string,
qs: {}, 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: {}, body: {},
}; };

View file

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

View file

@ -71,5 +71,11 @@ module.exports = {
], ],
}, },
}, },
{
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
rules: {
'n8n-local-rules/no-dynamic-import-template': 'off',
},
},
], ],
}; };

View file

@ -43,6 +43,13 @@ require('express-async-errors');
require('source-map-support').install(); require('source-map-support').install();
require('reflect-metadata'); 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') { if (process.env.NODEJS_PREFER_IPV4 === 'true') {
require('dns').setDefaultResultOrder('ipv4first'); require('dns').setDefaultResultOrder('ipv4first');
} }

View file

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

View file

@ -1,6 +1,5 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import convict from 'convict'; import convict from 'convict';
import dotenv from 'dotenv';
import { flatten } from 'flat'; import { flatten } from 'flat';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
@ -22,8 +21,6 @@ if (inE2ETests) {
process.env.N8N_PUBLIC_API_DISABLED = 'true'; process.env.N8N_PUBLIC_API_DISABLED = 'true';
process.env.SKIP_STATISTICS_EVENTS = 'true'; process.env.SKIP_STATISTICS_EVENTS = 'true';
process.env.N8N_SECURE_COOKIE = 'false'; process.env.N8N_SECURE_COOKIE = 'false';
} else {
dotenv.config();
} }
// Load schema after process.env has been overwritten // Load schema after process.env has been overwritten

View file

@ -2,11 +2,14 @@ import { UserUpdateRequestDto } from '@n8n/api-types';
import type { Response } from 'express'; import type { Response } from 'express';
import { mock, anyObject } from 'jest-mock-extended'; import { mock, anyObject } from 'jest-mock-extended';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { randomString } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { AUTH_COOKIE_NAME } from '@/constants'; 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 type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository'; import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
@ -18,6 +21,7 @@ import type { PublicUser } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service'; import { MfaService } from '@/mfa/mfa.service';
import type { AuthenticatedRequest, MeRequest } from '@/requests'; import type { AuthenticatedRequest, MeRequest } from '@/requests';
import { API_KEY_PREFIX } from '@/services/public-api-key.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { badPasswords } from '@test/test-data'; import { badPasswords } from '@test/test-data';
@ -30,6 +34,7 @@ describe('MeController', () => {
const userService = mockInstance(UserService); const userService = mockInstance(UserService);
const userRepository = mockInstance(UserRepository); const userRepository = mockInstance(UserRepository);
const mockMfaService = mockInstance(MfaService); const mockMfaService = mockInstance(MfaService);
const apiKeysRepository = mockInstance(ApiKeyRepository);
mockInstance(AuthUserRepository); mockInstance(AuthUserRepository);
mockInstance(InvalidAuthTokenRepository); mockInstance(InvalidAuthTokenRepository);
mockInstance(License).isWithinUsersLimit.mockReturnValue(true); mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
@ -412,27 +417,63 @@ describe('MeController', () => {
describe('API Key methods', () => { describe('API Key methods', () => {
let req: AuthenticatedRequest; let req: AuthenticatedRequest;
beforeAll(() => { beforeAll(() => {
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) }); req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
}); });
describe('createAPIKey', () => { describe('createAPIKey', () => {
it('should create and save an API key', async () => { it('should create and save an API key', async () => {
const { apiKey } = await controller.createAPIKey(req); const apiKeyData = {
expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey }); 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', () => { describe('getAPIKeys', () => {
it('should return the users api key redacted', async () => { it('should return the users api keys redacted', async () => {
const { apiKey } = await controller.getAPIKey(req); const apiKeyData = {
expect(apiKey).not.toEqual(req.user.apiKey); 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', () => { describe('deleteAPIKey', () => {
it('should delete the API key', async () => { it('should delete the API key', async () => {
const user = mock<User>({
id: '123',
password: 'password',
authIdentities: [],
role: 'global:member',
mfaEnabled: false,
});
const req = mock<MeRequest.DeleteAPIKey>({ user, params: { id: user.id } });
await controller.deleteAPIKey(req); 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,
});
}); });
}); });
}); });

View file

@ -1,6 +1,6 @@
import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators'; import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators';
import { AnnotationTagsRequest } from '@/requests'; import { AnnotationTagsRequest } from '@/requests';
import { AnnotationTagService } from '@/services/annotation-tag.service'; import { AnnotationTagService } from '@/services/annotation-tag.service.ee';
@RestController('/annotation-tags') @RestController('/annotation-tags')
export class AnnotationTagsController { export class AnnotationTagsController {

View file

@ -4,7 +4,6 @@ import {
UserUpdateRequestDto, UserUpdateRequestDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { randomBytes } from 'crypto';
import { type RequestHandler, Response } from 'express'; import { type RequestHandler, Response } from 'express';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
@ -22,13 +21,12 @@ import { MfaService } from '@/mfa/mfa.service';
import { isApiEnabled } from '@/public-api'; import { isApiEnabled } from '@/public-api';
import { AuthenticatedRequest, MeRequest } from '@/requests'; import { AuthenticatedRequest, MeRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility'; import { PasswordUtility } from '@/services/password.utility';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers'; import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers';
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto'; import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
export const API_KEY_PREFIX = 'n8n_api_';
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
if (isApiEnabled()) { if (isApiEnabled()) {
next(); next();
@ -48,6 +46,7 @@ export class MeController {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly mfaService: MfaService, 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) { async createAPIKey(req: AuthenticatedRequest) {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user);
await this.userService.update(req.user.id, { apiKey });
this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false }); 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] }) @Get('/api-keys', { middlewares: [isApiEnabledMiddleware] })
async getAPIKey(req: AuthenticatedRequest) { async getAPIKeys(req: AuthenticatedRequest) {
const apiKey = this.redactApiKey(req.user.apiKey); const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user);
return { apiKey }; return apiKeys;
} }
/** /**
* Deletes an API Key * Delete an API Key
*/ */
@Delete('/api-key', { middlewares: [isApiEnabledMiddleware] }) @Delete('/api-keys/:id', { middlewares: [isApiEnabledMiddleware] })
async deleteAPIKey(req: AuthenticatedRequest) { async deleteAPIKey(req: MeRequest.DeleteAPIKey) {
await this.userService.update(req.user.id, { apiKey: null }); await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id);
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false }); this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
@ -273,14 +270,4 @@ export class MeController {
return user.settings; 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)
);
}
} }

View file

@ -8,7 +8,6 @@ describe('User Entity', () => {
firstName: 'Don', firstName: 'Don',
lastName: 'Joe', lastName: 'Joe',
password: '123456789', password: '123456789',
apiKey: '123',
}); });
expect(JSON.stringify(user)).toEqual( expect(JSON.stringify(user)).toEqual(
'{"email":"test@example.com","firstName":"Don","lastName":"Joe"}', '{"email":"test@example.com","firstName":"Don","lastName":"Joe"}',

View file

@ -1,8 +1,8 @@
import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm'; import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
import { IsString, Length } from 'class-validator'; import { IsString, Length } from 'class-validator';
import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
import { WithTimestampsAndStringId } from './abstract-entity'; import { WithTimestampsAndStringId } from './abstract-entity';

View file

@ -1,7 +1,7 @@
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import type { AnnotationTagEntity } from './annotation-tag-entity'; import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
import type { ExecutionAnnotation } from './execution-annotation'; import type { ExecutionAnnotation } from './execution-annotation.ee';
/** /**
* This entity represents the junction table between the execution annotations and the tags * This entity represents the junction table between the execution annotations and the tags

View file

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

View file

@ -12,8 +12,8 @@ import {
} from '@n8n/typeorm'; } from '@n8n/typeorm';
import type { AnnotationVote } from 'n8n-workflow'; import type { AnnotationVote } from 'n8n-workflow';
import type { AnnotationTagEntity } from './annotation-tag-entity'; import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
import type { AnnotationTagMapping } from './annotation-tag-mapping'; import type { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import { ExecutionEntity } from './execution-entity'; import { ExecutionEntity } from './execution-entity';
@Entity({ name: 'execution_annotations' }) @Entity({ name: 'execution_annotations' })

View file

@ -12,7 +12,7 @@ import {
} from '@n8n/typeorm'; } from '@n8n/typeorm';
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; 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 { datetimeColumnType } from './abstract-entity';
import type { ExecutionData } from './execution-data'; import type { ExecutionData } from './execution-data';

View file

@ -1,11 +1,12 @@
import { AnnotationTagEntity } from './annotation-tag-entity'; import { AnnotationTagEntity } from './annotation-tag-entity.ee';
import { AnnotationTagMapping } from './annotation-tag-mapping'; import { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import { ApiKey } from './api-key';
import { AuthIdentity } from './auth-identity'; import { AuthIdentity } from './auth-identity';
import { AuthProviderSyncHistory } from './auth-provider-sync-history'; import { AuthProviderSyncHistory } from './auth-provider-sync-history';
import { AuthUser } from './auth-user'; import { AuthUser } from './auth-user';
import { CredentialsEntity } from './credentials-entity'; import { CredentialsEntity } from './credentials-entity';
import { EventDestinations } from './event-destinations'; import { EventDestinations } from './event-destinations';
import { ExecutionAnnotation } from './execution-annotation'; import { ExecutionAnnotation } from './execution-annotation.ee';
import { ExecutionData } from './execution-data'; import { ExecutionData } from './execution-data';
import { ExecutionEntity } from './execution-entity'; import { ExecutionEntity } from './execution-entity';
import { ExecutionMetadata } from './execution-metadata'; import { ExecutionMetadata } from './execution-metadata';
@ -54,4 +55,5 @@ export const entities = {
WorkflowHistory, WorkflowHistory,
Project, Project,
ProjectRelation, ProjectRelation,
ApiKey,
}; };

View file

@ -23,6 +23,7 @@ import { NoUrl } from '@/validators/no-url.validator';
import { NoXss } from '@/validators/no-xss.validator'; import { NoXss } from '@/validators/no-xss.validator';
import { WithTimestamps, jsonColumnType } from './abstract-entity'; import { WithTimestamps, jsonColumnType } from './abstract-entity';
import type { ApiKey } from './api-key';
import type { AuthIdentity } from './auth-identity'; import type { AuthIdentity } from './auth-identity';
import type { ProjectRelation } from './project-relation'; import type { ProjectRelation } from './project-relation';
import type { SharedCredentials } from './shared-credentials'; import type { SharedCredentials } from './shared-credentials';
@ -89,6 +90,9 @@ export class User extends WithTimestamps implements IUser {
@OneToMany('AuthIdentity', 'user') @OneToMany('AuthIdentity', 'user')
authIdentities: AuthIdentity[]; authIdentities: AuthIdentity[];
@OneToMany('ApiKey', 'user')
apiKeys: ApiKey[];
@OneToMany('SharedWorkflow', 'user') @OneToMany('SharedWorkflow', 'user')
sharedWorkflows: SharedWorkflow[]; sharedWorkflows: SharedWorkflow[];
@ -107,10 +111,6 @@ export class User extends WithTimestamps implements IUser {
this.email = this.email?.toLowerCase() ?? null; this.email = this.email?.toLowerCase() ?? null;
} }
@Column({ type: String, nullable: true })
@Index({ unique: true })
apiKey: string | null;
@Column({ type: Boolean, default: false }) @Column({ type: Boolean, default: false })
mfaEnabled: boolean; mfaEnabled: boolean;
@ -151,7 +151,7 @@ export class User extends WithTimestamps implements IUser {
} }
toJSON() { toJSON() {
const { password, apiKey, ...rest } = this; const { password, ...rest } = this;
return rest; return rest;
} }

View file

@ -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<Partial<ApiKey>>;
// 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};`);
}
}

View file

@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -128,4 +129,5 @@ export const mysqlMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828, CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
]; ];

View file

@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -128,4 +129,5 @@ export const postgresMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828, CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
]; ];

View file

@ -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<Partial<ApiKey>>;
// 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;');
}
}

View file

@ -37,6 +37,7 @@ import { AddMfaColumns1690000000030 } from './1690000000040-AddMfaColumns';
import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete'; import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete';
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -122,6 +123,7 @@ const sqliteMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222, CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146, RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828, CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -1,7 +1,7 @@
import { DataSource, Repository } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
@Service() @Service()
export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapping> { export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapping> {

View file

@ -1,7 +1,7 @@
import { DataSource, Repository } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
@Service() @Service()
export class AnnotationTagRepository extends Repository<AnnotationTagEntity> { export class AnnotationTagRepository extends Repository<AnnotationTagEntity> {

View file

@ -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<ApiKey> {
constructor(dataSource: DataSource) {
super(ApiKey, dataSource.manager);
}
}

View file

@ -1,7 +1,7 @@
import { DataSource, Repository } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
@Service() @Service()
export class ExecutionAnnotationRepository extends Repository<ExecutionAnnotation> { export class ExecutionAnnotationRepository extends Repository<ExecutionAnnotation> {

View file

@ -36,9 +36,9 @@ import type {
import { Service } from 'typedi'; import { Service } from 'typedi';
import config from '@/config'; import config from '@/config';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error'; import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
import type { ExecutionSummaries } from '@/executions/execution.types'; import type { ExecutionSummaries } from '@/executions/execution.types';
import type { import type {
@ -54,6 +54,8 @@ import { ExecutionDataRepository } from './execution-data.repository';
import type { ExecutionData } from '../entities/execution-data'; import type { ExecutionData } from '../entities/execution-data';
import { ExecutionEntity } from '../entities/execution-entity'; import { ExecutionEntity } from '../entities/execution-entity';
import { ExecutionMetadata } from '../entities/execution-metadata'; import { ExecutionMetadata } from '../entities/execution-metadata';
import { SharedWorkflow } from '../entities/shared-workflow';
import { WorkflowEntity } from '../entities/workflow-entity';
export interface IGetExecutionsQueryFilter { export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string; id?: FindOperator<string> | string;
@ -874,6 +876,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
metadata, metadata,
annotationTags, annotationTags,
vote, vote,
projectId,
} = query; } = query;
const fields = Object.keys(this.summaryFields) const fields = Object.keys(this.summaryFields)
@ -945,6 +948,12 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
} }
} }
if (projectId) {
qb.innerJoin(WorkflowEntity, 'w', 'w.id = execution.workflowId')
.innerJoin(SharedWorkflow, 'sw', 'sw.workflowId = w.id')
.where('sw.projectId = :projectId', { projectId });
}
return qb; return qb;
} }

View file

@ -1,6 +1,7 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { QueryFailedError } from '@n8n/typeorm'; import { QueryFailedError } from '@n8n/typeorm';
import { AxiosError } from 'axios';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow'; import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow';
import Container from 'typedi'; import Container from 'typedi';
@ -67,6 +68,8 @@ export const initErrorHandling = async () => {
beforeSend(event, { originalException }) { beforeSend(event, { originalException }) {
if (!originalException) return null; if (!originalException) return null;
if (originalException instanceof AxiosError) return null;
if ( if (
originalException instanceof QueryFailedError && originalException instanceof QueryFailedError &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg)) ['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))

View file

@ -21,7 +21,7 @@ import { ActiveExecutions } from '@/active-executions';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import config from '@/config'; import config from '@/config';
import type { User } from '@/databases/entities/user'; 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 { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { IGetExecutionsQueryFilter } from '@/databases/repositories/execution.repository'; import type { IGetExecutionsQueryFilter } from '@/databases/repositories/execution.repository';

View file

@ -80,6 +80,7 @@ export namespace ExecutionSummaries {
startedBefore: string; startedBefore: string;
annotationTags: string[]; // tag IDs annotationTags: string[]; // tag IDs
vote: AnnotationVote; vote: AnnotationVote;
projectId: string;
}>; }>;
type AccessFields = { type AccessFields = {

View file

@ -1,6 +1,6 @@
import { validate } from 'class-validator'; 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 { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';

View file

@ -26,7 +26,7 @@ import type {
import type PCancelable from 'p-cancelable'; import type PCancelable from 'p-cancelable';
import type { ActiveWorkflowManager } from '@/active-workflow-manager'; 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 { AuthProviderType } from '@/databases/entities/auth-identity';
import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';

View file

@ -18,6 +18,7 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import picocolors from 'picocolors';
import { Container, Service } from 'typedi'; import { Container, Service } from 'typedi';
import { import {
@ -146,6 +147,7 @@ export class LoadNodesAndCredentials {
path.join(nodeModulesDir, packagePath), path.join(nodeModulesDir, packagePath),
); );
} catch (error) { } catch (error) {
this.logger.error((error as Error).message);
ErrorReporter.error(error); ErrorReporter.error(error);
} }
} }
@ -258,6 +260,13 @@ export class LoadNodesAndCredentials {
dir: string, dir: string,
) { ) {
const loader = new constructor(dir, this.excludeNodes, this.includeNodes); 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(); await loader.loadAll();
this.loaders[loader.packageName] = loader; this.loaders[loader.packageName] = loader;
return loader; return loader;

View file

@ -10,10 +10,10 @@ import { Container } from 'typedi';
import validator from 'validator'; import validator from 'validator';
import YAML from 'yamljs'; import YAML from 'yamljs';
import { UserRepository } from '@/databases/repositories/user.repository';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { License } from '@/license'; import { License } from '@/license';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
async function createApiRouter( async function createApiRouter(
@ -90,10 +90,9 @@ async function createApiRouter(
_scopes: unknown, _scopes: unknown,
schema: OpenAPIV3.ApiKeySecurityScheme, schema: OpenAPIV3.ApiKeySecurityScheme,
): Promise<boolean> => { ): Promise<boolean> => {
const apiKey = req.headers[schema.name.toLowerCase()] as string; const providedApiKey = req.headers[schema.name.toLowerCase()] as string;
const user = await Container.get(UserRepository).findOne({
where: { apiKey }, const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey);
});
if (!user) return false; if (!user) return false;

View file

@ -84,11 +84,7 @@ export declare namespace WorkflowRequest {
type Activate = Get; type Activate = Get;
type GetTags = Get; type GetTags = Get;
type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>; type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>;
type Transfer = AuthenticatedRequest< type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>;
{ workflowId: string },
{},
{ destinationProjectId: string }
>;
} }
export declare namespace UserRequest { export declare namespace UserRequest {

View file

@ -73,11 +73,13 @@ export = {
transferWorkflow: [ transferWorkflow: [
projectScope('workflow:move', 'workflow'), projectScope('workflow:move', 'workflow'),
async (req: WorkflowRequest.Transfer, res: express.Response) => { async (req: WorkflowRequest.Transfer, res: express.Response) => {
const { id: workflowId } = req.params;
const body = z.object({ destinationProjectId: z.string() }).parse(req.body); const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
await Container.get(EnterpriseWorkflowService).transferOne( await Container.get(EnterpriseWorkflowService).transferOne(
req.user, req.user,
req.params.workflowId, workflowId,
body.destinationProjectId, body.destinationProjectId,
); );

View file

@ -186,6 +186,7 @@ export declare namespace CredentialRequest {
export declare namespace MeRequest { export declare namespace MeRequest {
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>; export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>;
} }
export interface UserSetupPayload { export interface UserSetupPayload {

View file

@ -35,7 +35,7 @@ import type { FrontendService } from '@/services/frontend.service';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
import '@/controllers/active-workflows.controller'; import '@/controllers/active-workflows.controller';
import '@/controllers/annotation-tags.controller'; import '@/controllers/annotation-tags.controller.ee';
import '@/controllers/auth.controller'; import '@/controllers/auth.controller';
import '@/controllers/binary-data.controller'; import '@/controllers/binary-data.controller';
import '@/controllers/curl.controller'; import '@/controllers/curl.controller';

View file

@ -1,7 +1,7 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository'; import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
import { validateEntity } from '@/generic-helpers'; import { validateEntity } from '@/generic-helpers';
import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces'; import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces';

View file

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

View file

@ -58,7 +58,7 @@ export class UserService {
withScopes?: boolean; withScopes?: boolean;
}, },
) { ) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user; const { password, updatedAt, authIdentities, ...rest } = user;
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');

View file

@ -10,7 +10,6 @@ export function assertReturnedUserProps(user: User) {
expect(user.personalizationAnswers).toBeNull(); expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeUndefined(); expect(user.password).toBeUndefined();
expect(user.isPending).toBe(false); expect(user.isPending).toBe(false);
expect(user.apiKey).not.toBeDefined();
expect(user.globalScopes).toBeDefined(); expect(user.globalScopes).toBeDefined();
expect(user.globalScopes).not.toHaveLength(0); expect(user.globalScopes).not.toHaveLength(0);
} }

View file

@ -6,6 +6,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { ExecutionService } from '@/executions/execution.service'; import { ExecutionService } from '@/executions/execution.service';
import type { ExecutionSummaries } from '@/executions/execution.types'; import type { ExecutionSummaries } from '@/executions/execution.types';
import { createTeamProject } from '@test-integration/db/projects';
import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions'; import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions';
import { createWorkflow } from './shared/db/workflows'; 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 () => { test('should exclude executions by inaccessible `workflowId`', async () => {
const accessibleWorkflow = await createWorkflow(); const accessibleWorkflow = await createWorkflow();
const inaccessibleWorkflow = await createWorkflow(); const inaccessibleWorkflow = await createWorkflow();

View file

@ -1,22 +1,29 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { IsNull } from '@n8n/typeorm';
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow'; import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import validator from 'validator'; import validator from 'validator';
import type { ApiKey } from '@/databases/entities/api-key';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { addApiKey, createOwner, createUser, createUserShell } from './shared/db/users'; import { createOwnerWithApiKey, createUser, createUserShell } from './shared/db/users';
import { randomApiKey, randomEmail, randomName, randomValidPassword } from './shared/random'; import { randomEmail, randomName, randomValidPassword } from './shared/random';
import * as testDb from './shared/test-db'; import * as testDb from './shared/test-db';
import type { SuperAgentTest } from './shared/types'; import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
let publicApiKeyService: PublicApiKeyService;
beforeAll(() => {
publicApiKeyService = Container.get(PublicApiKeyService);
});
beforeEach(async () => { beforeEach(async () => {
await testDb.truncate(['User']); await testDb.truncate(['User']);
@ -28,22 +35,22 @@ describe('When public API is disabled', () => {
let authAgent: SuperAgentTest; let authAgent: SuperAgentTest;
beforeEach(async () => { beforeEach(async () => {
owner = await createOwner(); owner = await createOwnerWithApiKey();
await addApiKey(owner);
authAgent = testServer.authAgentFor(owner); authAgent = testServer.authAgentFor(owner);
mockInstance(GlobalConfig, { publicApi: { disabled: true } }); mockInstance(GlobalConfig, { publicApi: { disabled: true } });
}); });
test('POST /me/api-key should 404', async () => { test('POST /me/api-keys should 404', async () => {
await authAgent.post('/me/api-key').expect(404); await authAgent.post('/me/api-keys').expect(404);
}); });
test('GET /me/api-key should 404', async () => { test('GET /me/api-keys should 404', async () => {
await authAgent.get('/me/api-key').expect(404); await authAgent.get('/me/api-keys').expect(404);
}); });
test('DELETE /me/api-key should 404', async () => { test('DELETE /me/api-key/:id should 404', async () => {
await authAgent.delete('/me/api-key').expect(404); await authAgent.delete(`/me/api-keys/${1}`).expect(404);
}); });
}); });
@ -53,7 +60,6 @@ describe('Owner shell', () => {
beforeEach(async () => { beforeEach(async () => {
ownerShell = await createUserShell('global:owner'); ownerShell = await createUserShell('global:owner');
await addApiKey(ownerShell);
authOwnerShellAgent = testServer.authAgentFor(ownerShell); authOwnerShellAgent = testServer.authAgentFor(ownerShell);
}); });
@ -63,17 +69,8 @@ describe('Owner shell', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const { const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
id, response.body.data;
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true); expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase()); expect(email).toBe(validPayload.email.toLowerCase());
@ -83,7 +80,6 @@ describe('Owner shell', () => {
expect(password).toBeUndefined(); expect(password).toBeUndefined();
expect(isPending).toBe(false); expect(isPending).toBe(false);
expect(role).toBe('global:owner'); expect(role).toBe('global:owner');
expect(apiKey).toBeUndefined();
const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id }); 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 () => { test('POST /me/api-keys should create an api key', async () => {
const response = await authOwnerShellAgent.post('/me/api-key'); const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
expect(response.statusCode).toBe(200); const newApiKey = newApiKeyResponse.body.data as ApiKey;
expect(response.body.data.apiKey).toBeDefined();
expect(response.body.data.apiKey).not.toBeNull();
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({ expect(newApiKeyResponse.statusCode).toBe(200);
where: { email: IsNull() }, expect(newApiKey).toBeDefined();
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
userId: ownerShell.id,
}); });
expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey); expect(newStoredApiKey).toEqual({
}); id: expect.any(String),
label: 'My API Key',
test('GET /me/api-key should fetch the api key redacted', async () => { userId: ownerShell.id,
const response = await authOwnerShellAgent.get('/me/api-key'); apiKey: newApiKey.apiKey,
createdAt: expect.any(Date),
expect(response.statusCode).toBe(200); updatedAt: expect.any(Date),
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(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({ member = await createUser({
password: memberPassword, password: memberPassword,
role: 'global:member', role: 'global:member',
apiKey: randomApiKey(),
}); });
authMemberAgent = testServer.authAgentFor(member); authMemberAgent = testServer.authAgentFor(member);
await utils.setInstanceOwnerSetUp(true); await utils.setInstanceOwnerSetUp(true);
}); });
@ -215,17 +228,8 @@ describe('Member', () => {
for (const validPayload of VALID_PATCH_ME_PAYLOADS) { for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
const { const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
id, response.body.data;
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true); expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email.toLowerCase()); expect(email).toBe(validPayload.email.toLowerCase());
@ -235,7 +239,6 @@ describe('Member', () => {
expect(password).toBeUndefined(); expect(password).toBeUndefined();
expect(isPending).toBe(false); expect(isPending).toBe(false);
expect(role).toBe('global:member'); expect(role).toBe('global:member');
expect(apiKey).toBeUndefined();
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); const storedMember = await Container.get(UserRepository).findOneByOrFail({ id });
@ -275,6 +278,7 @@ describe('Member', () => {
}; };
const response = await authMemberAgent.patch('/me/password').send(validPayload); const response = await authMemberAgent.patch('/me/password').send(validPayload);
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
@ -315,33 +319,59 @@ describe('Member', () => {
} }
}); });
test('POST /me/api-key should create an api key', async () => { test('POST /me/api-keys should create an api key', async () => {
const response = await testServer.authAgentFor(member).post('/me/api-key'); const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
expect(response.statusCode).toBe(200); expect(newApiKeyResponse.statusCode).toBe(200);
expect(response.body.data.apiKey).toBeDefined(); expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
expect(response.body.data.apiKey).not.toBeNull(); 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 () => { test('GET /me/api-keys should fetch the api key redacted', async () => {
const response = await testServer.authAgentFor(member).get('/me/api-key'); const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
expect(response.statusCode).toBe(200); const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys');
expect(response.body.data.apiKey).not.toEqual(member.apiKey);
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 () => { test('DELETE /me/api-keys/:id should delete the api key', async () => {
const response = await testServer.authAgentFor(member).delete('/me/api-key'); 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);
}); });
}); });

View file

@ -7,8 +7,8 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre
import { createTeamProject } from '@test-integration/db/projects'; import { createTeamProject } from '@test-integration/db/projects';
import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials';
import { addApiKey, createUser, createUserShell } from '../shared/db/users'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { randomApiKey, randomName } from '../shared/random'; import { randomName } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
@ -24,8 +24,8 @@ let saveCredential: SaveCredentialFunction;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => { beforeAll(async () => {
owner = await addApiKey(await createUserShell('global:owner')); owner = await createOwnerWithApiKey();
member = await createUser({ role: 'global:member', apiKey: randomApiKey() }); member = await createMemberWithApiKey();
authOwnerAgent = testServer.publicApiAgentFor(owner); authOwnerAgent = testServer.publicApiAgentFor(owner);
authMemberAgent = testServer.publicApiAgentFor(member); authMemberAgent = testServer.publicApiAgentFor(member);
@ -156,10 +156,7 @@ describe('DELETE /credentials/:id', () => {
}); });
test('should delete owned cred for member but leave others untouched', async () => { test('should delete owned cred for member but leave others untouched', async () => {
const anotherMember = await createUser({ const anotherMember = await createMemberWithApiKey();
role: 'global:member',
apiKey: randomApiKey(),
});
const savedCredential = await saveCredential(dbCredential(), { user: member }); const savedCredential = await saveCredential(dbCredential(), { user: member });
const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member }); const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member });

View file

@ -12,13 +12,12 @@ import {
createSuccessfulExecution, createSuccessfulExecution,
createWaitingExecution, createWaitingExecution,
} from '../shared/db/executions'; } from '../shared/db/executions';
import { createUser } from '../shared/db/users'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { import {
createManyWorkflows, createManyWorkflows,
createWorkflow, createWorkflow,
shareWorkflowWithUsers, shareWorkflowWithUsers,
} from '../shared/db/workflows'; } from '../shared/db/workflows';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -36,9 +35,9 @@ mockInstance(Telemetry);
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ role: 'global:owner', apiKey: randomApiKey() }); owner = await createOwnerWithApiKey();
user1 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); user1 = await createMemberWithApiKey();
user2 = await createUser({ role: 'global:member', apiKey: randomApiKey() }); user2 = await createMemberWithApiKey();
// TODO: mock BinaryDataService instead // TODO: mock BinaryDataService instead
await utils.initBinaryDataService(); await utils.initBinaryDataService();

View file

@ -2,7 +2,7 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; 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 { setupTestServer } from '@test-integration/utils';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
@ -26,7 +26,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const projects = await Promise.all([ const projects = await Promise.all([
createTeamProject(), createTeamProject(),
createTeamProject(), createTeamProject(),
@ -53,15 +53,10 @@ describe('Projects in Public API', () => {
}); });
it('if not authenticated, should reject', async () => { it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwner({ withApiKey: false });
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).get('/projects'); const response = await testServer.publicApiAgentWithoutApiKey().get('/projects');
/** /**
* Assert * Assert
@ -74,7 +69,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
/** /**
* Act * Act
@ -97,12 +92,12 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).get('/projects'); const response = await testServer.publicApiAgentFor(member).get('/projects');
/** /**
* Assert * Assert
@ -119,7 +114,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
@ -150,14 +145,13 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(owner) .publicApiAgentWithoutApiKey()
.post('/projects') .post('/projects')
.send(projectPayload); .send(projectPayload);
@ -172,7 +166,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
@ -199,7 +193,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const member = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const projectPayload = { name: 'some-project' }; const projectPayload = { name: 'some-project' };
/** /**
@ -225,7 +219,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
@ -244,13 +238,14 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); const response = await testServer
.publicApiAgentWithoutApiKey()
.delete(`/projects/${project.id}`);
/** /**
* Assert * Assert
@ -263,7 +258,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
@ -287,13 +282,13 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const member = await createMember({ withApiKey: true }); const owner = await createMemberWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
/** /**
* Assert * Assert
@ -310,7 +305,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject('old-name'); const project = await createTeamProject('old-name');
/** /**
@ -332,14 +327,13 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(owner) .publicApiAgentWithoutApiKey()
.put(`/projects/${project.id}`) .put(`/projects/${project.id}`)
.send({ name: 'new-name' }); .send({ name: 'new-name' });
@ -354,7 +348,7 @@ describe('Projects in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**
@ -381,7 +375,7 @@ describe('Projects in Public API', () => {
*/ */
testServer.license.setQuota('quota:maxTeamProjects', -1); testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
const member = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const project = await createTeamProject(); const project = await createTeamProject();
/** /**

View file

@ -4,8 +4,7 @@ import type { User } from '@/databases/entities/user';
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { createTag } from '../shared/db/tags'; import { createTag } from '../shared/db/tags';
import { createUser } from '../shared/db/users'; import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -18,15 +17,8 @@ let authMemberAgent: SuperAgentTest;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ owner = await createOwnerWithApiKey();
role: 'global:owner', member = await createMemberWithApiKey();
apiKey: randomApiKey(),
});
member = await createUser({
role: 'global:member',
apiKey: randomApiKey(),
});
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -6,8 +6,13 @@ import { License } from '@/license';
import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; import { createTeamProject, linkUserToProject } from '@test-integration/db/projects';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { createOwner, createUser, createUserShell } from '../shared/db/users'; import {
import { randomApiKey } from '../shared/random'; createMember,
createMemberWithApiKey,
createOwnerWithApiKey,
createUser,
createUserShell,
} from '../shared/db/users';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -25,32 +30,23 @@ beforeEach(async () => {
describe('With license unlimited quota:users', () => { describe('With license unlimited quota:users', () => {
describe('GET /users', () => { describe('GET /users', () => {
test('should fail due to missing API Key', async () => { test('should fail due to missing API Key', async () => {
const owner = await createUser({ role: 'global:owner' }); const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users').expect(401); await authOwnerAgent.get('/users').expect(401);
}); });
test('should fail due to invalid API Key', async () => { test('should fail due to invalid API Key', async () => {
const owner = await createUser({ const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
role: 'global:owner',
apiKey: randomApiKey(),
});
owner.apiKey = 'invalid-key';
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users').expect(401); await authOwnerAgent.get('/users').expect(401);
}); });
test('should fail due to member trying to access owner only endpoint', async () => { 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); const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get('/users').expect(403); await authMemberAgent.get('/users').expect(403);
}); });
test('should return all users', async () => { test('should return all users', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
@ -92,10 +88,10 @@ describe('With license unlimited quota:users', () => {
* Arrange * Arrange
*/ */
const [owner, firstMember, secondMember, thirdMember] = await Promise.all([ const [owner, firstMember, secondMember, thirdMember] = await Promise.all([
createOwner({ withApiKey: true }), createOwnerWithApiKey(),
createUser({ role: 'global:member' }), createMember(),
createUser({ role: 'global:member' }), createMember(),
createUser({ role: 'global:member' }), createMember(),
]); ]);
const [firstProject, secondProject] = await Promise.all([ const [firstProject, secondProject] = await Promise.all([
@ -130,40 +126,30 @@ describe('With license unlimited quota:users', () => {
describe('GET /users/:id', () => { describe('GET /users/:id', () => {
test('should fail due to missing API Key', async () => { test('should fail due to missing API Key', async () => {
const owner = await createUser({ role: 'global:owner' }); const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
await authOwnerAgent.get(`/users/${owner.id}`).expect(401); await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
}); });
test('should fail due to invalid API Key', async () => { test('should fail due to invalid API Key', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner', const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
apiKey: randomApiKey(),
});
owner.apiKey = 'invalid-key';
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get(`/users/${owner.id}`).expect(401); await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
}); });
test('should fail due to member trying to access owner only endpoint', async () => { 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); const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get(`/users/${member.id}`).expect(403); await authMemberAgent.get(`/users/${member.id}`).expect(403);
}); });
test('should return 404 for non-existing id ', async () => { test('should return 404 for non-existing id ', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get(`/users/${uuid()}`).expect(404); await authOwnerAgent.get(`/users/${uuid()}`).expect(404);
}); });
test('should return a pending user', async () => { test('should return a pending user', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const { id: memberId } = await createUserShell('global:member'); const { id: memberId } = await createUserShell('global:member');
@ -199,20 +185,13 @@ describe('With license unlimited quota:users', () => {
describe('GET /users/:email', () => { describe('GET /users/:email', () => {
test('with non-existing email should return 404', async () => { test('with non-existing email should return 404', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404); await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404);
}); });
test('should return a user', async () => { test('should return a user', async () => {
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
const authOwnerAgent = testServer.publicApiAgentFor(owner); const authOwnerAgent = testServer.publicApiAgentFor(owner);
const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200); const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200);
@ -249,10 +228,7 @@ describe('With license without quota:users', () => {
beforeEach(async () => { beforeEach(async () => {
mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) }); mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) });
const owner = await createUser({ const owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
authOwnerAgent = testServer.publicApiAgentFor(owner); authOwnerAgent = testServer.publicApiAgentFor(owner);
}); });

View file

@ -1,7 +1,12 @@
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking'; 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 { setupTestServer } from '@test-integration/utils';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
@ -23,13 +28,12 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const payload = { email: 'test@test.com', role: 'global:admin' }; const payload = { email: 'test@test.com', role: 'global:admin' };
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload);
/** /**
* Assert * Assert
@ -42,7 +46,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const member = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const payload = [{ email: 'test@test.com', role: 'global:admin' }]; const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/** /**
@ -62,7 +66,8 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); 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' }]; const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/** /**
@ -99,13 +104,12 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const member = await createMember(); const member = await createMember();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`);
/** /**
* Assert * Assert
@ -118,14 +122,14 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const firstMember = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const secondMember = await createMember(); const secondMember = await createMember();
/** /**
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(firstMember) .publicApiAgentFor(member)
.delete(`/users/${secondMember.id}`); .delete(`/users/${secondMember.id}`);
/** /**
@ -140,7 +144,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
/** /**
@ -161,13 +165,14 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: false });
const member = await createMember(); const member = await createMember();
/** /**
* Act * Act
*/ */
const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); const response = await testServer
.publicApiAgentWithApiKey('')
.patch(`/users/${member.id}/role`);
/** /**
* Assert * Assert
@ -179,7 +184,7 @@ describe('Users in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
const payload = { newRoleName: 'global:admin' }; const payload = { newRoleName: 'global:admin' };
@ -206,7 +211,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const firstMember = await createMember({ withApiKey: true }); const member = await createMemberWithApiKey();
const secondMember = await createMember(); const secondMember = await createMember();
const payload = { newRoleName: 'global:admin' }; const payload = { newRoleName: 'global:admin' };
@ -214,7 +219,7 @@ describe('Users in Public API', () => {
* Act * Act
*/ */
const response = await testServer const response = await testServer
.publicApiAgentFor(firstMember) .publicApiAgentFor(member)
.patch(`/users/${secondMember.id}/role`) .patch(`/users/${secondMember.id}/role`)
.send(payload); .send(payload);
@ -230,7 +235,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
const payload = { newRoleName: 'invalid' }; const payload = { newRoleName: 'invalid' };
@ -253,7 +258,7 @@ describe('Users in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
const payload = { newRoleName: 'global:admin' }; const payload = { newRoleName: 'global:admin' };

View file

@ -1,5 +1,5 @@
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; 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 { createVariable, getVariableOrFail } from '@test-integration/db/variables';
import { setupTestServer } from '@test-integration/utils'; import { setupTestServer } from '@test-integration/utils';
@ -22,7 +22,7 @@ describe('Variables in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:variables'); testServer.license.enable('feat:variables');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variables = await Promise.all([createVariable(), createVariable(), createVariable()]); const variables = await Promise.all([createVariable(), createVariable(), createVariable()]);
/** /**
@ -48,7 +48,8 @@ describe('Variables in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true });
const owner = await createOwnerWithApiKey();
/** /**
* Act * Act
@ -72,7 +73,7 @@ describe('Variables in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:variables'); testServer.license.enable('feat:variables');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variablePayload = { key: 'key', value: 'value' }; const variablePayload = { key: 'key', value: 'value' };
/** /**
@ -96,7 +97,7 @@ describe('Variables in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variablePayload = { key: 'key', value: 'value' }; const variablePayload = { key: 'key', value: 'value' };
/** /**
@ -124,7 +125,7 @@ describe('Variables in Public API', () => {
* Arrange * Arrange
*/ */
testServer.license.enable('feat:variables'); testServer.license.enable('feat:variables');
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variable = await createVariable(); const variable = await createVariable();
/** /**
@ -145,7 +146,7 @@ describe('Variables in Public API', () => {
/** /**
* Arrange * Arrange
*/ */
const owner = await createOwner({ withApiKey: true }); const owner = await createOwnerWithApiKey();
const variable = await createVariable(); const variable = await createVariable();
/** /**

View file

@ -17,9 +17,8 @@ import { createTeamProject } from '@test-integration/db/projects';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { createTag } from '../shared/db/tags'; 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 { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
@ -40,18 +39,13 @@ const license = testServer.license;
mockInstance(ExecutionService); mockInstance(ExecutionService);
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ owner = await createOwnerWithApiKey();
role: 'global:owner',
apiKey: randomApiKey(),
});
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id, owner.id,
); );
member = await createUser({ member = await createMemberWithApiKey();
role: 'global:member',
apiKey: randomApiKey(),
});
memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
member.id, member.id,
); );
@ -1518,6 +1512,10 @@ describe('PUT /workflows/:id/transfer', () => {
const secondProject = await createTeamProject('second-project', member); const secondProject = await createTeamProject('second-project', member);
const workflow = await createWorkflow({}, firstProject); 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 * Act
*/ */
@ -1529,6 +1527,13 @@ describe('PUT /workflows/:id/transfer', () => {
* Assert * Assert
*/ */
expect(response.statusCode).toBe(204); 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 () => { test('if no destination project, should reject', async () => {

View file

@ -4,7 +4,7 @@ import Container from 'typedi';
import type { ExecutionData } from '@/databases/entities/execution-data'; import type { ExecutionData } from '@/databases/entities/execution-data';
import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-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 { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';

View file

@ -1,8 +1,10 @@
import { hash } from 'bcryptjs'; import { hash } from 'bcryptjs';
import { randomString } from 'n8n-workflow';
import Container from 'typedi'; import Container from 'typedi';
import { AuthIdentity } from '@/databases/entities/auth-identity'; import { AuthIdentity } from '@/databases/entities/auth-identity';
import { type GlobalRole, type User } from '@/databases/entities/user'; 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 { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
@ -79,19 +81,38 @@ export async function createUserWithMfaEnabled(
}; };
} }
export async function createOwner({ withApiKey } = { withApiKey: false }) { const createApiKeyEntity = (user: User) => {
if (withApiKey) { const apiKey = randomApiKey();
return await addApiKey(await createUser({ role: 'global:owner' })); 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' }); return await createUser({ role: 'global:owner' });
} }
export async function createMember({ withApiKey } = { withApiKey: false }) { export async function createMember() {
if (withApiKey) {
return await addApiKey(await createUser({ role: 'global:member' }));
}
return await createUser({ role: 'global:member' }); return await createUser({ role: 'global:member' });
} }
@ -128,11 +149,6 @@ export async function createManyUsers(
return result.map((result) => result.user); return result.map((result) => result.user);
} }
export async function addApiKey(user: User): Promise<User> {
user.apiKey = randomApiKey();
return await Container.get(UserRepository).save(user);
}
export const getAllUsers = async () => export const getAllUsers = async () =>
await Container.get(UserRepository).find({ await Container.get(UserRepository).find({
relations: ['authIdentities'], relations: ['authIdentities'],

View file

@ -80,6 +80,7 @@ const repositories = [
'WorkflowHistory', 'WorkflowHistory',
'WorkflowStatistics', 'WorkflowStatistics',
'WorkflowTagMapping', 'WorkflowTagMapping',
'ApiKey',
] as const; ] as const;
/** /**
@ -87,9 +88,18 @@ const repositories = [
*/ */
export async function truncate(names: Array<(typeof repositories)[number]>) { export async function truncate(names: Array<(typeof repositories)[number]>) {
for (const name of names) { for (const name of names) {
const RepositoryClass: Class<Repository<object>> = let RepositoryClass: Class<Repository<object>>;
// eslint-disable-next-line n8n-local-rules/no-dynamic-import-template
(await import(`@/databases/repositories/${kebabCase(name)}.repository`))[`${name}Repository`]; 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({}); await Container.get(RepositoryClass).delete({});
} }
} }

View file

@ -55,6 +55,8 @@ export interface TestServer {
httpServer: Server; httpServer: Server;
authAgentFor: (user: User) => TestAgent; authAgentFor: (user: User) => TestAgent;
publicApiAgentFor: (user: User) => TestAgent; publicApiAgentFor: (user: User) => TestAgent;
publicApiAgentWithApiKey: (apiKey: string) => TestAgent;
publicApiAgentWithoutApiKey: () => TestAgent;
authlessAgent: TestAgent; authlessAgent: TestAgent;
restlessAgent: TestAgent; restlessAgent: TestAgent;
license: LicenseMocker; license: LicenseMocker;

View file

@ -62,17 +62,30 @@ function createAgent(
return agent; return agent;
} }
function publicApiAgent( const userDoesNotHaveApiKey = (user: User) => {
return !user.apiKeys || !Array.from(user.apiKeys) || user.apiKeys.length === 0;
};
const publicApiAgent = (
app: express.Application, 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); const agent = request.agent(app);
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`)); void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
if (user.apiKey) { if (!user && !apiKey) return agent;
void agent.set({ 'X-N8N-API-KEY': user.apiKey }); void agent.set({ 'X-N8N-API-KEY': agentApiKey });
}
return agent; return agent;
} };
export const setupTestServer = ({ export const setupTestServer = ({
endpointGroups, endpointGroups,
@ -100,6 +113,8 @@ export const setupTestServer = ({
authlessAgent: createAgent(app), authlessAgent: createAgent(app),
restlessAgent: createAgent(app, { auth: false, noRest: true }), restlessAgent: createAgent(app, { auth: false, noRest: true }),
publicApiAgentFor: (user) => publicApiAgent(app, { user }), publicApiAgentFor: (user) => publicApiAgent(app, { user }),
publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }),
publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}),
license: new LicenseMocker(), license: new LicenseMocker(),
}; };
@ -140,7 +155,7 @@ export const setupTestServer = ({
for (const group of endpointGroups) { for (const group of endpointGroups) {
switch (group) { switch (group) {
case 'annotationTags': case 'annotationTags':
await import('@/controllers/annotation-tags.controller'); await import('@/controllers/annotation-tags.controller.ee');
break; break;
case 'credentials': case 'credentials':

View file

@ -30,18 +30,6 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
const loader = new PackageDirectoryLoader(packageDir); const loader = new PackageDirectoryLoader(packageDir);
await loader.loadAll(); 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 loaderNodeTypes = Object.values(loader.nodeTypes);
const definedMethods = loaderNodeTypes.reduce((acc, cur) => { 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); const referencedMethods = findReferencedMethods(nodeTypes);
await Promise.all([ await Promise.all([

View file

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

View file

@ -1,5 +1,6 @@
import glob from 'fast-glob'; import glob from 'fast-glob';
import { readFile } from 'fs/promises'; import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import type { import type {
CodexData, CodexData,
DocumentationLink, DocumentationLink,
@ -350,18 +351,11 @@ export class CustomDirectoryLoader extends DirectoryLoader {
* e.g. /nodes-base or community packages. * e.g. /nodes-base or community packages.
*/ */
export class PackageDirectoryLoader extends DirectoryLoader { export class PackageDirectoryLoader extends DirectoryLoader {
packageName = ''; packageJson: n8n.PackageJson = this.readJSONSync('package.json');
packageJson!: n8n.PackageJson; packageName = this.packageJson.name;
async readPackageJson() {
this.packageJson = await this.readJSON('package.json');
this.packageName = this.packageJson.name;
}
override async loadAll() { override async loadAll() {
await this.readPackageJson();
const { n8n } = this.packageJson; const { n8n } = this.packageJson;
if (!n8n) return; if (!n8n) return;
@ -391,6 +385,17 @@ export class PackageDirectoryLoader extends DirectoryLoader {
}); });
} }
protected readJSONSync<T>(file: string): T {
const filePath = this.resolvePath(file);
const fileString = readFileSync(filePath, 'utf8');
try {
return jsonParse<T>(fileString);
} catch (error) {
throw new ApplicationError('Failed to parse JSON', { extra: { filePath } });
}
}
protected async readJSON<T>(file: string): Promise<T> { protected async readJSON<T>(file: string): Promise<T> {
const filePath = this.resolvePath(file); const filePath = this.resolvePath(file);
const fileString = await readFile(filePath, 'utf8'); const fileString = await readFile(filePath, 'utf8');
@ -408,8 +413,6 @@ export class PackageDirectoryLoader extends DirectoryLoader {
*/ */
export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
override async loadAll() { override async loadAll() {
await this.readPackageJson();
try { try {
const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json'); const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json');
for (const nodeName in knownNodes) { for (const nodeName in knownNodes) {

View file

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

View file

@ -136,7 +136,7 @@ const htmlContent = computed(() => {
}); });
const emit = defineEmits<{ const emit = defineEmits<{
'markdown-click': [link: string, e: MouseEvent]; 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent];
'update-content': [content: string]; 'update-content': [content: string];
}>(); }>();
@ -154,7 +154,7 @@ const onClick = (event: MouseEvent) => {
} }
} }
if (clickedLink) { if (clickedLink) {
emit('markdown-click', clickedLink?.href, event); emit('markdown-click', clickedLink, event);
} }
}; };

View file

@ -21,6 +21,7 @@ const emit = defineEmits<{
resize: [values: ResizeData]; resize: [values: ResizeData];
resizestart: []; resizestart: [];
resizeend: []; resizeend: [];
'markdown-click': [link: HTMLAnchorElement, e: MouseEvent];
}>(); }>();
const attrs = useAttrs(); const attrs = useAttrs();
@ -42,6 +43,10 @@ const onResizeEnd = () => {
isResizing.value = false; isResizing.value = false;
emit('resizeend'); emit('resizeend');
}; };
const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => {
emit('markdown-click', link, event);
};
</script> </script>
<template> <template>
@ -57,6 +62,6 @@ const onResizeEnd = () => {
@resize="onResize" @resize="onResize"
@resizestart="onResizeStart" @resizestart="onResizeStart"
> >
<N8nSticky v-bind="stickyBindings" /> <N8nSticky v-bind="stickyBindings" @markdown-click="onMarkdownClick" />
</N8nResizeWrapper> </N8nResizeWrapper>
</template> </template>

View file

@ -13,7 +13,7 @@ const props = withDefaults(defineProps<StickyProps>(), defaultStickyProps);
const emit = defineEmits<{ const emit = defineEmits<{
edit: [editing: boolean]; edit: [editing: boolean];
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
'markdown-click': [link: string, e: Event]; 'markdown-click': [link: HTMLAnchorElement, e: MouseEvent];
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
@ -63,7 +63,7 @@ const onUpdateModelValue = (value: string) => {
emit('update:modelValue', value); emit('update:modelValue', value);
}; };
const onMarkdownClick = (link: string, event: Event) => { const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => {
emit('markdown-click', link, event); emit('markdown-click', link, event);
}; };

View file

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

View file

@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterpr
export interface IN8nPromptResponse { export interface IN8nPromptResponse {
updated: boolean; updated: boolean;
} }
export type ApiKey = {
id: string;
label: string;
apiKey: string;
createdAt: string;
updatedAt: string;
};

View file

@ -1,14 +1,17 @@
import type { IRestApiContext } from '@/Interface'; import type { ApiKey, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
export async function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/me/api-key'); return await makeRestApiRequest(context, 'GET', '/me/api-keys');
} }
export async function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> { export async function createApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'POST', '/me/api-key'); return await makeRestApiRequest(context, 'POST', '/me/api-keys');
} }
export async function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> { export async function deleteApiKey(
return await makeRestApiRequest(context, 'DELETE', '/me/api-key'); context: IRestApiContext,
id: string,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/me/api-keys/${id}`);
} }

View file

@ -10,7 +10,7 @@ import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { format } from 'prettier'; import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel'; import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree'; 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 { CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus'; import { codeNodeEditorEventBus } from '@/event-bus';
@ -26,6 +26,7 @@ import { useLinter } from './linter';
import { codeNodeEditorTheme } from './theme'; import { codeNodeEditorTheme } from './theme';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
mode: CodeExecutionMode; mode: CodeExecutionMode;
@ -51,6 +52,7 @@ const emit = defineEmits<{
const message = useMessage(); const message = useMessage();
const editor = ref(null) as Ref<EditorView | null>; const editor = ref(null) as Ref<EditorView | null>;
const languageCompartment = ref(new Compartment()); const languageCompartment = ref(new Compartment());
const dragAndDropCompartment = ref(new Compartment());
const linterCompartment = ref(new Compartment()); const linterCompartment = ref(new Compartment());
const isEditorHovered = ref(false); const isEditorHovered = ref(false);
const isEditorFocused = ref(false); const isEditorFocused = ref(false);
@ -95,6 +97,7 @@ onMounted(() => {
extensions.push( extensions.push(
...writableEditorExtensions, ...writableEditorExtensions,
dragAndDropCompartment.value.of(dragAndDropExtension.value),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
isEditorFocused.value = true; isEditorFocused.value = true;
@ -151,6 +154,12 @@ const placeholder = computed(() => {
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? ''; 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 // eslint-disable-next-line vue/return-in-computed-property
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => { const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
switch (props.language) { switch (props.language) {
@ -188,6 +197,12 @@ watch(
}, },
); );
watch(dragAndDropExtension, (extension) => {
editor.value?.dispatch({
effects: dragAndDropCompartment.value.reconfigure(extension),
});
});
watch( watch(
() => props.language, () => props.language,
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => { (_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
@ -202,7 +217,6 @@ watch(
reloadLinter(); reloadLinter();
}, },
); );
watch( watch(
aiEnabled, aiEnabled,
async (isEnabled) => { async (isEnabled) => {
@ -361,6 +375,12 @@ function onAiLoadStart() {
function onAiLoadEnd() { function onAiLoadEnd() {
isLoadingAIResponse.value = false; isLoadingAIResponse.value = false;
} }
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInCodeEditor(toRaw(editor.value), event, value);
}
</script> </script>
<template> <template>
@ -384,10 +404,20 @@ function onAiLoadEnd() {
data-test-id="code-node-tab-code" data-test-id="code-node-tab-code"
:class="$style.fillHeight" :class="$style.fillHeight"
> >
<div <DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
ref="codeNodeEditorRef" <template #default="{ activeDrop, droppable }">
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput, $style.fillHeight]" <div
/> ref="codeNodeEditorRef"
:class="[
'ph-no-capture',
'code-editor-tabs',
$style.editorInput,
$style.fillHeight,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
/>
</template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane <el-tab-pane
@ -407,7 +437,19 @@ function onAiLoadEnd() {
</el-tabs> </el-tabs>
<!-- If AskAi not enabled, there's no point in rendering tabs --> <!-- If AskAi not enabled, there's no point in rendering tabs -->
<div v-else :class="$style.fillHeight"> <div v-else :class="$style.fillHeight">
<div ref="codeNodeEditorRef" :class="['ph-no-capture', $style.fillHeight]" /> <DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="codeNodeEditorRef"
:class="[
'ph-no-capture',
$style.fillHeight,
$style.editorInput,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
/>
</template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
</div> </div>
</div> </div>
@ -415,7 +457,7 @@ function onAiLoadEnd() {
<style scoped lang="scss"> <style scoped lang="scss">
:deep(.el-tabs) { :deep(.el-tabs) {
.code-editor-tabs .cm-editor { .cm-editor {
border: 0; border: 0;
} }
} }
@ -454,4 +496,21 @@ function onAiLoadEnd() {
.fillHeight { .fillHeight {
height: 100%; height: 100%;
} }
.editorInput.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.editorInput.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style> </style>

View file

@ -1,50 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useRootStore } from '@/stores/root.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ICredentialType } from 'n8n-workflow'; import { useRootStore } from '@/stores/root.store';
import NodeIcon from '@/components/NodeIcon.vue';
import { getThemedValue } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { getThemedValue } from '@/utils/nodeTypesUtils';
import { N8nNodeIcon } from 'n8n-design-system';
import type { ICredentialType } from 'n8n-workflow';
import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
credentialTypeName: string | null; credentialTypeName: string | null;
}>(); }>();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName)); const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName));
const filePath = computed(() => { const nodeBasedIconUrl = computed(() => {
const themeIconUrl = getThemedValue(credentialWithIcon.value?.iconUrl, uiStore.appliedTheme); const icon = getThemedValue(credentialWithIcon.value?.icon);
if (!icon?.startsWith('node:')) return null;
return nodeTypesStore.getNodeType(icon.replace('node:', ''))?.iconUrl;
});
const iconSource = computed(() => {
const themeIconUrl = getThemedValue(
nodeBasedIconUrl.value ?? credentialWithIcon.value?.iconUrl,
uiStore.appliedTheme,
);
if (!themeIconUrl) { if (!themeIconUrl) {
return null; return undefined;
} }
return rootStore.baseUrl + themeIconUrl; return rootStore.baseUrl + themeIconUrl;
}); });
const relevantNode = computed(() => { const iconType = computed(() => {
const icon = credentialWithIcon.value?.icon; if (iconSource.value) return 'file';
if (typeof icon === 'string' && icon.startsWith('node:')) { else if (iconName.value) return 'icon';
const nodeType = icon.replace('node:', ''); return 'unknown';
return nodeTypesStore.getNodeType(nodeType); });
}
if (!props.credentialTypeName) {
return null;
}
const nodesWithAccess = credentialsStore.getNodesWithAccess(props.credentialTypeName); const iconName = computed(() => {
if (nodesWithAccess.length) { const icon = getThemedValue(credentialWithIcon.value?.icon, uiStore.appliedTheme);
return nodesWithAccess[0]; if (!icon || !icon?.startsWith('fa:')) return undefined;
} return icon.replace('fa:', '');
});
return null; const iconColor = computed(() => {
const { iconColor: color } = credentialWithIcon.value ?? {};
if (!color) return undefined;
return `var(--color-node-icon-${color})`;
}); });
function getCredentialWithIcon(name: string | null): ICredentialType | null { function getCredentialWithIcon(name: string | null): ICredentialType | null {
@ -64,8 +73,8 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null {
if (type.extends) { if (type.extends) {
let parentCred = null; let parentCred = null;
type.extends.forEach((iconName) => { type.extends.forEach((credType) => {
parentCred = getCredentialWithIcon(iconName); parentCred = getCredentialWithIcon(credType);
if (parentCred !== null) return; if (parentCred !== null) return;
}); });
return parentCred; return parentCred;
@ -76,23 +85,18 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null {
</script> </script>
<template> <template>
<div> <N8nNodeIcon
<img v-if="filePath" :class="$style.credIcon" :src="filePath" /> :class="$style.icon"
<NodeIcon v-else-if="relevantNode" :node-type="relevantNode" :size="28" /> :type="iconType"
<span v-else :class="$style.fallback"></span> :size="26"
</div> :src="iconSource"
:name="iconName"
:color="iconColor"
/>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.credIcon { .icon {
height: 26px; --node-icon-color: var(--color-foreground-dark);
}
.fallback {
height: 28px;
width: 28px;
display: flex;
border-radius: 50%;
background-color: var(--color-foreground-base);
} }
</style> </style>

View file

@ -19,7 +19,7 @@ import OutputItemSelect from './InlineExpressionEditor/OutputItemSelect.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import DraggableTarget from './DraggableTarget.vue'; import DraggableTarget from './DraggableTarget.vue';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop'; import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
import { APP_MODALS_ELEMENT_ID } from '@/constants'; import { APP_MODALS_ELEMENT_ID } from '@/constants';
@ -119,7 +119,7 @@ function closeDialog() {
async function onDrop(expression: string, event: MouseEvent) { async function onDrop(expression: string, event: MouseEvent) {
if (!inputEditor.value) return; if (!inputEditor.value) return;
await dropInEditor(toRaw(inputEditor.value), event, expression); await dropInExpressionEditor(toRaw(inputEditor.value), event, expression);
} }
</script> </script>

View file

@ -10,7 +10,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop'; import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
import type { Segment } from '@/types/expressions'; import type { Segment } from '@/types/expressions';
import { startCompletion } from '@codemirror/autocomplete'; import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state'; import type { EditorState, SelectionRange } from '@codemirror/state';
@ -119,7 +119,9 @@ async function onDrop(value: string, event: MouseEvent) {
if (!editor) return; if (!editor) return;
const droppedSelection = await dropInEditor(toRaw(editor), event, value); const droppedSelection = await dropInExpressionEditor(toRaw(editor), event, value);
if (!ndvStore.isMappingOnboarded) ndvStore.setMappingOnboarded();
if (!ndvStore.isAutocompleteOnboarded) { if (!ndvStore.isAutocompleteOnboarded) {
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3); setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);

View file

@ -90,8 +90,8 @@ const allIssues = computed(() => {
const now = computed(() => DateTime.now().toISO()); const now = computed(() => DateTime.now().toISO());
const leftParameter = computed<INodeProperties>(() => ({ const leftParameter = computed<INodeProperties>(() => ({
name: '', name: 'left',
displayName: '', displayName: 'Left',
default: '', default: '',
placeholder: placeholder:
operator.value.type === 'dateTime' operator.value.type === 'dateTime'
@ -103,8 +103,8 @@ const leftParameter = computed<INodeProperties>(() => ({
const rightParameter = computed<INodeProperties>(() => { const rightParameter = computed<INodeProperties>(() => {
const type = operator.value.rightType ?? operator.value.type; const type = operator.value.rightType ?? operator.value.type;
return { return {
name: '', name: 'right',
displayName: '', displayName: 'Right',
default: '', default: '',
placeholder: placeholder:
type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'), type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'),

View file

@ -20,7 +20,7 @@ import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree'; import * as estree from 'prettier/plugins/estree';
import htmlParser from 'prettier/plugins/html'; import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss'; import cssParser from 'prettier/plugins/postcss';
import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
import { htmlEditorEventBus } from '@/event-bus'; import { htmlEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { useExpressionEditor } from '@/composables/useExpressionEditor';
@ -37,6 +37,7 @@ import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types'; import type { Range, Section } from './types';
import { nonTakenRanges } from './utils'; import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
modelValue: string; modelValue: string;
@ -84,6 +85,7 @@ const extensions = computed(() => [
dropCursor(), dropCursor(),
indentOnInput(), indentOnInput(),
highlightActiveLine(), highlightActiveLine(),
mappingDropCursor(),
]); ]);
const { const {
editor: editorRef, editor: editorRef,
@ -238,11 +240,25 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
htmlEditorEventBus.off('format-html', formatHtml); htmlEditorEventBus.off('format-html', formatHtml);
}); });
async function onDrop(value: string, event: MouseEvent) {
if (!editorRef.value) return;
await dropInExpressionEditor(toRaw(editorRef.value), event, value);
}
</script> </script>
<template> <template>
<div :class="$style.editor"> <div :class="$style.editor">
<div ref="htmlEditor" data-test-id="html-editor-container"></div> <DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="htmlEditor"
:class="{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable }"
data-test-id="html-editor-container"
></div
></template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
</div> </div>
</template> </template>
@ -255,4 +271,21 @@ onBeforeUnmount(() => {
height: 100%; height: 100%;
} }
} }
.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style> </style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { computed, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state'; import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
import { type Completion, CompletionContext } from '@codemirror/autocomplete'; import { type Completion, CompletionContext } from '@codemirror/autocomplete';
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions'; import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
@ -75,10 +75,18 @@ function getCompletionsWithDot(): readonly Completion[] {
return completionResult?.options ?? []; return completionResult?.options ?? [];
} }
watch(tip, (newTip) => { onBeforeUnmount(() => {
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag'); ndvStore.setHighlightDraggables(false);
}); });
watch(
tip,
(newTip) => {
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
},
{ immediate: true },
);
watchDebounced( watchDebounced(
[() => props.selection, () => props.unresolvedExpression], [() => props.selection, () => props.unresolvedExpression],
() => { () => {

View file

@ -27,6 +27,7 @@ describe('InlineExpressionTip.vue', () => {
mockNdvState = { mockNdvState = {
hasInputData: true, hasInputData: true,
isNDVDataEmpty: vi.fn(() => true), isNDVDataEmpty: vi.fn(() => true),
setHighlightDraggables: vi.fn(),
}; };
}); });
@ -43,11 +44,16 @@ describe('InlineExpressionTip.vue', () => {
hasInputData: true, hasInputData: true,
isNDVDataEmpty: vi.fn(() => false), isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input', focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
}; };
const { container } = renderComponent(InlineExpressionTip, { const { container, unmount } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(), pinia: createTestingPinia(),
}); });
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.'); expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
unmount();
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(false);
}); });
}); });
@ -58,6 +64,7 @@ describe('InlineExpressionTip.vue', () => {
isInputParentOfActiveNode: true, isInputParentOfActiveNode: true,
isNDVDataEmpty: vi.fn(() => false), isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input', focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
}; };
const { container } = renderComponent(InlineExpressionTip, { const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(), pinia: createTestingPinia(),

View file

@ -24,6 +24,7 @@ import {
} from '@/plugins/codemirror/keymap'; } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
modelValue: string; modelValue: string;
@ -69,6 +70,7 @@ const extensions = computed(() => {
foldGutter(), foldGutter(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
mappingDropCursor(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => { EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged || !editor.value) return; if (!viewUpdate.docChanged || !editor.value) return;
emit('update:modelValue', editor.value?.state.doc.toString()); emit('update:modelValue', editor.value?.state.doc.toString());

View file

@ -48,7 +48,7 @@ import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
import ModalRoot from '@/components/ModalRoot.vue'; import ModalRoot from '@/components/ModalRoot.vue';
import PersonalizationModal from '@/components/PersonalizationModal.vue'; import PersonalizationModal from '@/components/PersonalizationModal.vue';
import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue'; import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.vue'; import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
import UpdatesPanel from '@/components/UpdatesPanel.vue'; import UpdatesPanel from '@/components/UpdatesPanel.vue';
import NpsSurvey from '@/components/NpsSurvey.vue'; import NpsSurvey from '@/components/NpsSurvey.vue';
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue'; import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';

View file

@ -60,6 +60,7 @@ function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: stri
categories: [category], categories: [category],
}, },
iconUrl: nodeTypeDescription.iconUrl, iconUrl: nodeTypeDescription.iconUrl,
iconColor: nodeTypeDescription.iconColor,
outputs: nodeTypeDescription.outputs, outputs: nodeTypeDescription.outputs,
icon: nodeTypeDescription.icon, icon: nodeTypeDescription.icon,
defaults: nodeTypeDescription.defaults, defaults: nodeTypeDescription.defaults,

View file

@ -177,6 +177,15 @@ const displayValue = computed(() => {
if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) { if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) {
return i18n.baseText('parameterInput.loadOptionsError'); return i18n.baseText('parameterInput.loadOptionsError');
} }
if (nodeType.value?.credentials && nodeType.value?.credentials?.length > 0) {
const credentialsType = nodeType.value?.credentials[0];
if (credentialsType.required && !node.value?.credentials) {
return i18n.baseText('parameterInput.loadOptionsCredentialsRequired');
}
}
return i18n.baseText('parameterInput.loadOptionsErrorService', { return i18n.baseText('parameterInput.loadOptionsErrorService', {
interpolate: { service: nodeType.value.displayName }, interpolate: { service: nodeType.value.displayName },
}); });
@ -510,6 +519,28 @@ const isCodeNode = computed(
const isHtmlNode = computed(() => !!node.value && node.value.type === HTML_NODE_TYPE); const isHtmlNode = computed(() => !!node.value && node.value.type === HTML_NODE_TYPE);
const isInputTypeString = computed(() => props.parameter.type === 'string');
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
const isDropDisabled = computed(
() =>
props.parameter.noDataExpression ||
props.isReadOnly ||
isResourceLocatorParameter.value ||
isModelValueExpression.value,
);
const showDragnDropTip = computed(
() =>
isFocused.value &&
(isInputTypeString.value || isInputTypeNumber.value) &&
!isModelValueExpression.value &&
!isDropDisabled.value &&
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
!ndvStore.isMappingOnboarded &&
ndvStore.isInputParentOfActiveNode,
);
function isRemoteParameterOption(option: INodePropertyOptions) { function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name); return remoteParameterOptionsKeys.value.includes(option.name);
} }
@ -965,7 +996,11 @@ onUpdated(async () => {
</script> </script>
<template> <template>
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop> <div
ref="wrapper"
:class="[parameterInputClasses, { [$style.tipVisible]: showDragnDropTip }]"
@keydown.stop
>
<ExpressionEditModal <ExpressionEditModal
:dialog-visible="expressionEditDialogVisible" :dialog-visible="expressionEditDialogVisible"
:model-value="modelValueExpressionEdit" :model-value="modelValueExpressionEdit"
@ -1249,6 +1284,7 @@ onUpdated(async () => {
" "
:title="displayTitle" :title="displayTitle"
:placeholder="getPlaceholder()" :placeholder="getPlaceholder()"
data-test-id="parameter-input-field"
@update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)" @update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)"
@keydown.stop @keydown.stop
@focus="setFocus" @focus="setFocus"
@ -1447,6 +1483,9 @@ onUpdated(async () => {
:disabled="isReadOnly" :disabled="isReadOnly"
@update:model-value="valueChanged" @update:model-value="valueChanged"
/> />
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
</div> </div>
<ParameterIssues <ParameterIssues
@ -1477,6 +1516,7 @@ onUpdated(async () => {
.parameter-input { .parameter-input {
display: inline-block; display: inline-block;
position: relative;
:deep(.color-input) { :deep(.color-input) {
display: flex; display: flex;
@ -1609,3 +1649,23 @@ onUpdated(async () => {
} }
} }
</style> </style>
<style lang="scss" module>
.tipVisible {
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.tip {
position: absolute;
z-index: 2;
top: 100%;
background: var(--color-code-background);
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
</style>

View file

@ -13,7 +13,6 @@ import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/util
import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isResourceLocatorValue } from '@/utils/typeGuards';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow'; import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
type Props = { type Props = {
parameter: INodeProperties; parameter: INodeProperties;
@ -57,8 +56,7 @@ const ndvStore = useNDVStore();
const node = computed(() => ndvStore.activeNode); const node = computed(() => ndvStore.activeNode);
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path)); const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
const isInputTypeString = computed(() => props.parameter.type === 'string');
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isResourceLocator = computed( const isResourceLocator = computed(
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector', () => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
); );
@ -73,17 +71,6 @@ const isExpression = computed(() => isValueExpression(props.parameter, props.val
const showExpressionSelector = computed(() => const showExpressionSelector = computed(() =>
isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true, isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true,
); );
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
const showDragnDropTip = computed(
() =>
focused.value &&
(isInputTypeString.value || isInputTypeNumber.value) &&
!isExpression.value &&
!isDropDisabled.value &&
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
!ndvStore.isMappingOnboarded &&
ndvStore.isInputParentOfActiveNode,
);
function onFocus() { function onFocus() {
focused.value = true; focused.value = true;
@ -205,7 +192,7 @@ function onDrop(newParamValue: string) {
<template> <template>
<n8n-input-label <n8n-input-label
:class="[$style.wrapper, { [$style.tipVisible]: showDragnDropTip }]" :class="[$style.wrapper]"
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)" :label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)" :tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
:show-tooltip="focused" :show-tooltip="focused"
@ -258,9 +245,6 @@ function onDrop(newParamValue: string) {
/> />
</template> </template>
</DraggableTarget> </DraggableTarget>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
<div <div
:class="{ :class="{
[$style.options]: true, [$style.options]: true,
@ -292,24 +276,6 @@ function onDrop(newParamValue: string) {
} }
} }
.tipVisible {
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.tip {
position: absolute;
z-index: 2;
top: 100%;
background: var(--color-code-background);
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.options { .options {
position: absolute; position: absolute;
bottom: -22px; bottom: -22px;

View file

@ -34,8 +34,9 @@ import {
StandardSQL, StandardSQL,
keywordCompletionSource, keywordCompletionSource,
} from '@n8n/codemirror-lang-sql'; } from '@n8n/codemirror-lang-sql';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
const SQL_DIALECTS = { const SQL_DIALECTS = {
StandardSQL, StandardSQL,
@ -111,6 +112,7 @@ const extensions = computed(() => {
foldGutter(), foldGutter(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
mappingDropCursor(),
]); ]);
} }
return baseExtensions; return baseExtensions;
@ -178,11 +180,28 @@ function highlightLine(lineNumber: number | 'final') {
selection: { anchor: lineToHighlight.from }, selection: { anchor: lineToHighlight.from },
}); });
} }
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInExpressionEditor(toRaw(editor.value), event, value);
}
</script> </script>
<template> <template>
<div :class="$style.sqlEditor"> <div :class="$style.sqlEditor">
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div> <DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="sqlEditor"
:class="[
$style.codemirror,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
data-test-id="sql-editor-container"
></div>
</template>
</DraggableTarget>
<slot name="suffix" /> <slot name="suffix" />
<InlineExpressionEditorOutput <InlineExpressionEditorOutput
v-if="!fullscreen" v-if="!fullscreen"
@ -202,4 +221,21 @@ function highlightLine(lineNumber: number | 'final') {
.codemirror { .codemirror {
height: 100%; height: 100%;
} }
.codemirror.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.codemirror.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style> </style>

View file

@ -1,19 +1,12 @@
<script lang="ts"> <script setup lang="ts">
import { defineComponent, ref } from 'vue'; import { ref, computed, onMounted, nextTick } from 'vue';
import type { PropType, StyleValue } from 'vue'; import type { StyleValue } from 'vue';
import { mapStores } from 'pinia';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import type { Workflow } from 'n8n-workflow';
import { isNumber, isString } from '@/utils/typeGuards'; import { isNumber, isString } from '@/utils/typeGuards';
import type { import type { INodeUi, XYPosition } from '@/Interface';
INodeUi,
INodeUpdatePropertiesInformation,
IUpdateInformation,
XYPosition,
} from '@/Interface';
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
import { QUICKSTART_NOTE_NAME } from '@/constants'; import { QUICKSTART_NOTE_NAME } from '@/constants';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@ -25,313 +18,280 @@ import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { assert } from '@/utils/assert'; import { assert } from '@/utils/assert';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui'; import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { useCanvasStore } from '@/stores/canvas.store';
import { useHistoryStore } from '@/stores/history.store';
import { useNodeBase } from '@/composables/useNodeBase'; import { useNodeBase } from '@/composables/useNodeBase';
import { useTelemetry } from '@/composables/useTelemetry';
export default defineComponent({ const props = withDefaults(
name: 'Sticky', defineProps<{
props: { nodeViewScale?: number;
nodeViewScale: { gridSize?: number;
type: Number, name: string;
default: 1, instance: BrowserJsPlumbInstance;
}, isReadOnly?: boolean;
gridSize: { isActive?: boolean;
type: Number, hideActions?: boolean;
default: GRID_SIZE, disableSelecting?: boolean;
}, showCustomTooltip?: boolean;
name: { workflow: Workflow;
type: String, }>(),
required: true, {
}, nodeViewScale: 1,
instance: { gridSize: GRID_SIZE,
type: Object as PropType<BrowserJsPlumbInstance>,
required: true,
},
isReadOnly: {
type: Boolean,
},
isActive: {
type: Boolean,
},
hideActions: {
type: Boolean,
},
disableSelecting: {
type: Boolean,
},
showCustomTooltip: {
type: Boolean,
},
workflow: {
type: Object as PropType<Workflow>,
required: true,
},
}, },
emits: { removeNode: null, nodeSelected: null }, );
setup(props, { emit }) {
const deviceSupport = useDeviceSupport();
const toast = useToast();
const forceActions = ref(false);
const isColorPopoverVisible = ref(false);
const stickOptions = ref<HTMLElement>(); defineOptions({ name: 'Sticky' });
const setForceActions = (value: boolean) => { const emit = defineEmits<{
forceActions.value = value; removeNode: [string];
}; nodeSelected: [string, boolean, boolean];
const setColorPopoverVisible = (value: boolean) => { }>();
isColorPopoverVisible.value = value;
};
const contextMenu = useContextMenu((action) => { const deviceSupport = useDeviceSupport();
if (action === 'change_color') { const telemetry = useTelemetry();
setForceActions(true); const toast = useToast();
setColorPopoverVisible(true); const ndvStore = useNDVStore();
} const nodeTypesStore = useNodeTypesStore();
}); const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const nodeBase = useNodeBase({ const isResizing = ref<boolean>(false);
name: props.name, const isTouchActive = ref<boolean>(false);
instance: props.instance, const forceActions = ref(false);
workflowObject: props.workflow, const isColorPopoverVisible = ref(false);
isReadOnly: props.isReadOnly, const stickOptions = ref<HTMLElement>();
emit: emit as (event: string, ...args: unknown[]) => void,
});
onClickOutside(stickOptions, () => setColorPopoverVisible(false)); const setForceActions = (value: boolean) => {
forceActions.value = value;
};
return { const setColorPopoverVisible = (value: boolean) => {
deviceSupport, isColorPopoverVisible.value = value;
toast, };
contextMenu,
forceActions,
...nodeBase,
setForceActions,
isColorPopoverVisible,
setColorPopoverVisible,
stickOptions,
};
},
data() {
return {
isResizing: false,
isTouchActive: false,
};
},
computed: {
...mapStores(
useNodeTypesStore,
useUIStore,
useNDVStore,
useCanvasStore,
useWorkflowsStore,
useHistoryStore,
),
data(): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
},
nodeId(): string {
return this.data?.id || '';
},
defaultText(): string {
if (!this.nodeType) {
return '';
}
const properties = this.nodeType.properties;
const content = properties.find((property) => property.name === 'content');
return content && isString(content.default) ? content.default : ''; const contextMenu = useContextMenu((action) => {
}, if (action === 'change_color') {
isSelected(): boolean { setForceActions(true);
return ( setColorPopoverVisible(true);
this.uiStore.getSelectedNodes.find((node: INodeUi) => node.name === this.data?.name) !== }
undefined
);
},
nodeType(): INodeTypeDescription | null {
return this.data && this.nodeTypesStore.getNodeType(this.data.type, this.data.typeVersion);
},
node(): INodeUi | null {
// same as this.data but reactive..
return this.workflowsStore.getNodeByName(this.name);
},
position(): XYPosition {
if (this.node) {
return this.node.position;
} else {
return [0, 0];
}
},
height(): number {
return this.node && isNumber(this.node.parameters.height) ? this.node.parameters.height : 0;
},
width(): number {
return this.node && isNumber(this.node.parameters.width) ? this.node.parameters.width : 0;
},
stickySize(): StyleValue {
const returnStyles: {
[key: string]: string | number;
} = {
height: this.height + 'px',
width: this.width + 'px',
};
return returnStyles;
},
stickyPosition(): StyleValue {
const returnStyles: {
[key: string]: string | number;
} = {
left: this.position[0] + 'px',
top: this.position[1] + 'px',
zIndex: this.isActive ? 9999999 : -1 * Math.floor((this.height * this.width) / 1000),
};
return returnStyles;
},
showActions(): boolean {
return (
!(this.hideActions || this.isReadOnly || this.workflowRunning || this.isResizing) ||
this.forceActions
);
},
workflowRunning(): boolean {
return this.uiStore.isActionActive['workflowRunning'];
},
},
mounted() {
// Initialize the node
if (this.data !== null) {
try {
this.addNode(this.data);
} catch (error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
},
methods: {
onShowPopover() {
this.setForceActions(true);
},
onHidePopover() {
this.setForceActions(false);
},
async deleteNode() {
assert(this.data);
// Wait a tick else vue causes problems because the data is gone
await this.$nextTick();
this.$emit('removeNode', this.data.name);
},
changeColor(index: number) {
this.workflowsStore.updateNodeProperties({
name: this.name,
properties: {
parameters: {
...this.node?.parameters,
color: index,
},
position: this.node?.position ?? [0, 0],
},
});
},
onEdit(edit: boolean) {
if (edit && !this.isActive && this.node) {
this.ndvStore.activeNodeName = this.node.name;
} else if (this.isActive && !edit) {
this.ndvStore.activeNodeName = null;
}
},
onMarkdownClick(link: HTMLAnchorElement) {
if (link) {
const isOnboardingNote = this.name === QUICKSTART_NOTE_NAME;
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
const type =
isOnboardingNote && isWelcomeVideo
? 'welcome_video'
: isOnboardingNote && link.getAttribute('href') === '/templates'
? 'templates'
: 'other';
this.$telemetry.track('User clicked note link', { type });
}
},
onInputChange(content: string) {
if (!this.node) {
return;
}
this.node.parameters.content = content;
this.setParameters({ content });
},
onResizeStart() {
this.isResizing = true;
if (!this.isSelected && this.node) {
this.$emit('nodeSelected', this.node.name, false, true);
}
},
onResize({ height, width, dX, dY }: { width: number; height: number; dX: number; dY: number }) {
if (!this.node) {
return;
}
if (dX !== 0 || dY !== 0) {
this.setPosition([this.node.position[0] + (dX || 0), this.node.position[1] + (dY || 0)]);
}
this.setParameters({ height, width });
},
onResizeEnd() {
this.isResizing = false;
},
setParameters(params: { content?: string; height?: number; width?: number; color?: string }) {
if (this.node) {
const nodeParameters = {
content: isString(params.content) ? params.content : this.node.parameters.content,
height: isNumber(params.height) ? params.height : this.node.parameters.height,
width: isNumber(params.width) ? params.width : this.node.parameters.width,
color: isString(params.color) ? params.color : this.node.parameters.color,
};
const updateInformation: IUpdateInformation = {
key: this.node.id,
name: this.node.name,
value: nodeParameters,
};
this.workflowsStore.setNodeParameters(updateInformation);
}
},
setPosition(position: XYPosition) {
if (!this.node) {
return;
}
const updateInformation: INodeUpdatePropertiesInformation = {
name: this.node.name,
properties: {
position,
},
};
this.workflowsStore.updateNodeProperties(updateInformation);
},
touchStart() {
if (this.deviceSupport.isTouchDevice && !this.deviceSupport.isMacOs && !this.isTouchActive) {
this.isTouchActive = true;
setTimeout(() => {
this.isTouchActive = false;
}, 2000);
}
},
onContextMenu(e: MouseEvent): void {
if (this.node && !this.isActive) {
this.contextMenu.open(e, { source: 'node-right-click', nodeId: this.node.id });
} else {
e.stopPropagation();
}
},
},
}); });
const nodeBase = useNodeBase({
name: props.name,
instance: props.instance,
workflowObject: props.workflow,
isReadOnly: props.isReadOnly,
emit: emit as (event: string, ...args: unknown[]) => void,
});
onClickOutside(stickOptions, () => setColorPopoverVisible(false));
defineExpose({
deviceSupport,
toast,
contextMenu,
forceActions,
...nodeBase,
setForceActions,
isColorPopoverVisible,
setColorPopoverVisible,
stickOptions,
});
const data = computed(() => workflowsStore.getNodeByName(props.name));
// TODO: remove either node or data
const node = computed(() => workflowsStore.getNodeByName(props.name));
const nodeId = computed(() => data.value?.id);
const nodeType = computed(() => {
return data.value && nodeTypesStore.getNodeType(data.value.type, data.value.typeVersion);
});
const defaultText = computed(() => {
if (!nodeType.value) {
return '';
}
const properties = nodeType.value.properties;
const content = properties.find((property) => property.name === 'content');
return content && isString(content.default) ? content.default : '';
});
const isSelected = computed(
() =>
uiStore.getSelectedNodes.find(({ name }: INodeUi) => name === data.value?.name) !== undefined,
);
const position = computed<XYPosition>(() => (node.value ? node.value.position : [0, 0]));
const height = computed(() =>
node.value && isNumber(node.value.parameters.height) ? node.value.parameters.height : 0,
);
const width = computed(() =>
node.value && isNumber(node.value.parameters.width) ? node.value.parameters.width : 0,
);
const stickySize = computed<StyleValue>(() => ({
height: height.value + 'px',
width: width.value + 'px',
}));
const stickyPosition = computed<StyleValue>(() => ({
left: position.value[0] + 'px',
top: position.value[1] + 'px',
zIndex: props.isActive ? 9999999 : -1 * Math.floor((height.value * width.value) / 1000),
}));
const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const showActions = computed(
() =>
!(props.hideActions || props.isReadOnly || workflowRunning.value || isResizing.value) ||
forceActions.value,
);
onMounted(() => {
// Initialize the node
if (data.value !== null) {
try {
nodeBase.addNode(data.value);
} catch (error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
});
const onShowPopover = () => setForceActions(true);
const onHidePopover = () => setForceActions(false);
const deleteNode = async () => {
assert(data.value);
// Wait a tick else vue causes problems because the data is gone
await nextTick();
emit('removeNode', data.value.name);
};
const changeColor = (index: number) => {
workflowsStore.updateNodeProperties({
name: props.name,
properties: {
parameters: {
...node.value?.parameters,
color: index,
},
position: node.value?.position ?? [0, 0],
},
});
};
const onEdit = (edit: boolean) => {
if (edit && !props.isActive && node.value) {
ndvStore.activeNodeName = node.value.name;
} else if (props.isActive && !edit) {
ndvStore.activeNodeName = null;
}
};
const onMarkdownClick = (link: HTMLAnchorElement) => {
if (link) {
const isOnboardingNote = props.name === QUICKSTART_NOTE_NAME;
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
const type =
isOnboardingNote && isWelcomeVideo
? 'welcome_video'
: isOnboardingNote && link.getAttribute('href') === '/templates'
? 'templates'
: 'other';
telemetry.track('User clicked note link', { type });
}
};
const setParameters = (params: {
content?: string;
height?: number;
width?: number;
color?: string;
}) => {
if (node.value) {
const nodeParameters = {
content: isString(params.content) ? params.content : node.value.parameters.content,
height: isNumber(params.height) ? params.height : node.value.parameters.height,
width: isNumber(params.width) ? params.width : node.value.parameters.width,
color: isString(params.color) ? params.color : node.value.parameters.color,
};
workflowsStore.setNodeParameters({
key: node.value.id,
name: node.value.name,
value: nodeParameters,
});
}
};
const onInputChange = (content: string) => {
if (!node.value) {
return;
}
node.value.parameters.content = content;
setParameters({ content });
};
const setPosition = (newPosition: XYPosition) => {
if (!node.value) return;
workflowsStore.updateNodeProperties({
name: node.value.name,
properties: { position: newPosition },
});
};
const onResizeStart = () => {
isResizing.value = true;
if (!isSelected.value && node.value) {
emit('nodeSelected', node.value.name, false, true);
}
};
const onResize = ({
height,
width,
dX,
dY,
}: {
width: number;
height: number;
dX: number;
dY: number;
}) => {
if (!node.value) {
return;
}
if (dX !== 0 || dY !== 0) {
setPosition([node.value.position[0] + (dX || 0), node.value.position[1] + (dY || 0)]);
}
setParameters({ height, width });
};
const onResizeEnd = () => {
isResizing.value = false;
};
const touchStart = () => {
if (deviceSupport.isTouchDevice && !deviceSupport.isMacOs && !isTouchActive.value) {
isTouchActive.value = true;
setTimeout(() => {
isTouchActive.value = false;
}, 2000);
}
};
const onContextMenu = (e: MouseEvent): void => {
if (node.value && !props.isActive) {
contextMenu.open(e, { source: 'node-right-click', nodeId: node.value.id });
} else {
e.stopPropagation();
}
};
</script> </script>
<template> <template>
@ -355,9 +315,9 @@ export default defineComponent({
<div v-show="isSelected" class="select-sticky-background" /> <div v-show="isSelected" class="select-sticky-background" />
<div <div
v-touch:start="touchStart" v-touch:start="touchStart"
v-touch:end="touchEnd" v-touch:end="nodeBase.touchEnd"
class="sticky-box" class="sticky-box"
@click.left="mouseLeftClick" @click.left="nodeBase.mouseLeftClick"
@contextmenu="onContextMenu" @contextmenu="onContextMenu"
> >
<N8nResizeableSticky <N8nResizeableSticky

View file

@ -1,66 +1,111 @@
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import CredentialIcon from '@/components/CredentialIcon.vue'; import CredentialIcon from '@/components/CredentialIcon.vue';
import { STORES } from '@/constants';
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { useCredentialsStore } from '@/stores/credentials.store';
const twitterV1 = mock<INodeTypeDescription>({ import { useRootStore } from '@/stores/root.store';
version: 1, import { useNodeTypesStore } from '../../stores/nodeTypes.store';
credentials: [{ name: 'twitterOAuth1Api', required: true }],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
});
const twitterV2 = mock<INodeTypeDescription>({
version: 2,
credentials: [{ name: 'twitterOAuth2Api', required: true }],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
});
const nodeTypes = groupNodeTypesByNameAndType([twitterV1, twitterV2]);
const initialState = {
[STORES.CREDENTIALS]: {},
[STORES.NODE_TYPES]: { nodeTypes },
};
const renderComponent = createComponentRenderer(CredentialIcon, {
pinia: createTestingPinia({ initialState }),
global: {
stubs: ['n8n-tooltip'],
},
});
describe('CredentialIcon', () => { describe('CredentialIcon', () => {
const findIcon = (baseElement: Element) => baseElement.querySelector('img'); const renderComponent = createComponentRenderer(CredentialIcon, {
pinia: createTestingPinia(),
global: {
stubs: ['n8n-tooltip'],
},
});
let pinia: TestingPinia;
it('shows correct icon for credential type that is for the latest node type version', () => { beforeEach(() => {
const { baseElement } = renderComponent({ pinia = createTestingPinia({ stubActions: false });
pinia: createTestingPinia({ initialState }),
props: {
credentialTypeName: 'twitterOAuth2Api',
},
});
expect(findIcon(baseElement)).toHaveAttribute(
'src',
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
);
}); });
it('shows correct icon for credential type that is for an older node type version', () => { it('shows correct icon when iconUrl is set on credential', () => {
const { baseElement } = renderComponent({ const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
pinia: createTestingPinia({ initialState }), useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
iconUrl: testIconUrl,
}),
]);
const { getByRole } = renderComponent({
pinia,
props: { props: {
credentialTypeName: 'twitterOAuth1Api', credentialTypeName: 'test',
}, },
}); });
expect(findIcon(baseElement)).toHaveAttribute( expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
'src', });
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
); it('shows correct icon when icon is set on credential', () => {
useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
icon: 'fa:clock',
iconColor: 'azure',
}),
]);
const { getByRole } = renderComponent({
pinia,
props: {
credentialTypeName: 'test',
},
});
const icon = getByRole('img', { hidden: true });
expect(icon.tagName).toBe('svg');
expect(icon).toHaveClass('fa-clock');
});
it('shows correct icon when credential has an icon with node: prefix', () => {
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
icon: 'node:n8n-nodes-base.test',
iconColor: 'azure',
}),
]);
useNodeTypesStore().setNodeTypes([
mock<INodeTypeDescription>({
version: 1,
name: 'n8n-nodes-base.test',
iconUrl: testIconUrl,
}),
]);
const { getByRole } = renderComponent({
pinia,
props: {
credentialTypeName: 'test',
},
});
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
});
it('shows fallback icon when icon is not found', () => {
useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
icon: 'node:n8n-nodes-base.test',
iconColor: 'azure',
}),
]);
const { baseElement } = renderComponent({
pinia,
props: {
credentialTypeName: 'test',
},
});
expect(baseElement.querySelector('.nodeIconPlaceholder')).toBeInTheDocument();
}); });
}); });

View file

@ -54,6 +54,7 @@ describe('ParameterInput.vue', () => {
type: 'test', type: 'test',
typeVersion: 1, typeVersion: 1,
}, },
isNDVDataEmpty: vi.fn(() => false),
}; };
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],
@ -167,4 +168,47 @@ describe('ParameterInput.vue', () => {
// Nothing should be emitted // Nothing should be emitted
expect(emitted('update')).toBeUndefined(); expect(emitted('update')).toBeUndefined();
}); });
test('should show message when can not load options without credentials', async () => {
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => {
throw new Error('Node does not have any credentials set');
});
// @ts-expect-error Readonly property
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
displayName: 'Test',
credentials: [
{
name: 'openAiApi',
required: true,
},
],
});
const { emitted, container, getByTestId } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
props: {
path: 'columns',
parameter: {
displayName: 'Columns',
name: 'columns',
type: 'options',
typeOptions: { loadOptionsMethod: 'getColumnsMultiOptions' },
},
modelValue: 'id',
},
});
await waitFor(() => expect(getByTestId('parameter-input-field')).toBeInTheDocument());
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
expect(input.value.toLowerCase()).not.toContain('error');
expect(input).toHaveValue('Set up credential to see options');
expect(emitted('update')).toBeUndefined();
});
}); });

View file

@ -15,7 +15,7 @@ import { usePostHog } from '@/stores/posthog.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import type { Placement } from '@floating-ui/core'; import type { Placement } from '@floating-ui/core';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue'; import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
export type ExecutionFilterProps = { export type ExecutionFilterProps = {
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>; workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;

View file

@ -2,7 +2,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow'; import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
import { useExecutionsStore } from '@/stores/executions.store'; import { useExecutionsStore } from '@/stores/executions.store';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue'; import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import VoteButtons from '@/components/executions/workflow/VoteButtons.vue'; import VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';

View file

@ -436,6 +436,10 @@ export function useCanvasMapping({
let status: CanvasConnectionData['status']; let status: CanvasConnectionData['status'];
if (fromNode) { if (fromNode) {
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const runDataTotal =
nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0;
if (nodeExecutionRunningById.value[fromNode.id]) { if (nodeExecutionRunningById.value[fromNode.id]) {
status = 'running'; status = 'running';
} else if ( } else if (
@ -445,7 +449,7 @@ export function useCanvasMapping({
status = 'pinned'; status = 'pinned';
} else if (nodeHasIssuesById.value[fromNode.id]) { } else if (nodeHasIssuesById.value[fromNode.id]) {
status = 'error'; status = 'error';
} else if (nodeExecutionRunDataById.value[fromNode.id]) { } else if (runDataTotal > 0) {
status = 'success'; status = 'success';
} }
} }

Some files were not shown because too many files have changed in this diff Show more