mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into node-1608-credential-parameters-tech-debt-project
This commit is contained in:
commit
6534b614e0
32
.github/workflows/release-publish.yml
vendored
32
.github/workflows/release-publish.yml
vendored
|
@ -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}}
|
||||
|
|
43
CHANGELOG.md
43
CHANGELOG.md
|
@ -1,3 +1,46 @@
|
|||
# [1.61.0](https://github.com/n8n-io/n8n/compare/n8n@1.60.0...n8n@1.61.0) (2024-09-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Add executionData to expressions in pagination code ([#10926](https://github.com/n8n-io/n8n/issues/10926)) ([eac103e](https://github.com/n8n-io/n8n/commit/eac103e367d59a532b9ba12db78a0dd10aee62fb))
|
||||
* **core:** Fix webhook binary data max size configuration ([#10897](https://github.com/n8n-io/n8n/issues/10897)) ([693fb7e](https://github.com/n8n-io/n8n/commit/693fb7e580b7e030c86977bff6d319bbee4fcd62))
|
||||
* **core:** Remove subworkflow license check ([#10893](https://github.com/n8n-io/n8n/issues/10893)) ([0290e38](https://github.com/n8n-io/n8n/commit/0290e38f990275074eb7e7ccd0b41f1ae0215dd2))
|
||||
* **editor:** Credentials scopes and n8n scopes mix up ([#10930](https://github.com/n8n-io/n8n/issues/10930)) ([e069608](https://github.com/n8n-io/n8n/commit/e0696080227aee7ccb50d51a82873e8a1ba4667d))
|
||||
* **editor:** Fix design system form component sizing ([#10961](https://github.com/n8n-io/n8n/issues/10961)) ([cf153ea](https://github.com/n8n-io/n8n/commit/cf153ea085165115ee523fbb1bd32080dde47eda))
|
||||
* **editor:** Fix modal overflow when AI is enabled in code node ([#10887](https://github.com/n8n-io/n8n/issues/10887)) ([f9f303f](https://github.com/n8n-io/n8n/commit/f9f303f562084db8c8956da267680b1f935aa2df))
|
||||
* **editor:** Fix source control push modal checkboxes ([#10910](https://github.com/n8n-io/n8n/issues/10910)) ([8db8817](https://github.com/n8n-io/n8n/commit/8db88178511749b19a5878816ef062092fd9f2be))
|
||||
* **editor:** Fix styling and typography in AI Assistant chat ([#10895](https://github.com/n8n-io/n8n/issues/10895)) ([57ff3cc](https://github.com/n8n-io/n8n/commit/57ff3cc27b9470bfbe2486c3c1831c57f5a4075f))
|
||||
* **editor:** Prevent clipboard xss injection ([#10894](https://github.com/n8n-io/n8n/issues/10894)) ([e20ab59](https://github.com/n8n-io/n8n/commit/e20ab59c1dcf9da19a30268ce19930bfa7e38992))
|
||||
* **editor:** Prevent node name input in NDV to expand unnecessarily ([#10922](https://github.com/n8n-io/n8n/issues/10922)) ([a2237d1](https://github.com/n8n-io/n8n/commit/a2237d128ff6a4d65cd30325b6b9d9b765ca7be6))
|
||||
* **editor:** Update gird size when opening credentials support chat ([#10882](https://github.com/n8n-io/n8n/issues/10882)) ([b86fd80](https://github.com/n8n-io/n8n/commit/b86fd80fc9fe06011367ca04a75e4b52533db1fe))
|
||||
* **editor:** Use `:focus-visible` instead for `:focus` for buttons ([#10921](https://github.com/n8n-io/n8n/issues/10921)) ([bf28d09](https://github.com/n8n-io/n8n/commit/bf28d0965c46620a106c87037bafd2cf936f1050))
|
||||
* **editor:** Use correct output for connected nodes in schema view ([#10928](https://github.com/n8n-io/n8n/issues/10928)) ([ad60d49](https://github.com/n8n-io/n8n/commit/ad60d49b4251138a7c69cb5e9f00c3ef875486e0))
|
||||
* Enable Assistant on other credential views ([#10931](https://github.com/n8n-io/n8n/issues/10931)) ([557db9c](https://github.com/n8n-io/n8n/commit/557db9c170a89447ec9cc14aa1af51e5fd11dd92))
|
||||
* Ensure user id for early track events ([#10885](https://github.com/n8n-io/n8n/issues/10885)) ([23c09ea](https://github.com/n8n-io/n8n/commit/23c09eae4223545c717270a5cd305d2e57e1ad5b))
|
||||
* **Google Sheets Node:** Insert data if sheet is empty instead of error ([#10942](https://github.com/n8n-io/n8n/issues/10942)) ([c75990e](https://github.com/n8n-io/n8n/commit/c75990e0632c581384542610a886ef89621a9403))
|
||||
* Hide assistant button when showing Click to connect ([#10932](https://github.com/n8n-io/n8n/issues/10932)) ([d74cff2](https://github.com/n8n-io/n8n/commit/d74cff20301f285588f93207f29660d25fdbc8da))
|
||||
* **HTTP Request Node:** Do not modify request object when sanitizing message for UI ([#10923](https://github.com/n8n-io/n8n/issues/10923)) ([8cc10cc](https://github.com/n8n-io/n8n/commit/8cc10cc2c1869b9abcafd157e41be65ce2b6f499))
|
||||
* **MQTT Node:** Close connection if connection attempt fails ([#10873](https://github.com/n8n-io/n8n/issues/10873)) ([ee7147c](https://github.com/n8n-io/n8n/commit/ee7147c6b3b053ac8fc317319ab257204e599f16))
|
||||
* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#10965](https://github.com/n8n-io/n8n/issues/10965)) ([62159bd](https://github.com/n8n-io/n8n/commit/62159bd71c9a0303b597a68113e0ac50473ee8d4))
|
||||
* **Notion Node:** Allow UUID v8 in notion id checks ([#10938](https://github.com/n8n-io/n8n/issues/10938)) ([46beda0](https://github.com/n8n-io/n8n/commit/46beda05f6771c31bcf0b6a781976d8261079a66))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Brandfetch Node:** Update to use new API ([#10877](https://github.com/n8n-io/n8n/issues/10877)) ([08ba9a3](https://github.com/n8n-io/n8n/commit/08ba9a36a43b6c84f69bb04fa4d6419a7a4adddf))
|
||||
* **editor:** Setup Sentry integration ([#10945](https://github.com/n8n-io/n8n/issues/10945)) ([6de4dff](https://github.com/n8n-io/n8n/commit/6de4dfff87e4da888567081a9928d9682bdea11d))
|
||||
* **editor:** Show a notice before deleting annotated executions ([#10934](https://github.com/n8n-io/n8n/issues/10934)) ([dcc1c72](https://github.com/n8n-io/n8n/commit/dcc1c72fc4b56c3252183541b22da801804d4f79))
|
||||
* Page size 1 option ([#10957](https://github.com/n8n-io/n8n/issues/10957)) ([bdc0622](https://github.com/n8n-io/n8n/commit/bdc0622f59e98c9e6c542f5cb59a2dbd9008ba96))
|
||||
* **Slack Node:** Add option to hide workflow link on message update ([#10927](https://github.com/n8n-io/n8n/issues/10927)) ([422c946](https://github.com/n8n-io/n8n/commit/422c9463c8d931a728615a1fe5a10f05a96ecaa2))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **editor:** Use virtual scrolling in `RunDataJson.vue` ([#10838](https://github.com/n8n-io/n8n/issues/10838)) ([f5474ff](https://github.com/n8n-io/n8n/commit/f5474ff79198a2f5a145d0a9df1bb651ea677ec5))
|
||||
|
||||
|
||||
|
||||
# [1.60.0](https://github.com/n8n-io/n8n/compare/n8n@1.59.0...n8n@1.60.0) (2024-09-18)
|
||||
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.60.0",
|
||||
"version": "1.61.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -10,10 +10,21 @@ import {
|
|||
import { RetrievalQAChain } from 'langchain/chains';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
PromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
||||
import { getPromptInputByType } from '../../../utils/helpers';
|
||||
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
|
||||
import { getTracingConfig } from '../../../utils/tracing';
|
||||
|
||||
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
|
||||
If you don't know the answer, just say that you don't know, don't try to make up an answer.
|
||||
----------------
|
||||
{context}`;
|
||||
|
||||
export class ChainRetrievalQa implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Question and Answer Chain',
|
||||
|
@ -137,6 +148,26 @@ export class ChainRetrievalQa implements INodeType {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'System Prompt Template',
|
||||
name: 'systemPromptTemplate',
|
||||
type: 'string',
|
||||
default: SYSTEM_PROMPT_TEMPLATE,
|
||||
description:
|
||||
'Template string used for the system prompt. This should include the variable `{context}` for the provided context. For text completion models, you should also include the variable `{question}` for the user’s query.',
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -154,7 +185,6 @@ export class ChainRetrievalQa implements INodeType {
|
|||
)) as BaseRetriever;
|
||||
|
||||
const items = this.getInputData();
|
||||
const chain = RetrievalQAChain.fromLLM(model, retriever);
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
|
@ -178,6 +208,35 @@ export class ChainRetrievalQa implements INodeType {
|
|||
throw new NodeOperationError(this.getNode(), 'The ‘query‘ parameter is empty.');
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
systemPromptTemplate?: string;
|
||||
};
|
||||
|
||||
const chainParameters = {} as {
|
||||
prompt?: PromptTemplate | ChatPromptTemplate;
|
||||
};
|
||||
|
||||
if (options.systemPromptTemplate !== undefined) {
|
||||
if (isChatInstance(model)) {
|
||||
const messages = [
|
||||
SystemMessagePromptTemplate.fromTemplate(options.systemPromptTemplate),
|
||||
HumanMessagePromptTemplate.fromTemplate('{question}'),
|
||||
];
|
||||
const chatPromptTemplate = ChatPromptTemplate.fromMessages(messages);
|
||||
|
||||
chainParameters.prompt = chatPromptTemplate;
|
||||
} else {
|
||||
const completionPromptTemplate = new PromptTemplate({
|
||||
template: options.systemPromptTemplate,
|
||||
inputVariables: ['context', 'question'],
|
||||
});
|
||||
|
||||
chainParameters.prompt = completionPromptTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
const chain = RetrievalQAChain.fromLLM(model, retriever, chainParameters);
|
||||
|
||||
const response = await chain.withConfig(getTracingConfig(this)).invoke({ query });
|
||||
returnData.push({ json: { response } });
|
||||
} catch (error) {
|
||||
|
|
|
@ -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: {},
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.60.0",
|
||||
"version": "1.61.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -71,5 +71,11 @@ module.exports = {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
|
||||
rules: {
|
||||
'n8n-local-rules/no-dynamic-import-template': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"}',
|
||||
|
|
|
@ -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';
|
||||
|
|
@ -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
|
25
packages/cli/src/databases/entities/api-key.ts
Normal file
25
packages/cli/src/databases/entities/api-key.ts
Normal 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;
|
||||
}
|
|
@ -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' })
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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};`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;');
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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> {
|
|
@ -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> {
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -80,6 +80,7 @@ export namespace ExecutionSummaries {
|
|||
startedBefore: string;
|
||||
annotationTags: string[]; // tag IDs
|
||||
vote: AnnotationVote;
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
type AccessFields = {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
80
packages/cli/src/services/public-api-key.service.ts
Normal file
80
packages/cli/src/services/public-api-key.service.ts
Normal 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')}`;
|
||||
}
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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' };
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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({});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
() => {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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';
|
|
@ -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
Loading…
Reference in a new issue