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
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
merge-back-into-master:
name: Merge back into master
needs: [publish-to-npm, create-github-release]
if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1
with:
fetch-depth: 0
- run: |
git checkout --track origin/master
git config user.name "github-actions[bot]"
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
git push origin master
git push origin :${{github.event.pull_request.base.ref}}
# merge-back-into-master:
# name: Merge back into master
# needs: [publish-to-npm, create-github-release]
# if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4.1.1
# with:
# fetch-depth: 0
# - run: |
# git checkout --track origin/master
# git config user.name "github-actions[bot]"
# git config user.email 41898282+github-actions[bot]@users.noreply.github.com
# git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
# git push origin master
# git push origin :${{github.event.pull_request.base.ref}}

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)

View file

@ -229,6 +229,35 @@ describe('Workflow Executions', () => {
cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
executionsTab.getters.executionListItems().eq(11).should('be.visible');
});
it('should redirect back to editor after seeing a couple of execution using browser back button', () => {
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(2).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(4).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(6).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
cy.url().should('not.include', '/executions');
cy.url().should('include', '/workflow/');
workflowPage.getters.nodeViewRoot().should('be.visible');
});
});
describe('when new workflow is not saved', () => {

View file

@ -674,6 +674,23 @@ describe('NDV', () => {
ndv.getters.parameterInput('operation').find('input').should('have.value', 'Delete');
});
it('Should show a notice when remote options cannot be fetched because of missing credentials', () => {
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 403 }).as(
'parameterOptions',
);
workflowPage.actions.addInitialNodeToCanvas(NOTION_NODE_NAME, {
keepNdvOpen: true,
action: 'Update a database page',
});
ndv.actions.addItemToFixedCollection('propertiesUi');
ndv.getters
.parameterInput('key')
.find('input')
.should('have.value', 'Set up credential to see options');
});
it('Should show error state when remote options cannot be fetched', () => {
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as(
'parameterOptions',
@ -684,6 +701,11 @@ describe('NDV', () => {
action: 'Update a database page',
});
clickCreateNewCredential();
setCredentialValues({
apiKey: 'sk_test_123',
});
ndv.actions.addItemToFixedCollection('propertiesUi');
ndv.getters
.parameterInput('key')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,10 +10,21 @@ import {
import { RetrievalQAChain } from 'langchain/chains';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseRetriever } from '@langchain/core/retrievers';
import {
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate,
PromptTemplate,
} from '@langchain/core/prompts';
import { getTemplateNoticeField } from '../../../utils/sharedFields';
import { getPromptInputByType } from '../../../utils/helpers';
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
import { getTracingConfig } from '../../../utils/tracing';
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
{context}`;
export class ChainRetrievalQa implements INodeType {
description: INodeTypeDescription = {
displayName: 'Question and Answer Chain',
@ -137,6 +148,26 @@ export class ChainRetrievalQa implements INodeType {
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'System Prompt Template',
name: 'systemPromptTemplate',
type: 'string',
default: SYSTEM_PROMPT_TEMPLATE,
description:
'Template string used for the system prompt. This should include the variable `{context}` for the provided context. For text completion models, you should also include the variable `{question}` for the users query.',
typeOptions: {
rows: 6,
},
},
],
},
],
};
@ -154,7 +185,6 @@ export class ChainRetrievalQa implements INodeType {
)) as BaseRetriever;
const items = this.getInputData();
const chain = RetrievalQAChain.fromLLM(model, retriever);
const returnData: INodeExecutionData[] = [];
@ -178,6 +208,35 @@ export class ChainRetrievalQa implements INodeType {
throw new NodeOperationError(this.getNode(), 'The query parameter is empty.');
}
const options = this.getNodeParameter('options', itemIndex, {}) as {
systemPromptTemplate?: string;
};
const chainParameters = {} as {
prompt?: PromptTemplate | ChatPromptTemplate;
};
if (options.systemPromptTemplate !== undefined) {
if (isChatInstance(model)) {
const messages = [
SystemMessagePromptTemplate.fromTemplate(options.systemPromptTemplate),
HumanMessagePromptTemplate.fromTemplate('{question}'),
];
const chatPromptTemplate = ChatPromptTemplate.fromMessages(messages);
chainParameters.prompt = chatPromptTemplate;
} else {
const completionPromptTemplate = new PromptTemplate({
template: options.systemPromptTemplate,
inputVariables: ['context', 'question'],
});
chainParameters.prompt = completionPromptTemplate;
}
}
const chain = RetrievalQAChain.fromLLM(model, retriever, chainParameters);
const response = await chain.withConfig(getTracingConfig(this)).invoke({ query });
returnData.push({ json: { response } });
} catch (error) {

View file

@ -275,7 +275,11 @@ export class ToolHttpRequest implements INodeType {
method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods,
url: this.getNodeParameter('url', itemIndex) as string,
qs: {},
headers: {},
headers: {
// FIXME: This is a workaround to prevent the node from sending a default User-Agent (`n8n`) when the header is not set.
// Needs to be replaced with a proper fix after NODE-1777 is resolved
'User-Agent': undefined,
},
body: {},
};

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "1.60.0",
"version": "1.61.0",
"description": "",
"main": "index.js",
"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('reflect-metadata');
// Skip loading dotenv in e2e tests.
// Also, do not use `inE2ETests` from constants here, because that'd end up code that might read from `process.env` before the values are loaded from an `.env` file.
if (process.env.E2E_TESTS !== 'true') {
// Loading dotenv early ensures that `process.env` is up-to-date everywhere in code
require('dotenv').config();
}
if (process.env.NODEJS_PREFER_IPV4 === 'true') {
require('dns').setDefaultResultOrder('ipv4first');
}

View file

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

View file

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

View file

@ -2,11 +2,14 @@ import { UserUpdateRequestDto } from '@n8n/api-types';
import type { Response } from 'express';
import { mock, anyObject } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import { randomString } from 'n8n-workflow';
import { Container } from 'typedi';
import { AUTH_COOKIE_NAME } from '@/constants';
import { API_KEY_PREFIX, MeController } from '@/controllers/me.controller';
import { MeController } from '@/controllers/me.controller';
import type { ApiKey } from '@/databases/entities/api-key';
import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
@ -18,6 +21,7 @@ import type { PublicUser } from '@/interfaces';
import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import type { AuthenticatedRequest, MeRequest } from '@/requests';
import { API_KEY_PREFIX } from '@/services/public-api-key.service';
import { UserService } from '@/services/user.service';
import { mockInstance } from '@test/mocking';
import { badPasswords } from '@test/test-data';
@ -30,6 +34,7 @@ describe('MeController', () => {
const userService = mockInstance(UserService);
const userRepository = mockInstance(UserRepository);
const mockMfaService = mockInstance(MfaService);
const apiKeysRepository = mockInstance(ApiKeyRepository);
mockInstance(AuthUserRepository);
mockInstance(InvalidAuthTokenRepository);
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
@ -412,27 +417,63 @@ describe('MeController', () => {
describe('API Key methods', () => {
let req: AuthenticatedRequest;
beforeAll(() => {
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) });
req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
});
describe('createAPIKey', () => {
it('should create and save an API key', async () => {
const { apiKey } = await controller.createAPIKey(req);
expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey });
const apiKeyData = {
id: '123',
userId: '123',
label: 'My API Key',
apiKey: `${API_KEY_PREFIX}${randomString(42)}`,
createdAt: new Date(),
} as ApiKey;
apiKeysRepository.upsert.mockImplementation();
apiKeysRepository.findOneByOrFail.mockResolvedValue(apiKeyData);
const newApiKey = await controller.createAPIKey(req);
expect(apiKeysRepository.upsert).toHaveBeenCalled();
expect(apiKeyData).toEqual(newApiKey);
});
});
describe('getAPIKey', () => {
it('should return the users api key redacted', async () => {
const { apiKey } = await controller.getAPIKey(req);
expect(apiKey).not.toEqual(req.user.apiKey);
describe('getAPIKeys', () => {
it('should return the users api keys redacted', async () => {
const apiKeyData = {
id: '123',
userId: '123',
label: 'My API Key',
apiKey: `${API_KEY_PREFIX}${randomString(42)}`,
createdAt: new Date(),
} as ApiKey;
apiKeysRepository.findBy.mockResolvedValue([apiKeyData]);
const apiKeys = await controller.getAPIKeys(req);
expect(apiKeys[0].apiKey).not.toEqual(apiKeyData.apiKey);
expect(apiKeysRepository.findBy).toHaveBeenCalledWith({ userId: req.user.id });
});
});
describe('deleteAPIKey', () => {
it('should delete the API key', async () => {
const user = mock<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);
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 { AnnotationTagsRequest } from '@/requests';
import { AnnotationTagService } from '@/services/annotation-tag.service';
import { AnnotationTagService } from '@/services/annotation-tag.service.ee';
@RestController('/annotation-tags')
export class AnnotationTagsController {

View file

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

View file

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

View file

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

View file

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

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';
import type { AnnotationVote } from 'n8n-workflow';
import type { AnnotationTagEntity } from './annotation-tag-entity';
import type { AnnotationTagMapping } from './annotation-tag-mapping';
import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
import type { AnnotationTagMapping } from './annotation-tag-mapping.ee';
import { ExecutionEntity } from './execution-entity';
@Entity({ name: 'execution_annotations' })

View file

@ -12,7 +12,7 @@ import {
} from '@n8n/typeorm';
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
import { datetimeColumnType } from './abstract-entity';
import type { ExecutionData } from './execution-data';

View file

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

View file

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

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 { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -128,4 +129,5 @@ export const mysqlMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
];

View file

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

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 { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -122,6 +123,7 @@ const sqliteMigrations: Migration[] = [
CreateInvalidAuthTokenTable1723627610222,
RefactorExecutionIndices1723796243146,
CreateAnnotationTables1724753530828,
AddApiKeysTable1724951148974,
];
export { sqliteMigrations };

View file

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

View file

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

View file

@ -36,9 +36,9 @@ import type {
import { Service } from 'typedi';
import config from '@/config';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
import type { ExecutionSummaries } from '@/executions/execution.types';
import type {
@ -54,6 +54,8 @@ import { ExecutionDataRepository } from './execution-data.repository';
import type { ExecutionData } from '../entities/execution-data';
import { ExecutionEntity } from '../entities/execution-entity';
import { ExecutionMetadata } from '../entities/execution-metadata';
import { SharedWorkflow } from '../entities/shared-workflow';
import { WorkflowEntity } from '../entities/workflow-entity';
export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string;
@ -874,6 +876,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
metadata,
annotationTags,
vote,
projectId,
} = query;
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;
}

View file

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

View file

@ -21,7 +21,7 @@ import { ActiveExecutions } from '@/active-executions';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import config from '@/config';
import type { User } from '@/databases/entities/user';
import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository';
import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository.ee';
import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { IGetExecutionsQueryFilter } from '@/databases/repositories/execution.repository';

View file

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

View file

@ -1,6 +1,6 @@
import { validate } from 'class-validator';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user';

View file

@ -26,7 +26,7 @@ import type {
import type PCancelable from 'p-cancelable';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
import type { AuthProviderType } from '@/databases/entities/auth-identity';
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { TagEntity } from '@/databases/entities/tag-entity';

View file

@ -18,6 +18,7 @@ import type {
} from 'n8n-workflow';
import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import path from 'path';
import picocolors from 'picocolors';
import { Container, Service } from 'typedi';
import {
@ -146,6 +147,7 @@ export class LoadNodesAndCredentials {
path.join(nodeModulesDir, packagePath),
);
} catch (error) {
this.logger.error((error as Error).message);
ErrorReporter.error(error);
}
}
@ -258,6 +260,13 @@ export class LoadNodesAndCredentials {
dir: string,
) {
const loader = new constructor(dir, this.excludeNodes, this.includeNodes);
if (loader.packageName in this.loaders) {
throw new ApplicationError(
picocolors.red(
`nodes package ${loader.packageName} is already loaded.\n Please delete this second copy at path ${dir}`,
),
);
}
await loader.loadAll();
this.loaders[loader.packageName] = loader;
return loader;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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;
},
) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
const { password, updatedAt, authIdentities, ...rest } = user;
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.password).toBeUndefined();
expect(user.isPending).toBe(false);
expect(user.apiKey).not.toBeDefined();
expect(user.globalScopes).toBeDefined();
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 { ExecutionService } from '@/executions/execution.service';
import type { ExecutionSummaries } from '@/executions/execution.types';
import { createTeamProject } from '@test-integration/db/projects';
import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions';
import { createWorkflow } from './shared/db/workflows';
@ -294,6 +295,37 @@ describe('ExecutionService', () => {
});
});
test('should filter executions by `projectId`', async () => {
const firstProject = await createTeamProject();
const secondProject = await createTeamProject();
const firstWorkflow = await createWorkflow(undefined, firstProject);
const secondWorkflow = await createWorkflow(undefined, secondProject);
await createExecution({ status: 'success' }, firstWorkflow);
await createExecution({ status: 'success' }, firstWorkflow);
await createExecution({ status: 'success' }, secondWorkflow); // to filter out
const query: ExecutionSummaries.RangeQuery = {
kind: 'range',
range: { limit: 20 },
accessibleWorkflowIds: [firstWorkflow.id],
projectId: firstProject.id,
};
const output = await executionService.findRangeWithCount(query);
expect(output).toEqual({
count: 2,
estimated: false,
results: expect.arrayContaining([
expect.objectContaining({ workflowId: firstWorkflow.id }),
expect.objectContaining({ workflowId: firstWorkflow.id }),
// execution for workflow in second project was filtered out
]),
});
});
test('should exclude executions by inaccessible `workflowId`', async () => {
const accessibleWorkflow = await createWorkflow();
const inaccessibleWorkflow = await createWorkflow();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,9 +17,8 @@ import { createTeamProject } from '@test-integration/db/projects';
import { mockInstance } from '../../shared/mocking';
import { createTag } from '../shared/db/tags';
import { createUser } from '../shared/db/users';
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows';
import { randomApiKey } from '../shared/random';
import * as testDb from '../shared/test-db';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
@ -40,18 +39,13 @@ const license = testServer.license;
mockInstance(ExecutionService);
beforeAll(async () => {
owner = await createUser({
role: 'global:owner',
apiKey: randomApiKey(),
});
owner = await createOwnerWithApiKey();
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
member = await createUser({
role: 'global:member',
apiKey: randomApiKey(),
});
member = await createMemberWithApiKey();
memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
member.id,
);
@ -1518,6 +1512,10 @@ describe('PUT /workflows/:id/transfer', () => {
const secondProject = await createTeamProject('second-project', member);
const workflow = await createWorkflow({}, firstProject);
// Make data more similar to real world scenario by injecting additional records into the database
await createTeamProject('third-project', member);
await createWorkflow({}, firstProject);
/**
* Act
*/
@ -1529,6 +1527,13 @@ describe('PUT /workflows/:id/transfer', () => {
* Assert
*/
expect(response.statusCode).toBe(204);
const workflowsInProjectResponse = await authMemberAgent
.get(`/workflows?projectId=${secondProject.id}`)
.send();
expect(workflowsInProjectResponse.statusCode).toBe(200);
expect(workflowsInProjectResponse.body.data[0].id).toBe(workflow.id);
});
test('if no destination project, should reject', async () => {

View file

@ -4,7 +4,7 @@ import Container from 'typedi';
import type { ExecutionData } from '@/databases/entities/execution-data';
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository';
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';

View file

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

View file

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

View file

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

View file

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

View file

@ -30,18 +30,6 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
const loader = new PackageDirectoryLoader(packageDir);
await loader.loadAll();
const knownCredentials = loader.known.credentials;
const credentialTypes = Object.values(loader.credentialTypes).map((data) => {
const credentialType = data.type;
if (
knownCredentials[credentialType.name].supportedNodes?.length > 0 &&
credentialType.httpRequestNode
) {
credentialType.httpRequestNode.hidden = true;
}
return credentialType;
});
const loaderNodeTypes = Object.values(loader.nodeTypes);
const definedMethods = loaderNodeTypes.reduce((acc, cur) => {
@ -76,6 +64,36 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
}),
);
const knownCredentials = loader.known.credentials;
const credentialTypes = Object.values(loader.credentialTypes).map((data) => {
const credentialType = data.type;
const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? [];
if (supportedNodes.length > 0 && credentialType.httpRequestNode) {
credentialType.httpRequestNode.hidden = true;
}
credentialType.supportedNodes = supportedNodes;
if (!credentialType.iconUrl && !credentialType.icon) {
for (const supportedNode of supportedNodes) {
const nodeType = loader.nodeTypes[supportedNode]?.type.description;
if (!nodeType) continue;
if (nodeType.icon) {
credentialType.icon = nodeType.icon;
credentialType.iconColor = nodeType.iconColor;
break;
}
if (nodeType.iconUrl) {
credentialType.iconUrl = nodeType.iconUrl;
break;
}
}
}
return credentialType;
});
const referencedMethods = findReferencedMethods(nodeTypes);
await Promise.all([

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterpr
export interface IN8nPromptResponse {
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';
export async function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
return await makeRestApiRequest(context, 'GET', '/me/api-key');
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/me/api-keys');
}
export async function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
return await makeRestApiRequest(context, 'POST', '/me/api-key');
export async function createApiKey(context: IRestApiContext): Promise<ApiKey> {
return await makeRestApiRequest(context, 'POST', '/me/api-keys');
}
export async function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', '/me/api-key');
export async function deleteApiKey(
context: IRestApiContext,
id: string,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/me/api-keys/${id}`);
}

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
import type { Segment } from '@/types/expressions';
import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state';
@ -119,7 +119,9 @@ async function onDrop(value: string, event: MouseEvent) {
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) {
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);

View file

@ -90,8 +90,8 @@ const allIssues = computed(() => {
const now = computed(() => DateTime.now().toISO());
const leftParameter = computed<INodeProperties>(() => ({
name: '',
displayName: '',
name: 'left',
displayName: 'Left',
default: '',
placeholder:
operator.value.type === 'dateTime'
@ -103,8 +103,8 @@ const leftParameter = computed<INodeProperties>(() => ({
const rightParameter = computed<INodeProperties>(() => {
const type = operator.value.rightType ?? operator.value.type;
return {
name: '',
displayName: '',
name: 'right',
displayName: 'Right',
default: '',
placeholder:
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 htmlParser from 'prettier/plugins/html';
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 { useExpressionEditor } from '@/composables/useExpressionEditor';
@ -37,6 +37,7 @@ import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
modelValue: string;
@ -84,6 +85,7 @@ const extensions = computed(() => [
dropCursor(),
indentOnInput(),
highlightActiveLine(),
mappingDropCursor(),
]);
const {
editor: editorRef,
@ -238,11 +240,25 @@ onMounted(() => {
onBeforeUnmount(() => {
htmlEditorEventBus.off('format-html', formatHtml);
});
async function onDrop(value: string, event: MouseEvent) {
if (!editorRef.value) return;
await dropInExpressionEditor(toRaw(editorRef.value), event, value);
}
</script>
<template>
<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" />
</div>
</template>
@ -255,4 +271,21 @@ onBeforeUnmount(() => {
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>

View file

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

View file

@ -27,6 +27,7 @@ describe('InlineExpressionTip.vue', () => {
mockNdvState = {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => true),
setHighlightDraggables: vi.fn(),
};
});
@ -43,11 +44,16 @@ describe('InlineExpressionTip.vue', () => {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
const { container } = renderComponent(InlineExpressionTip, {
const { container, unmount } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
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,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),

View file

@ -24,6 +24,7 @@ import {
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
modelValue: string;
@ -69,6 +70,7 @@ const extensions = computed(() => {
foldGutter(),
dropCursor(),
bracketMatching(),
mappingDropCursor(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged || !editor.value) return;
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 PersonalizationModal from '@/components/PersonalizationModal.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 NpsSurvey from '@/components/NpsSurvey.vue';
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';

View file

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

View file

@ -177,6 +177,15 @@ const displayValue = computed(() => {
if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) {
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', {
interpolate: { service: nodeType.value.displayName },
});
@ -510,6 +519,28 @@ const isCodeNode = computed(
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) {
return remoteParameterOptionsKeys.value.includes(option.name);
}
@ -965,7 +996,11 @@ onUpdated(async () => {
</script>
<template>
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop>
<div
ref="wrapper"
:class="[parameterInputClasses, { [$style.tipVisible]: showDragnDropTip }]"
@keydown.stop
>
<ExpressionEditModal
:dialog-visible="expressionEditDialogVisible"
:model-value="modelValueExpressionEdit"
@ -1249,6 +1284,7 @@ onUpdated(async () => {
"
:title="displayTitle"
:placeholder="getPlaceholder()"
data-test-id="parameter-input-field"
@update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)"
@keydown.stop
@focus="setFocus"
@ -1447,6 +1483,9 @@ onUpdated(async () => {
:disabled="isReadOnly"
@update:model-value="valueChanged"
/>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
</div>
<ParameterIssues
@ -1477,6 +1516,7 @@ onUpdated(async () => {
.parameter-input {
display: inline-block;
position: relative;
:deep(.color-input) {
display: flex;
@ -1609,3 +1649,23 @@ onUpdated(async () => {
}
}
</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 { createEventBus } from 'n8n-design-system/utils';
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
type Props = {
parameter: INodeProperties;
@ -57,8 +56,7 @@ const ndvStore = useNDVStore();
const node = computed(() => ndvStore.activeNode);
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(
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
);
@ -73,17 +71,6 @@ const isExpression = computed(() => isValueExpression(props.parameter, props.val
const showExpressionSelector = computed(() =>
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() {
focused.value = true;
@ -205,7 +192,7 @@ function onDrop(newParamValue: string) {
<template>
<n8n-input-label
:class="[$style.wrapper, { [$style.tipVisible]: showDragnDropTip }]"
:class="[$style.wrapper]"
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
:show-tooltip="focused"
@ -258,9 +245,6 @@ function onDrop(newParamValue: string) {
/>
</template>
</DraggableTarget>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip />
</div>
<div
:class="{
[$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 {
position: absolute;
bottom: -22px;

View file

@ -34,8 +34,9 @@ import {
StandardSQL,
keywordCompletionSource,
} 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 { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
const SQL_DIALECTS = {
StandardSQL,
@ -111,6 +112,7 @@ const extensions = computed(() => {
foldGutter(),
dropCursor(),
bracketMatching(),
mappingDropCursor(),
]);
}
return baseExtensions;
@ -178,11 +180,28 @@ function highlightLine(lineNumber: number | 'final') {
selection: { anchor: lineToHighlight.from },
});
}
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInExpressionEditor(toRaw(editor.value), event, value);
}
</script>
<template>
<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" />
<InlineExpressionEditorOutput
v-if="!fullscreen"
@ -202,4 +221,21 @@ function highlightLine(lineNumber: number | 'final') {
.codemirror {
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>

View file

@ -1,19 +1,12 @@
<script lang="ts">
import { defineComponent, ref } from 'vue';
import type { PropType, StyleValue } from 'vue';
import { mapStores } from 'pinia';
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import type { StyleValue } from 'vue';
import { onClickOutside } from '@vueuse/core';
import type { Workflow } from 'n8n-workflow';
import { isNumber, isString } from '@/utils/typeGuards';
import type {
INodeUi,
INodeUpdatePropertiesInformation,
IUpdateInformation,
XYPosition,
} from '@/Interface';
import type { INodeUi, XYPosition } from '@/Interface';
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
import { QUICKSTART_NOTE_NAME } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
@ -25,313 +18,280 @@ import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { useToast } from '@/composables/useToast';
import { assert } from '@/utils/assert';
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 { useTelemetry } from '@/composables/useTelemetry';
export default defineComponent({
name: 'Sticky',
props: {
nodeViewScale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
default: GRID_SIZE,
},
name: {
type: String,
required: true,
},
instance: {
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,
},
const props = withDefaults(
defineProps<{
nodeViewScale?: number;
gridSize?: number;
name: string;
instance: BrowserJsPlumbInstance;
isReadOnly?: boolean;
isActive?: boolean;
hideActions?: boolean;
disableSelecting?: boolean;
showCustomTooltip?: boolean;
workflow: Workflow;
}>(),
{
nodeViewScale: 1,
gridSize: GRID_SIZE,
},
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) => {
forceActions.value = value;
};
const setColorPopoverVisible = (value: boolean) => {
isColorPopoverVisible.value = value;
};
const emit = defineEmits<{
removeNode: [string];
nodeSelected: [string, boolean, boolean];
}>();
const contextMenu = useContextMenu((action) => {
if (action === 'change_color') {
setForceActions(true);
setColorPopoverVisible(true);
}
});
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const toast = useToast();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const nodeBase = useNodeBase({
name: props.name,
instance: props.instance,
workflowObject: props.workflow,
isReadOnly: props.isReadOnly,
emit: emit as (event: string, ...args: unknown[]) => void,
});
const isResizing = ref<boolean>(false);
const isTouchActive = ref<boolean>(false);
const forceActions = ref(false);
const isColorPopoverVisible = ref(false);
const stickOptions = ref<HTMLElement>();
onClickOutside(stickOptions, () => setColorPopoverVisible(false));
const setForceActions = (value: boolean) => {
forceActions.value = value;
};
return {
deviceSupport,
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');
const setColorPopoverVisible = (value: boolean) => {
isColorPopoverVisible.value = value;
};
return content && isString(content.default) ? content.default : '';
},
isSelected(): boolean {
return (
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 contextMenu = useContextMenu((action) => {
if (action === 'change_color') {
setForceActions(true);
setColorPopoverVisible(true);
}
});
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>
<template>
@ -355,9 +315,9 @@ export default defineComponent({
<div v-show="isSelected" class="select-sticky-background" />
<div
v-touch:start="touchStart"
v-touch:end="touchEnd"
v-touch:end="nodeBase.touchEnd"
class="sticky-box"
@click.left="mouseLeftClick"
@click.left="nodeBase.mouseLeftClick"
@contextmenu="onContextMenu"
>
<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 type { INodeTypeDescription } from 'n8n-workflow';
import CredentialIcon from '@/components/CredentialIcon.vue';
import { STORES } from '@/constants';
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
import { createComponentRenderer } from '@/__tests__/render';
const twitterV1 = mock<INodeTypeDescription>({
version: 1,
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'],
},
});
import { useCredentialsStore } from '@/stores/credentials.store';
import { useRootStore } from '@/stores/root.store';
import { useNodeTypesStore } from '../../stores/nodeTypes.store';
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', () => {
const { baseElement } = renderComponent({
pinia: createTestingPinia({ initialState }),
props: {
credentialTypeName: 'twitterOAuth2Api',
},
});
expect(findIcon(baseElement)).toHaveAttribute(
'src',
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
);
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false });
});
it('shows correct icon for credential type that is for an older node type version', () => {
const { baseElement } = renderComponent({
pinia: createTestingPinia({ initialState }),
it('shows correct icon when iconUrl is set on credential', () => {
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
iconUrl: testIconUrl,
}),
]);
const { getByRole } = renderComponent({
pinia,
props: {
credentialTypeName: 'twitterOAuth1Api',
credentialTypeName: 'test',
},
});
expect(findIcon(baseElement)).toHaveAttribute(
'src',
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
);
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
});
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',
typeVersion: 1,
},
isNDVDataEmpty: vi.fn(() => false),
};
mockNodeTypesState = {
allNodeTypes: [],
@ -167,4 +168,47 @@ describe('ParameterInput.vue', () => {
// Nothing should be emitted
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 type { Placement } from '@floating-ui/core';
import { useDebounce } from '@/composables/useDebounce';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
export type ExecutionFilterProps = {
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;

View file

@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
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 VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
import { useToast } from '@/composables/useToast';

View file

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

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