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
|
- name: Trigger a release note
|
||||||
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
|
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
|
||||||
|
|
||||||
merge-back-into-master:
|
# merge-back-into-master:
|
||||||
name: Merge back into master
|
# name: Merge back into master
|
||||||
needs: [publish-to-npm, create-github-release]
|
# needs: [publish-to-npm, create-github-release]
|
||||||
if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
|
# if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
|
||||||
runs-on: ubuntu-latest
|
# runs-on: ubuntu-latest
|
||||||
steps:
|
# steps:
|
||||||
- uses: actions/checkout@v4.1.1
|
# - uses: actions/checkout@v4.1.1
|
||||||
with:
|
# with:
|
||||||
fetch-depth: 0
|
# fetch-depth: 0
|
||||||
- run: |
|
# - run: |
|
||||||
git checkout --track origin/master
|
# git checkout --track origin/master
|
||||||
git config user.name "github-actions[bot]"
|
# git config user.name "github-actions[bot]"
|
||||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
# git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||||
git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
|
# git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
|
||||||
git push origin master
|
# git push origin master
|
||||||
git push origin :${{github.event.pull_request.base.ref}}
|
# git push origin :${{github.event.pull_request.base.ref}}
|
||||||
|
|
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)
|
# [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();
|
cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
|
||||||
executionsTab.getters.executionListItems().eq(11).should('be.visible');
|
executionsTab.getters.executionListItems().eq(11).should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should redirect back to editor after seeing a couple of execution using browser back button', () => {
|
||||||
|
createMockExecutions();
|
||||||
|
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
|
||||||
|
|
||||||
|
executionsTab.actions.switchToExecutionsTab();
|
||||||
|
|
||||||
|
cy.wait(['@getExecutions']);
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
|
||||||
|
executionsTab.getters.executionListItems().eq(2).click();
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
executionsTab.getters.executionListItems().eq(4).click();
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
executionsTab.getters.executionListItems().eq(6).click();
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
|
||||||
|
cy.go('back');
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
cy.go('back');
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
cy.go('back');
|
||||||
|
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
|
||||||
|
cy.go('back');
|
||||||
|
|
||||||
|
cy.url().should('not.include', '/executions');
|
||||||
|
cy.url().should('include', '/workflow/');
|
||||||
|
workflowPage.getters.nodeViewRoot().should('be.visible');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when new workflow is not saved', () => {
|
describe('when new workflow is not saved', () => {
|
||||||
|
|
|
@ -674,6 +674,23 @@ describe('NDV', () => {
|
||||||
ndv.getters.parameterInput('operation').find('input').should('have.value', 'Delete');
|
ndv.getters.parameterInput('operation').find('input').should('have.value', 'Delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should show a notice when remote options cannot be fetched because of missing credentials', () => {
|
||||||
|
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 403 }).as(
|
||||||
|
'parameterOptions',
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowPage.actions.addInitialNodeToCanvas(NOTION_NODE_NAME, {
|
||||||
|
keepNdvOpen: true,
|
||||||
|
action: 'Update a database page',
|
||||||
|
});
|
||||||
|
|
||||||
|
ndv.actions.addItemToFixedCollection('propertiesUi');
|
||||||
|
ndv.getters
|
||||||
|
.parameterInput('key')
|
||||||
|
.find('input')
|
||||||
|
.should('have.value', 'Set up credential to see options');
|
||||||
|
});
|
||||||
|
|
||||||
it('Should show error state when remote options cannot be fetched', () => {
|
it('Should show error state when remote options cannot be fetched', () => {
|
||||||
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as(
|
cy.intercept('POST', '/rest/dynamic-node-parameters/options', { statusCode: 500 }).as(
|
||||||
'parameterOptions',
|
'parameterOptions',
|
||||||
|
@ -684,6 +701,11 @@ describe('NDV', () => {
|
||||||
action: 'Update a database page',
|
action: 'Update a database page',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clickCreateNewCredential();
|
||||||
|
setCredentialValues({
|
||||||
|
apiKey: 'sk_test_123',
|
||||||
|
});
|
||||||
|
|
||||||
ndv.actions.addItemToFixedCollection('propertiesUi');
|
ndv.actions.addItemToFixedCollection('propertiesUi');
|
||||||
ndv.getters
|
ndv.getters
|
||||||
.parameterInput('key')
|
.parameterInput('key')
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.15",
|
"node": ">=20.15",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-benchmark",
|
"name": "@n8n/n8n-benchmark",
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"description": "Cli for running benchmark tests for n8n",
|
"description": "Cli for running benchmark tests for n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -105,9 +105,8 @@ async function main() {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
console.error('');
|
console.error('');
|
||||||
await printContainerStatus(dockerComposeClient);
|
await printContainerStatus(dockerComposeClient);
|
||||||
console.error('');
|
|
||||||
await dumpLogs(dockerComposeClient);
|
|
||||||
} finally {
|
} finally {
|
||||||
|
await dumpLogs(dockerComposeClient);
|
||||||
await dockerComposeClient.$('down');
|
await dockerComposeClient.$('down');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,7 +117,7 @@ async function printContainerStatus(dockerComposeClient) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dumpLogs(dockerComposeClient) {
|
async function dumpLogs(dockerComposeClient) {
|
||||||
console.error('Container logs:');
|
console.info('Container logs:');
|
||||||
await dockerComposeClient.$('logs');
|
await dockerComposeClient.$('logs');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
@ -10,10 +10,21 @@ import {
|
||||||
import { RetrievalQAChain } from 'langchain/chains';
|
import { RetrievalQAChain } from 'langchain/chains';
|
||||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||||
|
import {
|
||||||
|
ChatPromptTemplate,
|
||||||
|
SystemMessagePromptTemplate,
|
||||||
|
HumanMessagePromptTemplate,
|
||||||
|
PromptTemplate,
|
||||||
|
} from '@langchain/core/prompts';
|
||||||
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
||||||
import { getPromptInputByType } from '../../../utils/helpers';
|
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
|
||||||
import { getTracingConfig } from '../../../utils/tracing';
|
import { getTracingConfig } from '../../../utils/tracing';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
|
||||||
|
If you don't know the answer, just say that you don't know, don't try to make up an answer.
|
||||||
|
----------------
|
||||||
|
{context}`;
|
||||||
|
|
||||||
export class ChainRetrievalQa implements INodeType {
|
export class ChainRetrievalQa implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
displayName: 'Question and Answer Chain',
|
displayName: 'Question and Answer Chain',
|
||||||
|
@ -137,6 +148,26 @@ export class ChainRetrievalQa implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Options',
|
||||||
|
name: 'options',
|
||||||
|
type: 'collection',
|
||||||
|
default: {},
|
||||||
|
placeholder: 'Add Option',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'System Prompt Template',
|
||||||
|
name: 'systemPromptTemplate',
|
||||||
|
type: 'string',
|
||||||
|
default: SYSTEM_PROMPT_TEMPLATE,
|
||||||
|
description:
|
||||||
|
'Template string used for the system prompt. This should include the variable `{context}` for the provided context. For text completion models, you should also include the variable `{question}` for the user’s query.',
|
||||||
|
typeOptions: {
|
||||||
|
rows: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -154,7 +185,6 @@ export class ChainRetrievalQa implements INodeType {
|
||||||
)) as BaseRetriever;
|
)) as BaseRetriever;
|
||||||
|
|
||||||
const items = this.getInputData();
|
const items = this.getInputData();
|
||||||
const chain = RetrievalQAChain.fromLLM(model, retriever);
|
|
||||||
|
|
||||||
const returnData: INodeExecutionData[] = [];
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
@ -178,6 +208,35 @@ export class ChainRetrievalQa implements INodeType {
|
||||||
throw new NodeOperationError(this.getNode(), 'The ‘query‘ parameter is empty.');
|
throw new NodeOperationError(this.getNode(), 'The ‘query‘ parameter is empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||||
|
systemPromptTemplate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chainParameters = {} as {
|
||||||
|
prompt?: PromptTemplate | ChatPromptTemplate;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.systemPromptTemplate !== undefined) {
|
||||||
|
if (isChatInstance(model)) {
|
||||||
|
const messages = [
|
||||||
|
SystemMessagePromptTemplate.fromTemplate(options.systemPromptTemplate),
|
||||||
|
HumanMessagePromptTemplate.fromTemplate('{question}'),
|
||||||
|
];
|
||||||
|
const chatPromptTemplate = ChatPromptTemplate.fromMessages(messages);
|
||||||
|
|
||||||
|
chainParameters.prompt = chatPromptTemplate;
|
||||||
|
} else {
|
||||||
|
const completionPromptTemplate = new PromptTemplate({
|
||||||
|
template: options.systemPromptTemplate,
|
||||||
|
inputVariables: ['context', 'question'],
|
||||||
|
});
|
||||||
|
|
||||||
|
chainParameters.prompt = completionPromptTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = RetrievalQAChain.fromLLM(model, retriever, chainParameters);
|
||||||
|
|
||||||
const response = await chain.withConfig(getTracingConfig(this)).invoke({ query });
|
const response = await chain.withConfig(getTracingConfig(this)).invoke({ query });
|
||||||
returnData.push({ json: { response } });
|
returnData.push({ json: { response } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -275,7 +275,11 @@ export class ToolHttpRequest implements INodeType {
|
||||||
method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods,
|
method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods,
|
||||||
url: this.getNodeParameter('url', itemIndex) as string,
|
url: this.getNodeParameter('url', itemIndex) as string,
|
||||||
qs: {},
|
qs: {},
|
||||||
headers: {},
|
headers: {
|
||||||
|
// FIXME: This is a workaround to prevent the node from sending a default User-Agent (`n8n`) when the header is not set.
|
||||||
|
// Needs to be replaced with a proper fix after NODE-1777 is resolved
|
||||||
|
'User-Agent': undefined,
|
||||||
|
},
|
||||||
body: {},
|
body: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-nodes-langchain",
|
"name": "@n8n/n8n-nodes-langchain",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -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('source-map-support').install();
|
||||||
require('reflect-metadata');
|
require('reflect-metadata');
|
||||||
|
|
||||||
|
// Skip loading dotenv in e2e tests.
|
||||||
|
// Also, do not use `inE2ETests` from constants here, because that'd end up code that might read from `process.env` before the values are loaded from an `.env` file.
|
||||||
|
if (process.env.E2E_TESTS !== 'true') {
|
||||||
|
// Loading dotenv early ensures that `process.env` is up-to-date everywhere in code
|
||||||
|
require('dotenv').config();
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.NODEJS_PREFER_IPV4 === 'true') {
|
if (process.env.NODEJS_PREFER_IPV4 === 'true') {
|
||||||
require('dns').setDefaultResultOrder('ipv4first');
|
require('dns').setDefaultResultOrder('ipv4first');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import convict from 'convict';
|
import convict from 'convict';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { flatten } from 'flat';
|
import { flatten } from 'flat';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
|
@ -22,8 +21,6 @@ if (inE2ETests) {
|
||||||
process.env.N8N_PUBLIC_API_DISABLED = 'true';
|
process.env.N8N_PUBLIC_API_DISABLED = 'true';
|
||||||
process.env.SKIP_STATISTICS_EVENTS = 'true';
|
process.env.SKIP_STATISTICS_EVENTS = 'true';
|
||||||
process.env.N8N_SECURE_COOKIE = 'false';
|
process.env.N8N_SECURE_COOKIE = 'false';
|
||||||
} else {
|
|
||||||
dotenv.config();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load schema after process.env has been overwritten
|
// Load schema after process.env has been overwritten
|
||||||
|
|
|
@ -2,11 +2,14 @@ import { UserUpdateRequestDto } from '@n8n/api-types';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { mock, anyObject } from 'jest-mock-extended';
|
import { mock, anyObject } from 'jest-mock-extended';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { randomString } from 'n8n-workflow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||||
import { API_KEY_PREFIX, MeController } from '@/controllers/me.controller';
|
import { MeController } from '@/controllers/me.controller';
|
||||||
|
import type { ApiKey } from '@/databases/entities/api-key';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||||
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
||||||
import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository';
|
import { InvalidAuthTokenRepository } from '@/databases/repositories/invalid-auth-token.repository';
|
||||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
|
@ -18,6 +21,7 @@ import type { PublicUser } from '@/interfaces';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { MfaService } from '@/mfa/mfa.service';
|
import { MfaService } from '@/mfa/mfa.service';
|
||||||
import type { AuthenticatedRequest, MeRequest } from '@/requests';
|
import type { AuthenticatedRequest, MeRequest } from '@/requests';
|
||||||
|
import { API_KEY_PREFIX } from '@/services/public-api-key.service';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
import { badPasswords } from '@test/test-data';
|
import { badPasswords } from '@test/test-data';
|
||||||
|
@ -30,6 +34,7 @@ describe('MeController', () => {
|
||||||
const userService = mockInstance(UserService);
|
const userService = mockInstance(UserService);
|
||||||
const userRepository = mockInstance(UserRepository);
|
const userRepository = mockInstance(UserRepository);
|
||||||
const mockMfaService = mockInstance(MfaService);
|
const mockMfaService = mockInstance(MfaService);
|
||||||
|
const apiKeysRepository = mockInstance(ApiKeyRepository);
|
||||||
mockInstance(AuthUserRepository);
|
mockInstance(AuthUserRepository);
|
||||||
mockInstance(InvalidAuthTokenRepository);
|
mockInstance(InvalidAuthTokenRepository);
|
||||||
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
||||||
|
@ -412,27 +417,63 @@ describe('MeController', () => {
|
||||||
describe('API Key methods', () => {
|
describe('API Key methods', () => {
|
||||||
let req: AuthenticatedRequest;
|
let req: AuthenticatedRequest;
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) });
|
req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createAPIKey', () => {
|
describe('createAPIKey', () => {
|
||||||
it('should create and save an API key', async () => {
|
it('should create and save an API key', async () => {
|
||||||
const { apiKey } = await controller.createAPIKey(req);
|
const apiKeyData = {
|
||||||
expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey });
|
id: '123',
|
||||||
|
userId: '123',
|
||||||
|
label: 'My API Key',
|
||||||
|
apiKey: `${API_KEY_PREFIX}${randomString(42)}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as ApiKey;
|
||||||
|
|
||||||
|
apiKeysRepository.upsert.mockImplementation();
|
||||||
|
|
||||||
|
apiKeysRepository.findOneByOrFail.mockResolvedValue(apiKeyData);
|
||||||
|
|
||||||
|
const newApiKey = await controller.createAPIKey(req);
|
||||||
|
|
||||||
|
expect(apiKeysRepository.upsert).toHaveBeenCalled();
|
||||||
|
expect(apiKeyData).toEqual(newApiKey);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAPIKey', () => {
|
describe('getAPIKeys', () => {
|
||||||
it('should return the users api key redacted', async () => {
|
it('should return the users api keys redacted', async () => {
|
||||||
const { apiKey } = await controller.getAPIKey(req);
|
const apiKeyData = {
|
||||||
expect(apiKey).not.toEqual(req.user.apiKey);
|
id: '123',
|
||||||
|
userId: '123',
|
||||||
|
label: 'My API Key',
|
||||||
|
apiKey: `${API_KEY_PREFIX}${randomString(42)}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as ApiKey;
|
||||||
|
|
||||||
|
apiKeysRepository.findBy.mockResolvedValue([apiKeyData]);
|
||||||
|
|
||||||
|
const apiKeys = await controller.getAPIKeys(req);
|
||||||
|
expect(apiKeys[0].apiKey).not.toEqual(apiKeyData.apiKey);
|
||||||
|
expect(apiKeysRepository.findBy).toHaveBeenCalledWith({ userId: req.user.id });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteAPIKey', () => {
|
describe('deleteAPIKey', () => {
|
||||||
it('should delete the API key', async () => {
|
it('should delete the API key', async () => {
|
||||||
|
const user = mock<User>({
|
||||||
|
id: '123',
|
||||||
|
password: 'password',
|
||||||
|
authIdentities: [],
|
||||||
|
role: 'global:member',
|
||||||
|
mfaEnabled: false,
|
||||||
|
});
|
||||||
|
const req = mock<MeRequest.DeleteAPIKey>({ user, params: { id: user.id } });
|
||||||
await controller.deleteAPIKey(req);
|
await controller.deleteAPIKey(req);
|
||||||
expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey: null });
|
expect(apiKeysRepository.delete).toHaveBeenCalledWith({
|
||||||
|
userId: req.user.id,
|
||||||
|
id: req.params.id,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators';
|
import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators';
|
||||||
import { AnnotationTagsRequest } from '@/requests';
|
import { AnnotationTagsRequest } from '@/requests';
|
||||||
import { AnnotationTagService } from '@/services/annotation-tag.service';
|
import { AnnotationTagService } from '@/services/annotation-tag.service.ee';
|
||||||
|
|
||||||
@RestController('/annotation-tags')
|
@RestController('/annotation-tags')
|
||||||
export class AnnotationTagsController {
|
export class AnnotationTagsController {
|
|
@ -4,7 +4,6 @@ import {
|
||||||
UserUpdateRequestDto,
|
UserUpdateRequestDto,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { type RequestHandler, Response } from 'express';
|
import { type RequestHandler, Response } from 'express';
|
||||||
|
|
||||||
import { AuthService } from '@/auth/auth.service';
|
import { AuthService } from '@/auth/auth.service';
|
||||||
|
@ -22,13 +21,12 @@ import { MfaService } from '@/mfa/mfa.service';
|
||||||
import { isApiEnabled } from '@/public-api';
|
import { isApiEnabled } from '@/public-api';
|
||||||
import { AuthenticatedRequest, MeRequest } from '@/requests';
|
import { AuthenticatedRequest, MeRequest } from '@/requests';
|
||||||
import { PasswordUtility } from '@/services/password.utility';
|
import { PasswordUtility } from '@/services/password.utility';
|
||||||
|
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers';
|
import { isSamlLicensedAndEnabled } from '@/sso/saml/saml-helpers';
|
||||||
|
|
||||||
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
|
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
|
||||||
|
|
||||||
export const API_KEY_PREFIX = 'n8n_api_';
|
|
||||||
|
|
||||||
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
|
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
|
||||||
if (isApiEnabled()) {
|
if (isApiEnabled()) {
|
||||||
next();
|
next();
|
||||||
|
@ -48,6 +46,7 @@ export class MeController {
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly eventService: EventService,
|
private readonly eventService: EventService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
|
private readonly publicApiKeyService: PublicApiKeyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -219,34 +218,32 @@ export class MeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an API Key
|
* Create an API Key
|
||||||
*/
|
*/
|
||||||
@Post('/api-key', { middlewares: [isApiEnabledMiddleware] })
|
@Post('/api-keys', { middlewares: [isApiEnabledMiddleware] })
|
||||||
async createAPIKey(req: AuthenticatedRequest) {
|
async createAPIKey(req: AuthenticatedRequest) {
|
||||||
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;
|
const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user);
|
||||||
|
|
||||||
await this.userService.update(req.user.id, { apiKey });
|
|
||||||
|
|
||||||
this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false });
|
this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false });
|
||||||
|
|
||||||
return { apiKey };
|
return newApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an API Key
|
* Get API keys
|
||||||
*/
|
*/
|
||||||
@Get('/api-key', { middlewares: [isApiEnabledMiddleware] })
|
@Get('/api-keys', { middlewares: [isApiEnabledMiddleware] })
|
||||||
async getAPIKey(req: AuthenticatedRequest) {
|
async getAPIKeys(req: AuthenticatedRequest) {
|
||||||
const apiKey = this.redactApiKey(req.user.apiKey);
|
const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user);
|
||||||
return { apiKey };
|
return apiKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes an API Key
|
* Delete an API Key
|
||||||
*/
|
*/
|
||||||
@Delete('/api-key', { middlewares: [isApiEnabledMiddleware] })
|
@Delete('/api-keys/:id', { middlewares: [isApiEnabledMiddleware] })
|
||||||
async deleteAPIKey(req: AuthenticatedRequest) {
|
async deleteAPIKey(req: MeRequest.DeleteAPIKey) {
|
||||||
await this.userService.update(req.user.id, { apiKey: null });
|
await this.publicApiKeyService.deleteApiKeyForUser(req.user, req.params.id);
|
||||||
|
|
||||||
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
|
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
|
||||||
|
|
||||||
|
@ -273,14 +270,4 @@ export class MeController {
|
||||||
|
|
||||||
return user.settings;
|
return user.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
private redactApiKey(apiKey: string | null) {
|
|
||||||
if (!apiKey) return;
|
|
||||||
const keepLength = 5;
|
|
||||||
return (
|
|
||||||
API_KEY_PREFIX +
|
|
||||||
apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) +
|
|
||||||
'*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ describe('User Entity', () => {
|
||||||
firstName: 'Don',
|
firstName: 'Don',
|
||||||
lastName: 'Joe',
|
lastName: 'Joe',
|
||||||
password: '123456789',
|
password: '123456789',
|
||||||
apiKey: '123',
|
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(user)).toEqual(
|
expect(JSON.stringify(user)).toEqual(
|
||||||
'{"email":"test@example.com","firstName":"Don","lastName":"Joe"}',
|
'{"email":"test@example.com","firstName":"Don","lastName":"Joe"}',
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
|
import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
|
||||||
import { IsString, Length } from 'class-validator';
|
import { IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
|
import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
|
||||||
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
|
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
|
||||||
|
|
||||||
import { WithTimestampsAndStringId } from './abstract-entity';
|
import { WithTimestampsAndStringId } from './abstract-entity';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
||||||
|
|
||||||
import type { AnnotationTagEntity } from './annotation-tag-entity';
|
import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
|
||||||
import type { ExecutionAnnotation } from './execution-annotation';
|
import type { ExecutionAnnotation } from './execution-annotation.ee';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This entity represents the junction table between the execution annotations and the tags
|
* This entity represents the junction table between the execution annotations and the tags
|
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';
|
} from '@n8n/typeorm';
|
||||||
import type { AnnotationVote } from 'n8n-workflow';
|
import type { AnnotationVote } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { AnnotationTagEntity } from './annotation-tag-entity';
|
import type { AnnotationTagEntity } from './annotation-tag-entity.ee';
|
||||||
import type { AnnotationTagMapping } from './annotation-tag-mapping';
|
import type { AnnotationTagMapping } from './annotation-tag-mapping.ee';
|
||||||
import { ExecutionEntity } from './execution-entity';
|
import { ExecutionEntity } from './execution-entity';
|
||||||
|
|
||||||
@Entity({ name: 'execution_annotations' })
|
@Entity({ name: 'execution_annotations' })
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from '@n8n/typeorm';
|
} from '@n8n/typeorm';
|
||||||
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
|
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
|
import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
|
||||||
|
|
||||||
import { datetimeColumnType } from './abstract-entity';
|
import { datetimeColumnType } from './abstract-entity';
|
||||||
import type { ExecutionData } from './execution-data';
|
import type { ExecutionData } from './execution-data';
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { AnnotationTagEntity } from './annotation-tag-entity';
|
import { AnnotationTagEntity } from './annotation-tag-entity.ee';
|
||||||
import { AnnotationTagMapping } from './annotation-tag-mapping';
|
import { AnnotationTagMapping } from './annotation-tag-mapping.ee';
|
||||||
|
import { ApiKey } from './api-key';
|
||||||
import { AuthIdentity } from './auth-identity';
|
import { AuthIdentity } from './auth-identity';
|
||||||
import { AuthProviderSyncHistory } from './auth-provider-sync-history';
|
import { AuthProviderSyncHistory } from './auth-provider-sync-history';
|
||||||
import { AuthUser } from './auth-user';
|
import { AuthUser } from './auth-user';
|
||||||
import { CredentialsEntity } from './credentials-entity';
|
import { CredentialsEntity } from './credentials-entity';
|
||||||
import { EventDestinations } from './event-destinations';
|
import { EventDestinations } from './event-destinations';
|
||||||
import { ExecutionAnnotation } from './execution-annotation';
|
import { ExecutionAnnotation } from './execution-annotation.ee';
|
||||||
import { ExecutionData } from './execution-data';
|
import { ExecutionData } from './execution-data';
|
||||||
import { ExecutionEntity } from './execution-entity';
|
import { ExecutionEntity } from './execution-entity';
|
||||||
import { ExecutionMetadata } from './execution-metadata';
|
import { ExecutionMetadata } from './execution-metadata';
|
||||||
|
@ -54,4 +55,5 @@ export const entities = {
|
||||||
WorkflowHistory,
|
WorkflowHistory,
|
||||||
Project,
|
Project,
|
||||||
ProjectRelation,
|
ProjectRelation,
|
||||||
|
ApiKey,
|
||||||
};
|
};
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { NoUrl } from '@/validators/no-url.validator';
|
||||||
import { NoXss } from '@/validators/no-xss.validator';
|
import { NoXss } from '@/validators/no-xss.validator';
|
||||||
|
|
||||||
import { WithTimestamps, jsonColumnType } from './abstract-entity';
|
import { WithTimestamps, jsonColumnType } from './abstract-entity';
|
||||||
|
import type { ApiKey } from './api-key';
|
||||||
import type { AuthIdentity } from './auth-identity';
|
import type { AuthIdentity } from './auth-identity';
|
||||||
import type { ProjectRelation } from './project-relation';
|
import type { ProjectRelation } from './project-relation';
|
||||||
import type { SharedCredentials } from './shared-credentials';
|
import type { SharedCredentials } from './shared-credentials';
|
||||||
|
@ -89,6 +90,9 @@ export class User extends WithTimestamps implements IUser {
|
||||||
@OneToMany('AuthIdentity', 'user')
|
@OneToMany('AuthIdentity', 'user')
|
||||||
authIdentities: AuthIdentity[];
|
authIdentities: AuthIdentity[];
|
||||||
|
|
||||||
|
@OneToMany('ApiKey', 'user')
|
||||||
|
apiKeys: ApiKey[];
|
||||||
|
|
||||||
@OneToMany('SharedWorkflow', 'user')
|
@OneToMany('SharedWorkflow', 'user')
|
||||||
sharedWorkflows: SharedWorkflow[];
|
sharedWorkflows: SharedWorkflow[];
|
||||||
|
|
||||||
|
@ -107,10 +111,6 @@ export class User extends WithTimestamps implements IUser {
|
||||||
this.email = this.email?.toLowerCase() ?? null;
|
this.email = this.email?.toLowerCase() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Column({ type: String, nullable: true })
|
|
||||||
@Index({ unique: true })
|
|
||||||
apiKey: string | null;
|
|
||||||
|
|
||||||
@Column({ type: Boolean, default: false })
|
@Column({ type: Boolean, default: false })
|
||||||
mfaEnabled: boolean;
|
mfaEnabled: boolean;
|
||||||
|
|
||||||
|
@ -151,7 +151,7 @@ export class User extends WithTimestamps implements IUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
const { password, apiKey, ...rest } = this;
|
const { password, ...rest } = this;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
||||||
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
||||||
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
||||||
|
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -128,4 +129,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
CreateInvalidAuthTokenTable1723627610222,
|
CreateInvalidAuthTokenTable1723627610222,
|
||||||
RefactorExecutionIndices1723796243146,
|
RefactorExecutionIndices1723796243146,
|
||||||
CreateAnnotationTables1724753530828,
|
CreateAnnotationTables1724753530828,
|
||||||
|
AddApiKeysTable1724951148974,
|
||||||
];
|
];
|
||||||
|
|
|
@ -63,6 +63,7 @@ import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101
|
||||||
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable';
|
||||||
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices';
|
||||||
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables';
|
||||||
|
import { AddApiKeysTable1724951148974 } from '../common/1724951148974-AddApiKeysTable';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -128,4 +129,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
CreateInvalidAuthTokenTable1723627610222,
|
CreateInvalidAuthTokenTable1723627610222,
|
||||||
RefactorExecutionIndices1723796243146,
|
RefactorExecutionIndices1723796243146,
|
||||||
CreateAnnotationTables1724753530828,
|
CreateAnnotationTables1724753530828,
|
||||||
|
AddApiKeysTable1724951148974,
|
||||||
];
|
];
|
||||||
|
|
|
@ -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 { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete';
|
||||||
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
|
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
|
||||||
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
||||||
|
import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable';
|
||||||
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
||||||
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
|
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
|
||||||
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
|
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
|
||||||
|
@ -122,6 +123,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
CreateInvalidAuthTokenTable1723627610222,
|
CreateInvalidAuthTokenTable1723627610222,
|
||||||
RefactorExecutionIndices1723796243146,
|
RefactorExecutionIndices1723796243146,
|
||||||
CreateAnnotationTables1724753530828,
|
CreateAnnotationTables1724753530828,
|
||||||
|
AddApiKeysTable1724951148974,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
|
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapping> {
|
export class AnnotationTagMappingRepository extends Repository<AnnotationTagMapping> {
|
|
@ -1,7 +1,7 @@
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AnnotationTagRepository extends Repository<AnnotationTagEntity> {
|
export class AnnotationTagRepository extends Repository<AnnotationTagEntity> {
|
|
@ -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 { DataSource, Repository } from '@n8n/typeorm';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
|
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class ExecutionAnnotationRepository extends Repository<ExecutionAnnotation> {
|
export class ExecutionAnnotationRepository extends Repository<ExecutionAnnotation> {
|
||||||
|
|
|
@ -36,9 +36,9 @@ import type {
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping';
|
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
|
||||||
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation';
|
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
|
||||||
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
|
import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error';
|
||||||
import type { ExecutionSummaries } from '@/executions/execution.types';
|
import type { ExecutionSummaries } from '@/executions/execution.types';
|
||||||
import type {
|
import type {
|
||||||
|
@ -54,6 +54,8 @@ import { ExecutionDataRepository } from './execution-data.repository';
|
||||||
import type { ExecutionData } from '../entities/execution-data';
|
import type { ExecutionData } from '../entities/execution-data';
|
||||||
import { ExecutionEntity } from '../entities/execution-entity';
|
import { ExecutionEntity } from '../entities/execution-entity';
|
||||||
import { ExecutionMetadata } from '../entities/execution-metadata';
|
import { ExecutionMetadata } from '../entities/execution-metadata';
|
||||||
|
import { SharedWorkflow } from '../entities/shared-workflow';
|
||||||
|
import { WorkflowEntity } from '../entities/workflow-entity';
|
||||||
|
|
||||||
export interface IGetExecutionsQueryFilter {
|
export interface IGetExecutionsQueryFilter {
|
||||||
id?: FindOperator<string> | string;
|
id?: FindOperator<string> | string;
|
||||||
|
@ -874,6 +876,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
metadata,
|
metadata,
|
||||||
annotationTags,
|
annotationTags,
|
||||||
vote,
|
vote,
|
||||||
|
projectId,
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const fields = Object.keys(this.summaryFields)
|
const fields = Object.keys(this.summaryFields)
|
||||||
|
@ -945,6 +948,12 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
qb.innerJoin(WorkflowEntity, 'w', 'w.id = execution.workflowId')
|
||||||
|
.innerJoin(SharedWorkflow, 'sw', 'sw.workflowId = w.id')
|
||||||
|
.where('sw.projectId = :projectId', { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { QueryFailedError } from '@n8n/typeorm';
|
import { QueryFailedError } from '@n8n/typeorm';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow';
|
import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
@ -67,6 +68,8 @@ export const initErrorHandling = async () => {
|
||||||
beforeSend(event, { originalException }) {
|
beforeSend(event, { originalException }) {
|
||||||
if (!originalException) return null;
|
if (!originalException) return null;
|
||||||
|
|
||||||
|
if (originalException instanceof AxiosError) return null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
originalException instanceof QueryFailedError &&
|
originalException instanceof QueryFailedError &&
|
||||||
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
|
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { ActiveExecutions } from '@/active-executions';
|
||||||
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository';
|
import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository.ee';
|
||||||
import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository';
|
import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import type { IGetExecutionsQueryFilter } from '@/databases/repositories/execution.repository';
|
import type { IGetExecutionsQueryFilter } from '@/databases/repositories/execution.repository';
|
||||||
|
|
|
@ -80,6 +80,7 @@ export namespace ExecutionSummaries {
|
||||||
startedBefore: string;
|
startedBefore: string;
|
||||||
annotationTags: string[]; // tag IDs
|
annotationTags: string[]; // tag IDs
|
||||||
vote: AnnotationVote;
|
vote: AnnotationVote;
|
||||||
|
projectId: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type AccessFields = {
|
type AccessFields = {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
|
|
||||||
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
|
|
@ -26,7 +26,7 @@ import type {
|
||||||
import type PCancelable from 'p-cancelable';
|
import type PCancelable from 'p-cancelable';
|
||||||
|
|
||||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import type { AuthProviderType } from '@/databases/entities/auth-identity';
|
import type { AuthProviderType } from '@/databases/entities/auth-identity';
|
||||||
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
|
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
|
||||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||||
|
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import picocolors from 'picocolors';
|
||||||
import { Container, Service } from 'typedi';
|
import { Container, Service } from 'typedi';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -146,6 +147,7 @@ export class LoadNodesAndCredentials {
|
||||||
path.join(nodeModulesDir, packagePath),
|
path.join(nodeModulesDir, packagePath),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.logger.error((error as Error).message);
|
||||||
ErrorReporter.error(error);
|
ErrorReporter.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -258,6 +260,13 @@ export class LoadNodesAndCredentials {
|
||||||
dir: string,
|
dir: string,
|
||||||
) {
|
) {
|
||||||
const loader = new constructor(dir, this.excludeNodes, this.includeNodes);
|
const loader = new constructor(dir, this.excludeNodes, this.includeNodes);
|
||||||
|
if (loader.packageName in this.loaders) {
|
||||||
|
throw new ApplicationError(
|
||||||
|
picocolors.red(
|
||||||
|
`nodes package ${loader.packageName} is already loaded.\n Please delete this second copy at path ${dir}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
await loader.loadAll();
|
await loader.loadAll();
|
||||||
this.loaders[loader.packageName] = loader;
|
this.loaders[loader.packageName] = loader;
|
||||||
return loader;
|
return loader;
|
||||||
|
|
|
@ -10,10 +10,10 @@ import { Container } from 'typedi';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import YAML from 'yamljs';
|
import YAML from 'yamljs';
|
||||||
|
|
||||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import type { AuthenticatedRequest } from '@/requests';
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
|
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
async function createApiRouter(
|
async function createApiRouter(
|
||||||
|
@ -90,10 +90,9 @@ async function createApiRouter(
|
||||||
_scopes: unknown,
|
_scopes: unknown,
|
||||||
schema: OpenAPIV3.ApiKeySecurityScheme,
|
schema: OpenAPIV3.ApiKeySecurityScheme,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const apiKey = req.headers[schema.name.toLowerCase()] as string;
|
const providedApiKey = req.headers[schema.name.toLowerCase()] as string;
|
||||||
const user = await Container.get(UserRepository).findOne({
|
|
||||||
where: { apiKey },
|
const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey);
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
|
|
||||||
|
|
|
@ -84,11 +84,7 @@ export declare namespace WorkflowRequest {
|
||||||
type Activate = Get;
|
type Activate = Get;
|
||||||
type GetTags = Get;
|
type GetTags = Get;
|
||||||
type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>;
|
type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>;
|
||||||
type Transfer = AuthenticatedRequest<
|
type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>;
|
||||||
{ workflowId: string },
|
|
||||||
{},
|
|
||||||
{ destinationProjectId: string }
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare namespace UserRequest {
|
export declare namespace UserRequest {
|
||||||
|
|
|
@ -73,11 +73,13 @@ export = {
|
||||||
transferWorkflow: [
|
transferWorkflow: [
|
||||||
projectScope('workflow:move', 'workflow'),
|
projectScope('workflow:move', 'workflow'),
|
||||||
async (req: WorkflowRequest.Transfer, res: express.Response) => {
|
async (req: WorkflowRequest.Transfer, res: express.Response) => {
|
||||||
|
const { id: workflowId } = req.params;
|
||||||
|
|
||||||
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
|
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
|
||||||
|
|
||||||
await Container.get(EnterpriseWorkflowService).transferOne(
|
await Container.get(EnterpriseWorkflowService).transferOne(
|
||||||
req.user,
|
req.user,
|
||||||
req.params.workflowId,
|
workflowId,
|
||||||
body.destinationProjectId,
|
body.destinationProjectId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -186,6 +186,7 @@ export declare namespace CredentialRequest {
|
||||||
|
|
||||||
export declare namespace MeRequest {
|
export declare namespace MeRequest {
|
||||||
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
|
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
|
||||||
|
export type DeleteAPIKey = AuthenticatedRequest<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSetupPayload {
|
export interface UserSetupPayload {
|
||||||
|
|
|
@ -35,7 +35,7 @@ import type { FrontendService } from '@/services/frontend.service';
|
||||||
import { OrchestrationService } from '@/services/orchestration.service';
|
import { OrchestrationService } from '@/services/orchestration.service';
|
||||||
|
|
||||||
import '@/controllers/active-workflows.controller';
|
import '@/controllers/active-workflows.controller';
|
||||||
import '@/controllers/annotation-tags.controller';
|
import '@/controllers/annotation-tags.controller.ee';
|
||||||
import '@/controllers/auth.controller';
|
import '@/controllers/auth.controller';
|
||||||
import '@/controllers/binary-data.controller';
|
import '@/controllers/binary-data.controller';
|
||||||
import '@/controllers/curl.controller';
|
import '@/controllers/curl.controller';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity';
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository';
|
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
|
||||||
import { validateEntity } from '@/generic-helpers';
|
import { validateEntity } from '@/generic-helpers';
|
||||||
import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces';
|
import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces';
|
||||||
|
|
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;
|
withScopes?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
|
const { password, updatedAt, authIdentities, ...rest } = user;
|
||||||
|
|
||||||
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ export function assertReturnedUserProps(user: User) {
|
||||||
expect(user.personalizationAnswers).toBeNull();
|
expect(user.personalizationAnswers).toBeNull();
|
||||||
expect(user.password).toBeUndefined();
|
expect(user.password).toBeUndefined();
|
||||||
expect(user.isPending).toBe(false);
|
expect(user.isPending).toBe(false);
|
||||||
expect(user.apiKey).not.toBeDefined();
|
|
||||||
expect(user.globalScopes).toBeDefined();
|
expect(user.globalScopes).toBeDefined();
|
||||||
expect(user.globalScopes).not.toHaveLength(0);
|
expect(user.globalScopes).not.toHaveLength(0);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { ExecutionService } from '@/executions/execution.service';
|
import { ExecutionService } from '@/executions/execution.service';
|
||||||
import type { ExecutionSummaries } from '@/executions/execution.types';
|
import type { ExecutionSummaries } from '@/executions/execution.types';
|
||||||
|
import { createTeamProject } from '@test-integration/db/projects';
|
||||||
|
|
||||||
import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions';
|
import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions';
|
||||||
import { createWorkflow } from './shared/db/workflows';
|
import { createWorkflow } from './shared/db/workflows';
|
||||||
|
@ -294,6 +295,37 @@ describe('ExecutionService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should filter executions by `projectId`', async () => {
|
||||||
|
const firstProject = await createTeamProject();
|
||||||
|
const secondProject = await createTeamProject();
|
||||||
|
|
||||||
|
const firstWorkflow = await createWorkflow(undefined, firstProject);
|
||||||
|
const secondWorkflow = await createWorkflow(undefined, secondProject);
|
||||||
|
|
||||||
|
await createExecution({ status: 'success' }, firstWorkflow);
|
||||||
|
await createExecution({ status: 'success' }, firstWorkflow);
|
||||||
|
await createExecution({ status: 'success' }, secondWorkflow); // to filter out
|
||||||
|
|
||||||
|
const query: ExecutionSummaries.RangeQuery = {
|
||||||
|
kind: 'range',
|
||||||
|
range: { limit: 20 },
|
||||||
|
accessibleWorkflowIds: [firstWorkflow.id],
|
||||||
|
projectId: firstProject.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await executionService.findRangeWithCount(query);
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
count: 2,
|
||||||
|
estimated: false,
|
||||||
|
results: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ workflowId: firstWorkflow.id }),
|
||||||
|
expect.objectContaining({ workflowId: firstWorkflow.id }),
|
||||||
|
// execution for workflow in second project was filtered out
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should exclude executions by inaccessible `workflowId`', async () => {
|
test('should exclude executions by inaccessible `workflowId`', async () => {
|
||||||
const accessibleWorkflow = await createWorkflow();
|
const accessibleWorkflow = await createWorkflow();
|
||||||
const inaccessibleWorkflow = await createWorkflow();
|
const inaccessibleWorkflow = await createWorkflow();
|
||||||
|
|
|
@ -1,22 +1,29 @@
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { IsNull } from '@n8n/typeorm';
|
|
||||||
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
|
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
|
|
||||||
|
import type { ApiKey } from '@/databases/entities/api-key';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
|
import { PublicApiKeyService } from '@/services/public-api-key.service';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
|
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
|
||||||
import { addApiKey, createOwner, createUser, createUserShell } from './shared/db/users';
|
import { createOwnerWithApiKey, createUser, createUserShell } from './shared/db/users';
|
||||||
import { randomApiKey, randomEmail, randomName, randomValidPassword } from './shared/random';
|
import { randomEmail, randomName, randomValidPassword } from './shared/random';
|
||||||
import * as testDb from './shared/test-db';
|
import * as testDb from './shared/test-db';
|
||||||
import type { SuperAgentTest } from './shared/types';
|
import type { SuperAgentTest } from './shared/types';
|
||||||
import * as utils from './shared/utils/';
|
import * as utils from './shared/utils/';
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
|
const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
|
||||||
|
let publicApiKeyService: PublicApiKeyService;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
publicApiKeyService = Container.get(PublicApiKeyService);
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testDb.truncate(['User']);
|
await testDb.truncate(['User']);
|
||||||
|
@ -28,22 +35,22 @@ describe('When public API is disabled', () => {
|
||||||
let authAgent: SuperAgentTest;
|
let authAgent: SuperAgentTest;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
owner = await createOwner();
|
owner = await createOwnerWithApiKey();
|
||||||
await addApiKey(owner);
|
|
||||||
authAgent = testServer.authAgentFor(owner);
|
authAgent = testServer.authAgentFor(owner);
|
||||||
mockInstance(GlobalConfig, { publicApi: { disabled: true } });
|
mockInstance(GlobalConfig, { publicApi: { disabled: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /me/api-key should 404', async () => {
|
test('POST /me/api-keys should 404', async () => {
|
||||||
await authAgent.post('/me/api-key').expect(404);
|
await authAgent.post('/me/api-keys').expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /me/api-key should 404', async () => {
|
test('GET /me/api-keys should 404', async () => {
|
||||||
await authAgent.get('/me/api-key').expect(404);
|
await authAgent.get('/me/api-keys').expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DELETE /me/api-key should 404', async () => {
|
test('DELETE /me/api-key/:id should 404', async () => {
|
||||||
await authAgent.delete('/me/api-key').expect(404);
|
await authAgent.delete(`/me/api-keys/${1}`).expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -53,7 +60,6 @@ describe('Owner shell', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
ownerShell = await createUserShell('global:owner');
|
ownerShell = await createUserShell('global:owner');
|
||||||
await addApiKey(ownerShell);
|
|
||||||
authOwnerShellAgent = testServer.authAgentFor(ownerShell);
|
authOwnerShellAgent = testServer.authAgentFor(ownerShell);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -63,17 +69,8 @@ describe('Owner shell', () => {
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const {
|
const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
|
||||||
id,
|
response.body.data;
|
||||||
email,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
personalizationAnswers,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isPending,
|
|
||||||
apiKey,
|
|
||||||
} = response.body.data;
|
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBe(validPayload.email.toLowerCase());
|
expect(email).toBe(validPayload.email.toLowerCase());
|
||||||
|
@ -83,7 +80,6 @@ describe('Owner shell', () => {
|
||||||
expect(password).toBeUndefined();
|
expect(password).toBeUndefined();
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(role).toBe('global:owner');
|
expect(role).toBe('global:owner');
|
||||||
expect(apiKey).toBeUndefined();
|
|
||||||
|
|
||||||
const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id });
|
const storedOwnerShell = await Container.get(UserRepository).findOneByOrFail({ id });
|
||||||
|
|
||||||
|
@ -161,37 +157,56 @@ describe('Owner shell', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /me/api-key should create an api key', async () => {
|
test('POST /me/api-keys should create an api key', async () => {
|
||||||
const response = await authOwnerShellAgent.post('/me/api-key');
|
const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
const newApiKey = newApiKeyResponse.body.data as ApiKey;
|
||||||
expect(response.body.data.apiKey).toBeDefined();
|
|
||||||
expect(response.body.data.apiKey).not.toBeNull();
|
|
||||||
|
|
||||||
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({
|
expect(newApiKeyResponse.statusCode).toBe(200);
|
||||||
where: { email: IsNull() },
|
expect(newApiKey).toBeDefined();
|
||||||
|
|
||||||
|
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
|
||||||
|
userId: ownerShell.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey);
|
expect(newStoredApiKey).toEqual({
|
||||||
});
|
id: expect.any(String),
|
||||||
|
label: 'My API Key',
|
||||||
test('GET /me/api-key should fetch the api key redacted', async () => {
|
userId: ownerShell.id,
|
||||||
const response = await authOwnerShellAgent.get('/me/api-key');
|
apiKey: newApiKey.apiKey,
|
||||||
|
createdAt: expect.any(Date),
|
||||||
expect(response.statusCode).toBe(200);
|
updatedAt: expect.any(Date),
|
||||||
expect(response.body.data.apiKey).not.toEqual(ownerShell.apiKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /me/api-key should delete the api key', async () => {
|
|
||||||
const response = await authOwnerShellAgent.delete('/me/api-key');
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({
|
|
||||||
where: { email: IsNull() },
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(storedShellOwner.apiKey).toBeNull();
|
test('GET /me/api-keys should fetch the api key redacted', async () => {
|
||||||
|
const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
|
||||||
|
|
||||||
|
const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys');
|
||||||
|
|
||||||
|
expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
||||||
|
id: newApiKeyResponse.body.data.id,
|
||||||
|
label: 'My API Key',
|
||||||
|
userId: ownerShell.id,
|
||||||
|
apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey),
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /me/api-keys/:id should delete the api key', async () => {
|
||||||
|
const newApiKeyResponse = await authOwnerShellAgent.post('/me/api-keys');
|
||||||
|
|
||||||
|
const deleteApiKeyResponse = await authOwnerShellAgent.delete(
|
||||||
|
`/me/api-keys/${newApiKeyResponse.body.data.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retrieveAllApiKeysResponse = await authOwnerShellAgent.get('/me/api-keys');
|
||||||
|
|
||||||
|
expect(deleteApiKeyResponse.body.data.success).toBe(true);
|
||||||
|
expect(retrieveAllApiKeysResponse.body.data.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -204,10 +219,8 @@ describe('Member', () => {
|
||||||
member = await createUser({
|
member = await createUser({
|
||||||
password: memberPassword,
|
password: memberPassword,
|
||||||
role: 'global:member',
|
role: 'global:member',
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
});
|
||||||
authMemberAgent = testServer.authAgentFor(member);
|
authMemberAgent = testServer.authAgentFor(member);
|
||||||
|
|
||||||
await utils.setInstanceOwnerSetUp(true);
|
await utils.setInstanceOwnerSetUp(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -215,17 +228,8 @@ describe('Member', () => {
|
||||||
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
|
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
|
||||||
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
|
const response = await authMemberAgent.patch('/me').send(validPayload).expect(200);
|
||||||
|
|
||||||
const {
|
const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } =
|
||||||
id,
|
response.body.data;
|
||||||
email,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
personalizationAnswers,
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
isPending,
|
|
||||||
apiKey,
|
|
||||||
} = response.body.data;
|
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBe(validPayload.email.toLowerCase());
|
expect(email).toBe(validPayload.email.toLowerCase());
|
||||||
|
@ -235,7 +239,6 @@ describe('Member', () => {
|
||||||
expect(password).toBeUndefined();
|
expect(password).toBeUndefined();
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(role).toBe('global:member');
|
expect(role).toBe('global:member');
|
||||||
expect(apiKey).toBeUndefined();
|
|
||||||
|
|
||||||
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id });
|
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id });
|
||||||
|
|
||||||
|
@ -275,6 +278,7 @@ describe('Member', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await authMemberAgent.patch('/me/password').send(validPayload);
|
const response = await authMemberAgent.patch('/me/password').send(validPayload);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
|
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
|
||||||
|
|
||||||
|
@ -315,33 +319,59 @@ describe('Member', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /me/api-key should create an api key', async () => {
|
test('POST /me/api-keys should create an api key', async () => {
|
||||||
const response = await testServer.authAgentFor(member).post('/me/api-key');
|
const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(newApiKeyResponse.statusCode).toBe(200);
|
||||||
expect(response.body.data.apiKey).toBeDefined();
|
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
|
||||||
expect(response.body.data.apiKey).not.toBeNull();
|
expect(newApiKeyResponse.body.data.apiKey).not.toBeNull();
|
||||||
|
|
||||||
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id });
|
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
|
||||||
|
userId: member.id,
|
||||||
|
});
|
||||||
|
|
||||||
expect(storedMember.apiKey).toEqual(response.body.data.apiKey);
|
expect(newStoredApiKey).toEqual({
|
||||||
|
id: expect.any(String),
|
||||||
|
label: 'My API Key',
|
||||||
|
userId: member.id,
|
||||||
|
apiKey: newApiKeyResponse.body.data.apiKey,
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /me/api-key should fetch the api key redacted', async () => {
|
test('GET /me/api-keys should fetch the api key redacted', async () => {
|
||||||
const response = await testServer.authAgentFor(member).get('/me/api-key');
|
const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys');
|
||||||
expect(response.body.data.apiKey).not.toEqual(member.apiKey);
|
|
||||||
|
expect(retrieveAllApiKeysResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
|
||||||
|
id: newApiKeyResponse.body.data.id,
|
||||||
|
label: 'My API Key',
|
||||||
|
userId: member.id,
|
||||||
|
apiKey: publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.apiKey),
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newApiKeyResponse.body.data.apiKey).not.toEqual(
|
||||||
|
retrieveAllApiKeysResponse.body.data[0].apiKey,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DELETE /me/api-key should delete the api key', async () => {
|
test('DELETE /me/api-keys/:id should delete the api key', async () => {
|
||||||
const response = await testServer.authAgentFor(member).delete('/me/api-key');
|
const newApiKeyResponse = await testServer.authAgentFor(member).post('/me/api-keys');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
const deleteApiKeyResponse = await testServer
|
||||||
|
.authAgentFor(member)
|
||||||
|
.delete(`/me/api-keys/${newApiKeyResponse.body.data.id}`);
|
||||||
|
|
||||||
const storedMember = await Container.get(UserRepository).findOneByOrFail({ id: member.id });
|
const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/me/api-keys');
|
||||||
|
|
||||||
expect(storedMember.apiKey).toBeNull();
|
expect(deleteApiKeyResponse.body.data.success).toBe(true);
|
||||||
|
expect(retrieveAllApiKeysResponse.body.data.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,8 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre
|
||||||
import { createTeamProject } from '@test-integration/db/projects';
|
import { createTeamProject } from '@test-integration/db/projects';
|
||||||
|
|
||||||
import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials';
|
import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials';
|
||||||
import { addApiKey, createUser, createUserShell } from '../shared/db/users';
|
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
||||||
import { randomApiKey, randomName } from '../shared/random';
|
import { randomName } from '../shared/random';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { CredentialPayload, SaveCredentialFunction } from '../shared/types';
|
import type { CredentialPayload, SaveCredentialFunction } from '../shared/types';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
|
@ -24,8 +24,8 @@ let saveCredential: SaveCredentialFunction;
|
||||||
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
owner = await addApiKey(await createUserShell('global:owner'));
|
owner = await createOwnerWithApiKey();
|
||||||
member = await createUser({ role: 'global:member', apiKey: randomApiKey() });
|
member = await createMemberWithApiKey();
|
||||||
|
|
||||||
authOwnerAgent = testServer.publicApiAgentFor(owner);
|
authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||||
authMemberAgent = testServer.publicApiAgentFor(member);
|
authMemberAgent = testServer.publicApiAgentFor(member);
|
||||||
|
@ -156,10 +156,7 @@ describe('DELETE /credentials/:id', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete owned cred for member but leave others untouched', async () => {
|
test('should delete owned cred for member but leave others untouched', async () => {
|
||||||
const anotherMember = await createUser({
|
const anotherMember = await createMemberWithApiKey();
|
||||||
role: 'global:member',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedCredential = await saveCredential(dbCredential(), { user: member });
|
const savedCredential = await saveCredential(dbCredential(), { user: member });
|
||||||
const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member });
|
const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member });
|
||||||
|
|
|
@ -12,13 +12,12 @@ import {
|
||||||
createSuccessfulExecution,
|
createSuccessfulExecution,
|
||||||
createWaitingExecution,
|
createWaitingExecution,
|
||||||
} from '../shared/db/executions';
|
} from '../shared/db/executions';
|
||||||
import { createUser } from '../shared/db/users';
|
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
||||||
import {
|
import {
|
||||||
createManyWorkflows,
|
createManyWorkflows,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
shareWorkflowWithUsers,
|
shareWorkflowWithUsers,
|
||||||
} from '../shared/db/workflows';
|
} from '../shared/db/workflows';
|
||||||
import { randomApiKey } from '../shared/random';
|
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
import * as utils from '../shared/utils/';
|
import * as utils from '../shared/utils/';
|
||||||
|
@ -36,9 +35,9 @@ mockInstance(Telemetry);
|
||||||
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
owner = await createUser({ role: 'global:owner', apiKey: randomApiKey() });
|
owner = await createOwnerWithApiKey();
|
||||||
user1 = await createUser({ role: 'global:member', apiKey: randomApiKey() });
|
user1 = await createMemberWithApiKey();
|
||||||
user2 = await createUser({ role: 'global:member', apiKey: randomApiKey() });
|
user2 = await createMemberWithApiKey();
|
||||||
|
|
||||||
// TODO: mock BinaryDataService instead
|
// TODO: mock BinaryDataService instead
|
||||||
await utils.initBinaryDataService();
|
await utils.initBinaryDataService();
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects';
|
import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects';
|
||||||
import { createMember, createOwner } from '@test-integration/db/users';
|
import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users';
|
||||||
import { setupTestServer } from '@test-integration/utils';
|
import { setupTestServer } from '@test-integration/utils';
|
||||||
|
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
|
@ -26,7 +26,7 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const projects = await Promise.all([
|
const projects = await Promise.all([
|
||||||
createTeamProject(),
|
createTeamProject(),
|
||||||
createTeamProject(),
|
createTeamProject(),
|
||||||
|
@ -53,15 +53,10 @@ describe('Projects in Public API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('if not authenticated, should reject', async () => {
|
it('if not authenticated, should reject', async () => {
|
||||||
/**
|
|
||||||
* Arrange
|
|
||||||
*/
|
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(owner).get('/projects');
|
const response = await testServer.publicApiAgentWithoutApiKey().get('/projects');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -74,7 +69,7 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
|
@ -97,12 +92,12 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const owner = await createMember({ withApiKey: true });
|
const member = await createMemberWithApiKey();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(owner).get('/projects');
|
const response = await testServer.publicApiAgentFor(member).get('/projects');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -119,7 +114,7 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const projectPayload = { name: 'some-project' };
|
const projectPayload = { name: 'some-project' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -150,14 +145,13 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
const projectPayload = { name: 'some-project' };
|
const projectPayload = { name: 'some-project' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer
|
const response = await testServer
|
||||||
.publicApiAgentFor(owner)
|
.publicApiAgentWithoutApiKey()
|
||||||
.post('/projects')
|
.post('/projects')
|
||||||
.send(projectPayload);
|
.send(projectPayload);
|
||||||
|
|
||||||
|
@ -172,7 +166,7 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const projectPayload = { name: 'some-project' };
|
const projectPayload = { name: 'some-project' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -199,7 +193,7 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const member = await createMember({ withApiKey: true });
|
const member = await createMemberWithApiKey();
|
||||||
const projectPayload = { name: 'some-project' };
|
const projectPayload = { name: 'some-project' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -225,7 +219,7 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -244,13 +238,14 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
|
const response = await testServer
|
||||||
|
.publicApiAgentWithoutApiKey()
|
||||||
|
.delete(`/projects/${project.id}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -263,7 +258,7 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -287,13 +282,13 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const member = await createMember({ withApiKey: true });
|
const owner = await createMemberWithApiKey();
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`);
|
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -310,7 +305,7 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const project = await createTeamProject('old-name');
|
const project = await createTeamProject('old-name');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -332,14 +327,13 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer
|
const response = await testServer
|
||||||
.publicApiAgentFor(owner)
|
.publicApiAgentWithoutApiKey()
|
||||||
.put(`/projects/${project.id}`)
|
.put(`/projects/${project.id}`)
|
||||||
.send({ name: 'new-name' });
|
.send({ name: 'new-name' });
|
||||||
|
|
||||||
|
@ -354,7 +348,7 @@ describe('Projects in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -381,7 +375,7 @@ describe('Projects in Public API', () => {
|
||||||
*/
|
*/
|
||||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
testServer.license.enable('feat:projectRole:admin');
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
const member = await createMember({ withApiKey: true });
|
const member = await createMemberWithApiKey();
|
||||||
const project = await createTeamProject();
|
const project = await createTeamProject();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,8 +4,7 @@ import type { User } from '@/databases/entities/user';
|
||||||
import { TagRepository } from '@/databases/repositories/tag.repository';
|
import { TagRepository } from '@/databases/repositories/tag.repository';
|
||||||
|
|
||||||
import { createTag } from '../shared/db/tags';
|
import { createTag } from '../shared/db/tags';
|
||||||
import { createUser } from '../shared/db/users';
|
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
||||||
import { randomApiKey } from '../shared/random';
|
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
import * as utils from '../shared/utils/';
|
import * as utils from '../shared/utils/';
|
||||||
|
@ -18,15 +17,8 @@ let authMemberAgent: SuperAgentTest;
|
||||||
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
owner = await createUser({
|
owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
member = await createMemberWithApiKey();
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
member = await createUser({
|
|
||||||
role: 'global:member',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -6,8 +6,13 @@ import { License } from '@/license';
|
||||||
import { createTeamProject, linkUserToProject } from '@test-integration/db/projects';
|
import { createTeamProject, linkUserToProject } from '@test-integration/db/projects';
|
||||||
|
|
||||||
import { mockInstance } from '../../shared/mocking';
|
import { mockInstance } from '../../shared/mocking';
|
||||||
import { createOwner, createUser, createUserShell } from '../shared/db/users';
|
import {
|
||||||
import { randomApiKey } from '../shared/random';
|
createMember,
|
||||||
|
createMemberWithApiKey,
|
||||||
|
createOwnerWithApiKey,
|
||||||
|
createUser,
|
||||||
|
createUserShell,
|
||||||
|
} from '../shared/db/users';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
import * as utils from '../shared/utils/';
|
import * as utils from '../shared/utils/';
|
||||||
|
@ -25,32 +30,23 @@ beforeEach(async () => {
|
||||||
describe('With license unlimited quota:users', () => {
|
describe('With license unlimited quota:users', () => {
|
||||||
describe('GET /users', () => {
|
describe('GET /users', () => {
|
||||||
test('should fail due to missing API Key', async () => {
|
test('should fail due to missing API Key', async () => {
|
||||||
const owner = await createUser({ role: 'global:owner' });
|
const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
|
||||||
await authOwnerAgent.get('/users').expect(401);
|
await authOwnerAgent.get('/users').expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail due to invalid API Key', async () => {
|
test('should fail due to invalid API Key', async () => {
|
||||||
const owner = await createUser({
|
const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
owner.apiKey = 'invalid-key';
|
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
|
||||||
await authOwnerAgent.get('/users').expect(401);
|
await authOwnerAgent.get('/users').expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail due to member trying to access owner only endpoint', async () => {
|
test('should fail due to member trying to access owner only endpoint', async () => {
|
||||||
const member = await createUser({ apiKey: randomApiKey() });
|
const member = await createMemberWithApiKey();
|
||||||
const authMemberAgent = testServer.publicApiAgentFor(member);
|
const authMemberAgent = testServer.publicApiAgentFor(member);
|
||||||
await authMemberAgent.get('/users').expect(403);
|
await authMemberAgent.get('/users').expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return all users', async () => {
|
test('should return all users', async () => {
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||||
|
|
||||||
|
@ -92,10 +88,10 @@ describe('With license unlimited quota:users', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const [owner, firstMember, secondMember, thirdMember] = await Promise.all([
|
const [owner, firstMember, secondMember, thirdMember] = await Promise.all([
|
||||||
createOwner({ withApiKey: true }),
|
createOwnerWithApiKey(),
|
||||||
createUser({ role: 'global:member' }),
|
createMember(),
|
||||||
createUser({ role: 'global:member' }),
|
createMember(),
|
||||||
createUser({ role: 'global:member' }),
|
createMember(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [firstProject, secondProject] = await Promise.all([
|
const [firstProject, secondProject] = await Promise.all([
|
||||||
|
@ -130,40 +126,30 @@ describe('With license unlimited quota:users', () => {
|
||||||
|
|
||||||
describe('GET /users/:id', () => {
|
describe('GET /users/:id', () => {
|
||||||
test('should fail due to missing API Key', async () => {
|
test('should fail due to missing API Key', async () => {
|
||||||
const owner = await createUser({ role: 'global:owner' });
|
const owner = await createOwnerWithApiKey();
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
|
||||||
await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
|
await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail due to invalid API Key', async () => {
|
test('should fail due to invalid API Key', async () => {
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
owner.apiKey = 'invalid-key';
|
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
|
||||||
await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
|
await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail due to member trying to access owner only endpoint', async () => {
|
test('should fail due to member trying to access owner only endpoint', async () => {
|
||||||
const member = await createUser({ apiKey: randomApiKey() });
|
const member = await createMemberWithApiKey();
|
||||||
const authMemberAgent = testServer.publicApiAgentFor(member);
|
const authMemberAgent = testServer.publicApiAgentFor(member);
|
||||||
await authMemberAgent.get(`/users/${member.id}`).expect(403);
|
await authMemberAgent.get(`/users/${member.id}`).expect(403);
|
||||||
});
|
});
|
||||||
test('should return 404 for non-existing id ', async () => {
|
test('should return 404 for non-existing id ', async () => {
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||||
await authOwnerAgent.get(`/users/${uuid()}`).expect(404);
|
await authOwnerAgent.get(`/users/${uuid()}`).expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return a pending user', async () => {
|
test('should return a pending user', async () => {
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { id: memberId } = await createUserShell('global:member');
|
const { id: memberId } = await createUserShell('global:member');
|
||||||
|
|
||||||
|
@ -199,20 +185,13 @@ describe('With license unlimited quota:users', () => {
|
||||||
|
|
||||||
describe('GET /users/:email', () => {
|
describe('GET /users/:email', () => {
|
||||||
test('with non-existing email should return 404', async () => {
|
test('with non-existing email should return 404', async () => {
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||||
await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404);
|
await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return a user', async () => {
|
test('should return a user', async () => {
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
const authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||||
const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200);
|
const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200);
|
||||||
|
|
||||||
|
@ -249,10 +228,7 @@ describe('With license without quota:users', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) });
|
mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) });
|
||||||
|
|
||||||
const owner = await createUser({
|
const owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
authOwnerAgent = testServer.publicApiAgentFor(owner);
|
authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
import { createMember, createOwner, getUserById } from '@test-integration/db/users';
|
import {
|
||||||
|
createMember,
|
||||||
|
createMemberWithApiKey,
|
||||||
|
createOwnerWithApiKey,
|
||||||
|
getUserById,
|
||||||
|
} from '@test-integration/db/users';
|
||||||
import { setupTestServer } from '@test-integration/utils';
|
import { setupTestServer } from '@test-integration/utils';
|
||||||
|
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
|
@ -23,13 +28,12 @@ describe('Users in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
const payload = { email: 'test@test.com', role: 'global:admin' };
|
const payload = { email: 'test@test.com', role: 'global:admin' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
|
const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -42,7 +46,7 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const member = await createMember({ withApiKey: true });
|
const member = await createMemberWithApiKey();
|
||||||
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
|
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -62,7 +66,8 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
|
await createOwnerWithApiKey();
|
||||||
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
|
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -99,13 +104,12 @@ describe('Users in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
const member = await createMember();
|
const member = await createMember();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`);
|
const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -118,14 +122,14 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const firstMember = await createMember({ withApiKey: true });
|
const member = await createMemberWithApiKey();
|
||||||
const secondMember = await createMember();
|
const secondMember = await createMember();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer
|
const response = await testServer
|
||||||
.publicApiAgentFor(firstMember)
|
.publicApiAgentFor(member)
|
||||||
.delete(`/users/${secondMember.id}`);
|
.delete(`/users/${secondMember.id}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -140,7 +144,7 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const member = await createMember();
|
const member = await createMember();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -161,13 +165,14 @@ describe('Users in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: false });
|
|
||||||
const member = await createMember();
|
const member = await createMember();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`);
|
const response = await testServer
|
||||||
|
.publicApiAgentWithApiKey('')
|
||||||
|
.patch(`/users/${member.id}/role`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert
|
* Assert
|
||||||
|
@ -179,7 +184,7 @@ describe('Users in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const member = await createMember();
|
const member = await createMember();
|
||||||
const payload = { newRoleName: 'global:admin' };
|
const payload = { newRoleName: 'global:admin' };
|
||||||
|
|
||||||
|
@ -206,7 +211,7 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const firstMember = await createMember({ withApiKey: true });
|
const member = await createMemberWithApiKey();
|
||||||
const secondMember = await createMember();
|
const secondMember = await createMember();
|
||||||
const payload = { newRoleName: 'global:admin' };
|
const payload = { newRoleName: 'global:admin' };
|
||||||
|
|
||||||
|
@ -214,7 +219,7 @@ describe('Users in Public API', () => {
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
const response = await testServer
|
const response = await testServer
|
||||||
.publicApiAgentFor(firstMember)
|
.publicApiAgentFor(member)
|
||||||
.patch(`/users/${secondMember.id}/role`)
|
.patch(`/users/${secondMember.id}/role`)
|
||||||
.send(payload);
|
.send(payload);
|
||||||
|
|
||||||
|
@ -230,7 +235,7 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const member = await createMember();
|
const member = await createMember();
|
||||||
const payload = { newRoleName: 'invalid' };
|
const payload = { newRoleName: 'invalid' };
|
||||||
|
|
||||||
|
@ -253,7 +258,7 @@ describe('Users in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:advancedPermissions');
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const member = await createMember();
|
const member = await createMember();
|
||||||
const payload = { newRoleName: 'global:admin' };
|
const payload = { newRoleName: 'global:admin' };
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||||
import { createOwner } from '@test-integration/db/users';
|
import { createOwnerWithApiKey } from '@test-integration/db/users';
|
||||||
import { createVariable, getVariableOrFail } from '@test-integration/db/variables';
|
import { createVariable, getVariableOrFail } from '@test-integration/db/variables';
|
||||||
import { setupTestServer } from '@test-integration/utils';
|
import { setupTestServer } from '@test-integration/utils';
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ describe('Variables in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:variables');
|
testServer.license.enable('feat:variables');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const variables = await Promise.all([createVariable(), createVariable(), createVariable()]);
|
const variables = await Promise.all([createVariable(), createVariable(), createVariable()]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -48,7 +48,8 @@ describe('Variables in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
|
||||||
|
const owner = await createOwnerWithApiKey();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
|
@ -72,7 +73,7 @@ describe('Variables in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:variables');
|
testServer.license.enable('feat:variables');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const variablePayload = { key: 'key', value: 'value' };
|
const variablePayload = { key: 'key', value: 'value' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -96,7 +97,7 @@ describe('Variables in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const variablePayload = { key: 'key', value: 'value' };
|
const variablePayload = { key: 'key', value: 'value' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -124,7 +125,7 @@ describe('Variables in Public API', () => {
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
testServer.license.enable('feat:variables');
|
testServer.license.enable('feat:variables');
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const variable = await createVariable();
|
const variable = await createVariable();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -145,7 +146,7 @@ describe('Variables in Public API', () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const owner = await createOwner({ withApiKey: true });
|
const owner = await createOwnerWithApiKey();
|
||||||
const variable = await createVariable();
|
const variable = await createVariable();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -17,9 +17,8 @@ import { createTeamProject } from '@test-integration/db/projects';
|
||||||
|
|
||||||
import { mockInstance } from '../../shared/mocking';
|
import { mockInstance } from '../../shared/mocking';
|
||||||
import { createTag } from '../shared/db/tags';
|
import { createTag } from '../shared/db/tags';
|
||||||
import { createUser } from '../shared/db/users';
|
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
||||||
import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows';
|
import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows';
|
||||||
import { randomApiKey } from '../shared/random';
|
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
import * as utils from '../shared/utils/';
|
import * as utils from '../shared/utils/';
|
||||||
|
@ -40,18 +39,13 @@ const license = testServer.license;
|
||||||
mockInstance(ExecutionService);
|
mockInstance(ExecutionService);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
owner = await createUser({
|
owner = await createOwnerWithApiKey();
|
||||||
role: 'global:owner',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||||
owner.id,
|
owner.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
member = await createUser({
|
member = await createMemberWithApiKey();
|
||||||
role: 'global:member',
|
|
||||||
apiKey: randomApiKey(),
|
|
||||||
});
|
|
||||||
memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||||
member.id,
|
member.id,
|
||||||
);
|
);
|
||||||
|
@ -1518,6 +1512,10 @@ describe('PUT /workflows/:id/transfer', () => {
|
||||||
const secondProject = await createTeamProject('second-project', member);
|
const secondProject = await createTeamProject('second-project', member);
|
||||||
const workflow = await createWorkflow({}, firstProject);
|
const workflow = await createWorkflow({}, firstProject);
|
||||||
|
|
||||||
|
// Make data more similar to real world scenario by injecting additional records into the database
|
||||||
|
await createTeamProject('third-project', member);
|
||||||
|
await createWorkflow({}, firstProject);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Act
|
* Act
|
||||||
*/
|
*/
|
||||||
|
@ -1529,6 +1527,13 @@ describe('PUT /workflows/:id/transfer', () => {
|
||||||
* Assert
|
* Assert
|
||||||
*/
|
*/
|
||||||
expect(response.statusCode).toBe(204);
|
expect(response.statusCode).toBe(204);
|
||||||
|
|
||||||
|
const workflowsInProjectResponse = await authMemberAgent
|
||||||
|
.get(`/workflows?projectId=${secondProject.id}`)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(workflowsInProjectResponse.statusCode).toBe(200);
|
||||||
|
expect(workflowsInProjectResponse.body.data[0].id).toBe(workflow.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('if no destination project, should reject', async () => {
|
test('if no destination project, should reject', async () => {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import Container from 'typedi';
|
||||||
import type { ExecutionData } from '@/databases/entities/execution-data';
|
import type { ExecutionData } from '@/databases/entities/execution-data';
|
||||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository';
|
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
|
||||||
import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
|
import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository';
|
||||||
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
|
import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
|
import { randomString } from 'n8n-workflow';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
import { AuthIdentity } from '@/databases/entities/auth-identity';
|
import { AuthIdentity } from '@/databases/entities/auth-identity';
|
||||||
import { type GlobalRole, type User } from '@/databases/entities/user';
|
import { type GlobalRole, type User } from '@/databases/entities/user';
|
||||||
|
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
|
||||||
import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository';
|
import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository';
|
||||||
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
||||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
|
@ -79,19 +81,38 @@ export async function createUserWithMfaEnabled(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOwner({ withApiKey } = { withApiKey: false }) {
|
const createApiKeyEntity = (user: User) => {
|
||||||
if (withApiKey) {
|
const apiKey = randomApiKey();
|
||||||
return await addApiKey(await createUser({ role: 'global:owner' }));
|
return Container.get(ApiKeyRepository).create({
|
||||||
}
|
userId: user.id,
|
||||||
|
label: randomString(10),
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addApiKey = async (user: User) => {
|
||||||
|
return await Container.get(ApiKeyRepository).save(createApiKeyEntity(user));
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createOwnerWithApiKey() {
|
||||||
|
const owner = await createOwner();
|
||||||
|
const apiKey = await addApiKey(owner);
|
||||||
|
owner.apiKeys = [apiKey];
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMemberWithApiKey() {
|
||||||
|
const member = await createMember();
|
||||||
|
const apiKey = await addApiKey(member);
|
||||||
|
member.apiKeys = [apiKey];
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOwner() {
|
||||||
return await createUser({ role: 'global:owner' });
|
return await createUser({ role: 'global:owner' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMember({ withApiKey } = { withApiKey: false }) {
|
export async function createMember() {
|
||||||
if (withApiKey) {
|
|
||||||
return await addApiKey(await createUser({ role: 'global:member' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await createUser({ role: 'global:member' });
|
return await createUser({ role: 'global:member' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,11 +149,6 @@ export async function createManyUsers(
|
||||||
return result.map((result) => result.user);
|
return result.map((result) => result.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addApiKey(user: User): Promise<User> {
|
|
||||||
user.apiKey = randomApiKey();
|
|
||||||
return await Container.get(UserRepository).save(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAllUsers = async () =>
|
export const getAllUsers = async () =>
|
||||||
await Container.get(UserRepository).find({
|
await Container.get(UserRepository).find({
|
||||||
relations: ['authIdentities'],
|
relations: ['authIdentities'],
|
||||||
|
|
|
@ -80,6 +80,7 @@ const repositories = [
|
||||||
'WorkflowHistory',
|
'WorkflowHistory',
|
||||||
'WorkflowStatistics',
|
'WorkflowStatistics',
|
||||||
'WorkflowTagMapping',
|
'WorkflowTagMapping',
|
||||||
|
'ApiKey',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -87,9 +88,18 @@ const repositories = [
|
||||||
*/
|
*/
|
||||||
export async function truncate(names: Array<(typeof repositories)[number]>) {
|
export async function truncate(names: Array<(typeof repositories)[number]>) {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const RepositoryClass: Class<Repository<object>> =
|
let RepositoryClass: Class<Repository<object>>;
|
||||||
// eslint-disable-next-line n8n-local-rules/no-dynamic-import-template
|
|
||||||
(await import(`@/databases/repositories/${kebabCase(name)}.repository`))[`${name}Repository`];
|
try {
|
||||||
|
RepositoryClass = (await import(`@/databases/repositories/${kebabCase(name)}.repository`))[
|
||||||
|
`${name}Repository`
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
RepositoryClass = (await import(`@/databases/repositories/${kebabCase(name)}.repository.ee`))[
|
||||||
|
`${name}Repository`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
await Container.get(RepositoryClass).delete({});
|
await Container.get(RepositoryClass).delete({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,8 @@ export interface TestServer {
|
||||||
httpServer: Server;
|
httpServer: Server;
|
||||||
authAgentFor: (user: User) => TestAgent;
|
authAgentFor: (user: User) => TestAgent;
|
||||||
publicApiAgentFor: (user: User) => TestAgent;
|
publicApiAgentFor: (user: User) => TestAgent;
|
||||||
|
publicApiAgentWithApiKey: (apiKey: string) => TestAgent;
|
||||||
|
publicApiAgentWithoutApiKey: () => TestAgent;
|
||||||
authlessAgent: TestAgent;
|
authlessAgent: TestAgent;
|
||||||
restlessAgent: TestAgent;
|
restlessAgent: TestAgent;
|
||||||
license: LicenseMocker;
|
license: LicenseMocker;
|
||||||
|
|
|
@ -62,17 +62,30 @@ function createAgent(
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicApiAgent(
|
const userDoesNotHaveApiKey = (user: User) => {
|
||||||
|
return !user.apiKeys || !Array.from(user.apiKeys) || user.apiKeys.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicApiAgent = (
|
||||||
app: express.Application,
|
app: express.Application,
|
||||||
{ user, version = 1 }: { user: User; version?: number },
|
{ user, apiKey, version = 1 }: { user?: User; apiKey?: string; version?: number },
|
||||||
) {
|
) => {
|
||||||
|
if (user && apiKey) {
|
||||||
|
throw new Error('Cannot provide both user and API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && userDoesNotHaveApiKey(user)) {
|
||||||
|
throw new Error('User does not have an API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentApiKey = apiKey ?? user?.apiKeys[0].apiKey;
|
||||||
|
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
|
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
|
||||||
if (user.apiKey) {
|
if (!user && !apiKey) return agent;
|
||||||
void agent.set({ 'X-N8N-API-KEY': user.apiKey });
|
void agent.set({ 'X-N8N-API-KEY': agentApiKey });
|
||||||
}
|
|
||||||
return agent;
|
return agent;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setupTestServer = ({
|
export const setupTestServer = ({
|
||||||
endpointGroups,
|
endpointGroups,
|
||||||
|
@ -100,6 +113,8 @@ export const setupTestServer = ({
|
||||||
authlessAgent: createAgent(app),
|
authlessAgent: createAgent(app),
|
||||||
restlessAgent: createAgent(app, { auth: false, noRest: true }),
|
restlessAgent: createAgent(app, { auth: false, noRest: true }),
|
||||||
publicApiAgentFor: (user) => publicApiAgent(app, { user }),
|
publicApiAgentFor: (user) => publicApiAgent(app, { user }),
|
||||||
|
publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }),
|
||||||
|
publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}),
|
||||||
license: new LicenseMocker(),
|
license: new LicenseMocker(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -140,7 +155,7 @@ export const setupTestServer = ({
|
||||||
for (const group of endpointGroups) {
|
for (const group of endpointGroups) {
|
||||||
switch (group) {
|
switch (group) {
|
||||||
case 'annotationTags':
|
case 'annotationTags':
|
||||||
await import('@/controllers/annotation-tags.controller');
|
await import('@/controllers/annotation-tags.controller.ee');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'credentials':
|
case 'credentials':
|
||||||
|
|
|
@ -30,18 +30,6 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
|
||||||
const loader = new PackageDirectoryLoader(packageDir);
|
const loader = new PackageDirectoryLoader(packageDir);
|
||||||
await loader.loadAll();
|
await loader.loadAll();
|
||||||
|
|
||||||
const knownCredentials = loader.known.credentials;
|
|
||||||
const credentialTypes = Object.values(loader.credentialTypes).map((data) => {
|
|
||||||
const credentialType = data.type;
|
|
||||||
if (
|
|
||||||
knownCredentials[credentialType.name].supportedNodes?.length > 0 &&
|
|
||||||
credentialType.httpRequestNode
|
|
||||||
) {
|
|
||||||
credentialType.httpRequestNode.hidden = true;
|
|
||||||
}
|
|
||||||
return credentialType;
|
|
||||||
});
|
|
||||||
|
|
||||||
const loaderNodeTypes = Object.values(loader.nodeTypes);
|
const loaderNodeTypes = Object.values(loader.nodeTypes);
|
||||||
|
|
||||||
const definedMethods = loaderNodeTypes.reduce((acc, cur) => {
|
const definedMethods = loaderNodeTypes.reduce((acc, cur) => {
|
||||||
|
@ -76,6 +64,36 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const knownCredentials = loader.known.credentials;
|
||||||
|
const credentialTypes = Object.values(loader.credentialTypes).map((data) => {
|
||||||
|
const credentialType = data.type;
|
||||||
|
const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? [];
|
||||||
|
if (supportedNodes.length > 0 && credentialType.httpRequestNode) {
|
||||||
|
credentialType.httpRequestNode.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialType.supportedNodes = supportedNodes;
|
||||||
|
|
||||||
|
if (!credentialType.iconUrl && !credentialType.icon) {
|
||||||
|
for (const supportedNode of supportedNodes) {
|
||||||
|
const nodeType = loader.nodeTypes[supportedNode]?.type.description;
|
||||||
|
|
||||||
|
if (!nodeType) continue;
|
||||||
|
if (nodeType.icon) {
|
||||||
|
credentialType.icon = nodeType.icon;
|
||||||
|
credentialType.iconColor = nodeType.iconColor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (nodeType.iconUrl) {
|
||||||
|
credentialType.iconUrl = nodeType.iconUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentialType;
|
||||||
|
});
|
||||||
|
|
||||||
const referencedMethods = findReferencedMethods(nodeTypes);
|
const referencedMethods = findReferencedMethods(nodeTypes);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-core",
|
"name": "n8n-core",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"description": "Core functionality of n8n",
|
"description": "Core functionality of n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import type {
|
import type {
|
||||||
CodexData,
|
CodexData,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
|
@ -350,18 +351,11 @@ export class CustomDirectoryLoader extends DirectoryLoader {
|
||||||
* e.g. /nodes-base or community packages.
|
* e.g. /nodes-base or community packages.
|
||||||
*/
|
*/
|
||||||
export class PackageDirectoryLoader extends DirectoryLoader {
|
export class PackageDirectoryLoader extends DirectoryLoader {
|
||||||
packageName = '';
|
packageJson: n8n.PackageJson = this.readJSONSync('package.json');
|
||||||
|
|
||||||
packageJson!: n8n.PackageJson;
|
packageName = this.packageJson.name;
|
||||||
|
|
||||||
async readPackageJson() {
|
|
||||||
this.packageJson = await this.readJSON('package.json');
|
|
||||||
this.packageName = this.packageJson.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async loadAll() {
|
override async loadAll() {
|
||||||
await this.readPackageJson();
|
|
||||||
|
|
||||||
const { n8n } = this.packageJson;
|
const { n8n } = this.packageJson;
|
||||||
if (!n8n) return;
|
if (!n8n) return;
|
||||||
|
|
||||||
|
@ -391,6 +385,17 @@ export class PackageDirectoryLoader extends DirectoryLoader {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readJSONSync<T>(file: string): T {
|
||||||
|
const filePath = this.resolvePath(file);
|
||||||
|
const fileString = readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return jsonParse<T>(fileString);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApplicationError('Failed to parse JSON', { extra: { filePath } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async readJSON<T>(file: string): Promise<T> {
|
protected async readJSON<T>(file: string): Promise<T> {
|
||||||
const filePath = this.resolvePath(file);
|
const filePath = this.resolvePath(file);
|
||||||
const fileString = await readFile(filePath, 'utf8');
|
const fileString = await readFile(filePath, 'utf8');
|
||||||
|
@ -408,8 +413,6 @@ export class PackageDirectoryLoader extends DirectoryLoader {
|
||||||
*/
|
*/
|
||||||
export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
|
export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
|
||||||
override async loadAll() {
|
override async loadAll() {
|
||||||
await this.readPackageJson();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json');
|
const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json');
|
||||||
for (const nodeName in knownNodes) {
|
for (const nodeName in knownNodes) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-design-system",
|
"name": "n8n-design-system",
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"import": "src/main.ts",
|
"import": "src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -136,7 +136,7 @@ const htmlContent = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'markdown-click': [link: string, e: MouseEvent];
|
'markdown-click': [link: HTMLAnchorElement, e: MouseEvent];
|
||||||
'update-content': [content: string];
|
'update-content': [content: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -154,7 +154,7 @@ const onClick = (event: MouseEvent) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (clickedLink) {
|
if (clickedLink) {
|
||||||
emit('markdown-click', clickedLink?.href, event);
|
emit('markdown-click', clickedLink, event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ const emit = defineEmits<{
|
||||||
resize: [values: ResizeData];
|
resize: [values: ResizeData];
|
||||||
resizestart: [];
|
resizestart: [];
|
||||||
resizeend: [];
|
resizeend: [];
|
||||||
|
'markdown-click': [link: HTMLAnchorElement, e: MouseEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
@ -42,6 +43,10 @@ const onResizeEnd = () => {
|
||||||
isResizing.value = false;
|
isResizing.value = false;
|
||||||
emit('resizeend');
|
emit('resizeend');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => {
|
||||||
|
emit('markdown-click', link, event);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -57,6 +62,6 @@ const onResizeEnd = () => {
|
||||||
@resize="onResize"
|
@resize="onResize"
|
||||||
@resizestart="onResizeStart"
|
@resizestart="onResizeStart"
|
||||||
>
|
>
|
||||||
<N8nSticky v-bind="stickyBindings" />
|
<N8nSticky v-bind="stickyBindings" @markdown-click="onMarkdownClick" />
|
||||||
</N8nResizeWrapper>
|
</N8nResizeWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -13,7 +13,7 @@ const props = withDefaults(defineProps<StickyProps>(), defaultStickyProps);
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [editing: boolean];
|
edit: [editing: boolean];
|
||||||
'update:modelValue': [value: string];
|
'update:modelValue': [value: string];
|
||||||
'markdown-click': [link: string, e: Event];
|
'markdown-click': [link: HTMLAnchorElement, e: MouseEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
@ -63,7 +63,7 @@ const onUpdateModelValue = (value: string) => {
|
||||||
emit('update:modelValue', value);
|
emit('update:modelValue', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMarkdownClick = (link: string, event: Event) => {
|
const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => {
|
||||||
emit('markdown-click', link, event);
|
emit('markdown-click', link, event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-editor-ui",
|
"name": "n8n-editor-ui",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"description": "Workflow Editor UI for n8n",
|
"description": "Workflow Editor UI for n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -1643,3 +1643,11 @@ export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterpr
|
||||||
export interface IN8nPromptResponse {
|
export interface IN8nPromptResponse {
|
||||||
updated: boolean;
|
updated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiKey = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
apiKey: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import type { IRestApiContext } from '@/Interface';
|
import type { ApiKey, IRestApiContext } from '@/Interface';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
|
||||||
export async function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
|
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
||||||
return await makeRestApiRequest(context, 'GET', '/me/api-key');
|
return await makeRestApiRequest(context, 'GET', '/me/api-keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
|
export async function createApiKey(context: IRestApiContext): Promise<ApiKey> {
|
||||||
return await makeRestApiRequest(context, 'POST', '/me/api-key');
|
return await makeRestApiRequest(context, 'POST', '/me/api-keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> {
|
export async function deleteApiKey(
|
||||||
return await makeRestApiRequest(context, 'DELETE', '/me/api-key');
|
context: IRestApiContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return await makeRestApiRequest(context, 'DELETE', `/me/api-keys/${id}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||||
import { format } from 'prettier';
|
import { format } from 'prettier';
|
||||||
import jsParser from 'prettier/plugins/babel';
|
import jsParser from 'prettier/plugins/babel';
|
||||||
import * as estree from 'prettier/plugins/estree';
|
import * as estree from 'prettier/plugins/estree';
|
||||||
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
|
|
||||||
import { CODE_NODE_TYPE } from '@/constants';
|
import { CODE_NODE_TYPE } from '@/constants';
|
||||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||||
|
@ -26,6 +26,7 @@ import { useLinter } from './linter';
|
||||||
import { codeNodeEditorTheme } from './theme';
|
import { codeNodeEditorTheme } from './theme';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: CodeExecutionMode;
|
mode: CodeExecutionMode;
|
||||||
|
@ -51,6 +52,7 @@ const emit = defineEmits<{
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const editor = ref(null) as Ref<EditorView | null>;
|
const editor = ref(null) as Ref<EditorView | null>;
|
||||||
const languageCompartment = ref(new Compartment());
|
const languageCompartment = ref(new Compartment());
|
||||||
|
const dragAndDropCompartment = ref(new Compartment());
|
||||||
const linterCompartment = ref(new Compartment());
|
const linterCompartment = ref(new Compartment());
|
||||||
const isEditorHovered = ref(false);
|
const isEditorHovered = ref(false);
|
||||||
const isEditorFocused = ref(false);
|
const isEditorFocused = ref(false);
|
||||||
|
@ -95,6 +97,7 @@ onMounted(() => {
|
||||||
|
|
||||||
extensions.push(
|
extensions.push(
|
||||||
...writableEditorExtensions,
|
...writableEditorExtensions,
|
||||||
|
dragAndDropCompartment.value.of(dragAndDropExtension.value),
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
isEditorFocused.value = true;
|
isEditorFocused.value = true;
|
||||||
|
@ -151,6 +154,12 @@ const placeholder = computed(() => {
|
||||||
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
|
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dragAndDropEnabled = computed(() => {
|
||||||
|
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
|
||||||
|
|
||||||
// eslint-disable-next-line vue/return-in-computed-property
|
// eslint-disable-next-line vue/return-in-computed-property
|
||||||
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
|
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
|
||||||
switch (props.language) {
|
switch (props.language) {
|
||||||
|
@ -188,6 +197,12 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(dragAndDropExtension, (extension) => {
|
||||||
|
editor.value?.dispatch({
|
||||||
|
effects: dragAndDropCompartment.value.reconfigure(extension),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.language,
|
() => props.language,
|
||||||
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
|
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
|
||||||
|
@ -202,7 +217,6 @@ watch(
|
||||||
reloadLinter();
|
reloadLinter();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
async (isEnabled) => {
|
async (isEnabled) => {
|
||||||
|
@ -361,6 +375,12 @@ function onAiLoadStart() {
|
||||||
function onAiLoadEnd() {
|
function onAiLoadEnd() {
|
||||||
isLoadingAIResponse.value = false;
|
isLoadingAIResponse.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDrop(value: string, event: MouseEvent) {
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
|
await dropInCodeEditor(toRaw(editor.value), event, value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -384,10 +404,20 @@ function onAiLoadEnd() {
|
||||||
data-test-id="code-node-tab-code"
|
data-test-id="code-node-tab-code"
|
||||||
:class="$style.fillHeight"
|
:class="$style.fillHeight"
|
||||||
>
|
>
|
||||||
<div
|
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
|
||||||
ref="codeNodeEditorRef"
|
<template #default="{ activeDrop, droppable }">
|
||||||
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput, $style.fillHeight]"
|
<div
|
||||||
/>
|
ref="codeNodeEditorRef"
|
||||||
|
:class="[
|
||||||
|
'ph-no-capture',
|
||||||
|
'code-editor-tabs',
|
||||||
|
$style.editorInput,
|
||||||
|
$style.fillHeight,
|
||||||
|
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DraggableTarget>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane
|
<el-tab-pane
|
||||||
|
@ -407,7 +437,19 @@ function onAiLoadEnd() {
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
||||||
<div v-else :class="$style.fillHeight">
|
<div v-else :class="$style.fillHeight">
|
||||||
<div ref="codeNodeEditorRef" :class="['ph-no-capture', $style.fillHeight]" />
|
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
|
||||||
|
<template #default="{ activeDrop, droppable }">
|
||||||
|
<div
|
||||||
|
ref="codeNodeEditorRef"
|
||||||
|
:class="[
|
||||||
|
'ph-no-capture',
|
||||||
|
$style.fillHeight,
|
||||||
|
$style.editorInput,
|
||||||
|
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DraggableTarget>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -415,7 +457,7 @@ function onAiLoadEnd() {
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
:deep(.el-tabs) {
|
:deep(.el-tabs) {
|
||||||
.code-editor-tabs .cm-editor {
|
.cm-editor {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -454,4 +496,21 @@ function onAiLoadEnd() {
|
||||||
.fillHeight {
|
.fillHeight {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editorInput.droppable {
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-color: var(--color-ndv-droppable-parameter);
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorInput.activeDrop {
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
border-style: solid;
|
||||||
|
cursor: grabbing;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,50 +1,59 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import type { ICredentialType } from 'n8n-workflow';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
|
||||||
import { getThemedValue } from '@/utils/nodeTypesUtils';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { getThemedValue } from '@/utils/nodeTypesUtils';
|
||||||
|
import { N8nNodeIcon } from 'n8n-design-system';
|
||||||
|
import type { ICredentialType } from 'n8n-workflow';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
credentialTypeName: string | null;
|
credentialTypeName: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName));
|
const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName));
|
||||||
|
|
||||||
const filePath = computed(() => {
|
const nodeBasedIconUrl = computed(() => {
|
||||||
const themeIconUrl = getThemedValue(credentialWithIcon.value?.iconUrl, uiStore.appliedTheme);
|
const icon = getThemedValue(credentialWithIcon.value?.icon);
|
||||||
|
if (!icon?.startsWith('node:')) return null;
|
||||||
|
return nodeTypesStore.getNodeType(icon.replace('node:', ''))?.iconUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconSource = computed(() => {
|
||||||
|
const themeIconUrl = getThemedValue(
|
||||||
|
nodeBasedIconUrl.value ?? credentialWithIcon.value?.iconUrl,
|
||||||
|
uiStore.appliedTheme,
|
||||||
|
);
|
||||||
|
|
||||||
if (!themeIconUrl) {
|
if (!themeIconUrl) {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rootStore.baseUrl + themeIconUrl;
|
return rootStore.baseUrl + themeIconUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
const relevantNode = computed(() => {
|
const iconType = computed(() => {
|
||||||
const icon = credentialWithIcon.value?.icon;
|
if (iconSource.value) return 'file';
|
||||||
if (typeof icon === 'string' && icon.startsWith('node:')) {
|
else if (iconName.value) return 'icon';
|
||||||
const nodeType = icon.replace('node:', '');
|
return 'unknown';
|
||||||
return nodeTypesStore.getNodeType(nodeType);
|
});
|
||||||
}
|
|
||||||
if (!props.credentialTypeName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodesWithAccess = credentialsStore.getNodesWithAccess(props.credentialTypeName);
|
const iconName = computed(() => {
|
||||||
if (nodesWithAccess.length) {
|
const icon = getThemedValue(credentialWithIcon.value?.icon, uiStore.appliedTheme);
|
||||||
return nodesWithAccess[0];
|
if (!icon || !icon?.startsWith('fa:')) return undefined;
|
||||||
}
|
return icon.replace('fa:', '');
|
||||||
|
});
|
||||||
|
|
||||||
return null;
|
const iconColor = computed(() => {
|
||||||
|
const { iconColor: color } = credentialWithIcon.value ?? {};
|
||||||
|
if (!color) return undefined;
|
||||||
|
return `var(--color-node-icon-${color})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getCredentialWithIcon(name: string | null): ICredentialType | null {
|
function getCredentialWithIcon(name: string | null): ICredentialType | null {
|
||||||
|
@ -64,8 +73,8 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null {
|
||||||
|
|
||||||
if (type.extends) {
|
if (type.extends) {
|
||||||
let parentCred = null;
|
let parentCred = null;
|
||||||
type.extends.forEach((iconName) => {
|
type.extends.forEach((credType) => {
|
||||||
parentCred = getCredentialWithIcon(iconName);
|
parentCred = getCredentialWithIcon(credType);
|
||||||
if (parentCred !== null) return;
|
if (parentCred !== null) return;
|
||||||
});
|
});
|
||||||
return parentCred;
|
return parentCred;
|
||||||
|
@ -76,23 +85,18 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<N8nNodeIcon
|
||||||
<img v-if="filePath" :class="$style.credIcon" :src="filePath" />
|
:class="$style.icon"
|
||||||
<NodeIcon v-else-if="relevantNode" :node-type="relevantNode" :size="28" />
|
:type="iconType"
|
||||||
<span v-else :class="$style.fallback"></span>
|
:size="26"
|
||||||
</div>
|
:src="iconSource"
|
||||||
|
:name="iconName"
|
||||||
|
:color="iconColor"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.credIcon {
|
.icon {
|
||||||
height: 26px;
|
--node-icon-color: var(--color-foreground-dark);
|
||||||
}
|
|
||||||
|
|
||||||
.fallback {
|
|
||||||
height: 28px;
|
|
||||||
width: 28px;
|
|
||||||
display: flex;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--color-foreground-base);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -19,7 +19,7 @@ import OutputItemSelect from './InlineExpressionEditor/OutputItemSelect.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import DraggableTarget from './DraggableTarget.vue';
|
import DraggableTarget from './DraggableTarget.vue';
|
||||||
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
|
import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
import { APP_MODALS_ELEMENT_ID } from '@/constants';
|
import { APP_MODALS_ELEMENT_ID } from '@/constants';
|
||||||
|
|
||||||
|
@ -119,7 +119,7 @@ function closeDialog() {
|
||||||
async function onDrop(expression: string, event: MouseEvent) {
|
async function onDrop(expression: string, event: MouseEvent) {
|
||||||
if (!inputEditor.value) return;
|
if (!inputEditor.value) return;
|
||||||
|
|
||||||
await dropInEditor(toRaw(inputEditor.value), event, expression);
|
await dropInExpressionEditor(toRaw(inputEditor.value), event, expression);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||||
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
|
import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import { startCompletion } from '@codemirror/autocomplete';
|
import { startCompletion } from '@codemirror/autocomplete';
|
||||||
import type { EditorState, SelectionRange } from '@codemirror/state';
|
import type { EditorState, SelectionRange } from '@codemirror/state';
|
||||||
|
@ -119,7 +119,9 @@ async function onDrop(value: string, event: MouseEvent) {
|
||||||
|
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
||||||
const droppedSelection = await dropInEditor(toRaw(editor), event, value);
|
const droppedSelection = await dropInExpressionEditor(toRaw(editor), event, value);
|
||||||
|
|
||||||
|
if (!ndvStore.isMappingOnboarded) ndvStore.setMappingOnboarded();
|
||||||
|
|
||||||
if (!ndvStore.isAutocompleteOnboarded) {
|
if (!ndvStore.isAutocompleteOnboarded) {
|
||||||
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);
|
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);
|
||||||
|
|
|
@ -90,8 +90,8 @@ const allIssues = computed(() => {
|
||||||
const now = computed(() => DateTime.now().toISO());
|
const now = computed(() => DateTime.now().toISO());
|
||||||
|
|
||||||
const leftParameter = computed<INodeProperties>(() => ({
|
const leftParameter = computed<INodeProperties>(() => ({
|
||||||
name: '',
|
name: 'left',
|
||||||
displayName: '',
|
displayName: 'Left',
|
||||||
default: '',
|
default: '',
|
||||||
placeholder:
|
placeholder:
|
||||||
operator.value.type === 'dateTime'
|
operator.value.type === 'dateTime'
|
||||||
|
@ -103,8 +103,8 @@ const leftParameter = computed<INodeProperties>(() => ({
|
||||||
const rightParameter = computed<INodeProperties>(() => {
|
const rightParameter = computed<INodeProperties>(() => {
|
||||||
const type = operator.value.rightType ?? operator.value.type;
|
const type = operator.value.rightType ?? operator.value.type;
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: 'right',
|
||||||
displayName: '',
|
displayName: 'Right',
|
||||||
default: '',
|
default: '',
|
||||||
placeholder:
|
placeholder:
|
||||||
type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'),
|
type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'),
|
||||||
|
|
|
@ -20,7 +20,7 @@ import jsParser from 'prettier/plugins/babel';
|
||||||
import * as estree from 'prettier/plugins/estree';
|
import * as estree from 'prettier/plugins/estree';
|
||||||
import htmlParser from 'prettier/plugins/html';
|
import htmlParser from 'prettier/plugins/html';
|
||||||
import cssParser from 'prettier/plugins/postcss';
|
import cssParser from 'prettier/plugins/postcss';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
|
||||||
|
|
||||||
import { htmlEditorEventBus } from '@/event-bus';
|
import { htmlEditorEventBus } from '@/event-bus';
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
|
@ -37,6 +37,7 @@ import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import type { Range, Section } from './types';
|
import type { Range, Section } from './types';
|
||||||
import { nonTakenRanges } from './utils';
|
import { nonTakenRanges } from './utils';
|
||||||
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -84,6 +85,7 @@ const extensions = computed(() => [
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
|
mappingDropCursor(),
|
||||||
]);
|
]);
|
||||||
const {
|
const {
|
||||||
editor: editorRef,
|
editor: editorRef,
|
||||||
|
@ -238,11 +240,25 @@ onMounted(() => {
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
htmlEditorEventBus.off('format-html', formatHtml);
|
htmlEditorEventBus.off('format-html', formatHtml);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function onDrop(value: string, event: MouseEvent) {
|
||||||
|
if (!editorRef.value) return;
|
||||||
|
|
||||||
|
await dropInExpressionEditor(toRaw(editorRef.value), event, value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.editor">
|
<div :class="$style.editor">
|
||||||
<div ref="htmlEditor" data-test-id="html-editor-container"></div>
|
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
|
||||||
|
<template #default="{ activeDrop, droppable }">
|
||||||
|
<div
|
||||||
|
ref="htmlEditor"
|
||||||
|
:class="{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable }"
|
||||||
|
data-test-id="html-editor-container"
|
||||||
|
></div
|
||||||
|
></template>
|
||||||
|
</DraggableTarget>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -255,4 +271,21 @@ onBeforeUnmount(() => {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.droppable {
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-color: var(--color-ndv-droppable-parameter);
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeDrop {
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
border-style: solid;
|
||||||
|
cursor: grabbing;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
|
import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
|
||||||
import { type Completion, CompletionContext } from '@codemirror/autocomplete';
|
import { type Completion, CompletionContext } from '@codemirror/autocomplete';
|
||||||
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
|
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
|
||||||
|
@ -75,10 +75,18 @@ function getCompletionsWithDot(): readonly Completion[] {
|
||||||
return completionResult?.options ?? [];
|
return completionResult?.options ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(tip, (newTip) => {
|
onBeforeUnmount(() => {
|
||||||
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
|
ndvStore.setHighlightDraggables(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
tip,
|
||||||
|
(newTip) => {
|
||||||
|
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
[() => props.selection, () => props.unresolvedExpression],
|
[() => props.selection, () => props.unresolvedExpression],
|
||||||
() => {
|
() => {
|
||||||
|
|
|
@ -27,6 +27,7 @@ describe('InlineExpressionTip.vue', () => {
|
||||||
mockNdvState = {
|
mockNdvState = {
|
||||||
hasInputData: true,
|
hasInputData: true,
|
||||||
isNDVDataEmpty: vi.fn(() => true),
|
isNDVDataEmpty: vi.fn(() => true),
|
||||||
|
setHighlightDraggables: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -43,11 +44,16 @@ describe('InlineExpressionTip.vue', () => {
|
||||||
hasInputData: true,
|
hasInputData: true,
|
||||||
isNDVDataEmpty: vi.fn(() => false),
|
isNDVDataEmpty: vi.fn(() => false),
|
||||||
focusedMappableInput: 'Some Input',
|
focusedMappableInput: 'Some Input',
|
||||||
|
setHighlightDraggables: vi.fn(),
|
||||||
};
|
};
|
||||||
const { container } = renderComponent(InlineExpressionTip, {
|
const { container, unmount } = renderComponent(InlineExpressionTip, {
|
||||||
pinia: createTestingPinia(),
|
pinia: createTestingPinia(),
|
||||||
});
|
});
|
||||||
|
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
|
||||||
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
|
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,6 +64,7 @@ describe('InlineExpressionTip.vue', () => {
|
||||||
isInputParentOfActiveNode: true,
|
isInputParentOfActiveNode: true,
|
||||||
isNDVDataEmpty: vi.fn(() => false),
|
isNDVDataEmpty: vi.fn(() => false),
|
||||||
focusedMappableInput: 'Some Input',
|
focusedMappableInput: 'Some Input',
|
||||||
|
setHighlightDraggables: vi.fn(),
|
||||||
};
|
};
|
||||||
const { container } = renderComponent(InlineExpressionTip, {
|
const { container } = renderComponent(InlineExpressionTip, {
|
||||||
pinia: createTestingPinia(),
|
pinia: createTestingPinia(),
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -69,6 +70,7 @@ const extensions = computed(() => {
|
||||||
foldGutter(),
|
foldGutter(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
|
mappingDropCursor(),
|
||||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||||
if (!viewUpdate.docChanged || !editor.value) return;
|
if (!viewUpdate.docChanged || !editor.value) return;
|
||||||
emit('update:modelValue', editor.value?.state.doc.toString());
|
emit('update:modelValue', editor.value?.state.doc.toString());
|
||||||
|
|
|
@ -48,7 +48,7 @@ import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
|
||||||
import ModalRoot from '@/components/ModalRoot.vue';
|
import ModalRoot from '@/components/ModalRoot.vue';
|
||||||
import PersonalizationModal from '@/components/PersonalizationModal.vue';
|
import PersonalizationModal from '@/components/PersonalizationModal.vue';
|
||||||
import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
|
import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
|
||||||
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.vue';
|
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
|
||||||
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
||||||
import NpsSurvey from '@/components/NpsSurvey.vue';
|
import NpsSurvey from '@/components/NpsSurvey.vue';
|
||||||
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
||||||
|
|
|
@ -60,6 +60,7 @@ function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: stri
|
||||||
categories: [category],
|
categories: [category],
|
||||||
},
|
},
|
||||||
iconUrl: nodeTypeDescription.iconUrl,
|
iconUrl: nodeTypeDescription.iconUrl,
|
||||||
|
iconColor: nodeTypeDescription.iconColor,
|
||||||
outputs: nodeTypeDescription.outputs,
|
outputs: nodeTypeDescription.outputs,
|
||||||
icon: nodeTypeDescription.icon,
|
icon: nodeTypeDescription.icon,
|
||||||
defaults: nodeTypeDescription.defaults,
|
defaults: nodeTypeDescription.defaults,
|
||||||
|
|
|
@ -177,6 +177,15 @@ const displayValue = computed(() => {
|
||||||
if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) {
|
if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) {
|
||||||
return i18n.baseText('parameterInput.loadOptionsError');
|
return i18n.baseText('parameterInput.loadOptionsError');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodeType.value?.credentials && nodeType.value?.credentials?.length > 0) {
|
||||||
|
const credentialsType = nodeType.value?.credentials[0];
|
||||||
|
|
||||||
|
if (credentialsType.required && !node.value?.credentials) {
|
||||||
|
return i18n.baseText('parameterInput.loadOptionsCredentialsRequired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return i18n.baseText('parameterInput.loadOptionsErrorService', {
|
return i18n.baseText('parameterInput.loadOptionsErrorService', {
|
||||||
interpolate: { service: nodeType.value.displayName },
|
interpolate: { service: nodeType.value.displayName },
|
||||||
});
|
});
|
||||||
|
@ -510,6 +519,28 @@ const isCodeNode = computed(
|
||||||
|
|
||||||
const isHtmlNode = computed(() => !!node.value && node.value.type === HTML_NODE_TYPE);
|
const isHtmlNode = computed(() => !!node.value && node.value.type === HTML_NODE_TYPE);
|
||||||
|
|
||||||
|
const isInputTypeString = computed(() => props.parameter.type === 'string');
|
||||||
|
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
|
||||||
|
|
||||||
|
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
|
||||||
|
const isDropDisabled = computed(
|
||||||
|
() =>
|
||||||
|
props.parameter.noDataExpression ||
|
||||||
|
props.isReadOnly ||
|
||||||
|
isResourceLocatorParameter.value ||
|
||||||
|
isModelValueExpression.value,
|
||||||
|
);
|
||||||
|
const showDragnDropTip = computed(
|
||||||
|
() =>
|
||||||
|
isFocused.value &&
|
||||||
|
(isInputTypeString.value || isInputTypeNumber.value) &&
|
||||||
|
!isModelValueExpression.value &&
|
||||||
|
!isDropDisabled.value &&
|
||||||
|
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
|
||||||
|
!ndvStore.isMappingOnboarded &&
|
||||||
|
ndvStore.isInputParentOfActiveNode,
|
||||||
|
);
|
||||||
|
|
||||||
function isRemoteParameterOption(option: INodePropertyOptions) {
|
function isRemoteParameterOption(option: INodePropertyOptions) {
|
||||||
return remoteParameterOptionsKeys.value.includes(option.name);
|
return remoteParameterOptionsKeys.value.includes(option.name);
|
||||||
}
|
}
|
||||||
|
@ -965,7 +996,11 @@ onUpdated(async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop>
|
<div
|
||||||
|
ref="wrapper"
|
||||||
|
:class="[parameterInputClasses, { [$style.tipVisible]: showDragnDropTip }]"
|
||||||
|
@keydown.stop
|
||||||
|
>
|
||||||
<ExpressionEditModal
|
<ExpressionEditModal
|
||||||
:dialog-visible="expressionEditDialogVisible"
|
:dialog-visible="expressionEditDialogVisible"
|
||||||
:model-value="modelValueExpressionEdit"
|
:model-value="modelValueExpressionEdit"
|
||||||
|
@ -1249,6 +1284,7 @@ onUpdated(async () => {
|
||||||
"
|
"
|
||||||
:title="displayTitle"
|
:title="displayTitle"
|
||||||
:placeholder="getPlaceholder()"
|
:placeholder="getPlaceholder()"
|
||||||
|
data-test-id="parameter-input-field"
|
||||||
@update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)"
|
@update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)"
|
||||||
@keydown.stop
|
@keydown.stop
|
||||||
@focus="setFocus"
|
@focus="setFocus"
|
||||||
|
@ -1447,6 +1483,9 @@ onUpdated(async () => {
|
||||||
:disabled="isReadOnly"
|
:disabled="isReadOnly"
|
||||||
@update:model-value="valueChanged"
|
@update:model-value="valueChanged"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="showDragnDropTip" :class="$style.tip">
|
||||||
|
<InlineExpressionTip />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ParameterIssues
|
<ParameterIssues
|
||||||
|
@ -1477,6 +1516,7 @@ onUpdated(async () => {
|
||||||
|
|
||||||
.parameter-input {
|
.parameter-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
:deep(.color-input) {
|
:deep(.color-input) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -1609,3 +1649,23 @@ onUpdated(async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.tipVisible {
|
||||||
|
--input-border-bottom-left-radius: 0;
|
||||||
|
--input-border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 100%;
|
||||||
|
background: var(--color-code-background);
|
||||||
|
border: var(--border-base);
|
||||||
|
border-top: none;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/util
|
||||||
import { isResourceLocatorValue } from '@/utils/typeGuards';
|
import { isResourceLocatorValue } from '@/utils/typeGuards';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
|
import type { INodeProperties, IParameterLabel, NodeParameterValueType } from 'n8n-workflow';
|
||||||
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
parameter: INodeProperties;
|
parameter: INodeProperties;
|
||||||
|
@ -57,8 +56,7 @@ const ndvStore = useNDVStore();
|
||||||
|
|
||||||
const node = computed(() => ndvStore.activeNode);
|
const node = computed(() => ndvStore.activeNode);
|
||||||
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
|
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
|
||||||
const isInputTypeString = computed(() => props.parameter.type === 'string');
|
|
||||||
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
|
|
||||||
const isResourceLocator = computed(
|
const isResourceLocator = computed(
|
||||||
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
|
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
|
||||||
);
|
);
|
||||||
|
@ -73,17 +71,6 @@ const isExpression = computed(() => isValueExpression(props.parameter, props.val
|
||||||
const showExpressionSelector = computed(() =>
|
const showExpressionSelector = computed(() =>
|
||||||
isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true,
|
isResourceLocator.value ? !hasOnlyListMode(props.parameter) : true,
|
||||||
);
|
);
|
||||||
const isInputDataEmpty = computed(() => ndvStore.isNDVDataEmpty('input'));
|
|
||||||
const showDragnDropTip = computed(
|
|
||||||
() =>
|
|
||||||
focused.value &&
|
|
||||||
(isInputTypeString.value || isInputTypeNumber.value) &&
|
|
||||||
!isExpression.value &&
|
|
||||||
!isDropDisabled.value &&
|
|
||||||
(!ndvStore.hasInputData || !isInputDataEmpty.value) &&
|
|
||||||
!ndvStore.isMappingOnboarded &&
|
|
||||||
ndvStore.isInputParentOfActiveNode,
|
|
||||||
);
|
|
||||||
|
|
||||||
function onFocus() {
|
function onFocus() {
|
||||||
focused.value = true;
|
focused.value = true;
|
||||||
|
@ -205,7 +192,7 @@ function onDrop(newParamValue: string) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n8n-input-label
|
<n8n-input-label
|
||||||
:class="[$style.wrapper, { [$style.tipVisible]: showDragnDropTip }]"
|
:class="[$style.wrapper]"
|
||||||
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
|
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
|
||||||
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
|
:tooltip-text="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
|
||||||
:show-tooltip="focused"
|
:show-tooltip="focused"
|
||||||
|
@ -258,9 +245,6 @@ function onDrop(newParamValue: string) {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DraggableTarget>
|
</DraggableTarget>
|
||||||
<div v-if="showDragnDropTip" :class="$style.tip">
|
|
||||||
<InlineExpressionTip />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
[$style.options]: true,
|
[$style.options]: true,
|
||||||
|
@ -292,24 +276,6 @@ function onDrop(newParamValue: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipVisible {
|
|
||||||
--input-border-bottom-left-radius: 0;
|
|
||||||
--input-border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
top: 100%;
|
|
||||||
background: var(--color-code-background);
|
|
||||||
border: var(--border-base);
|
|
||||||
border-top: none;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
.options {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -22px;
|
bottom: -22px;
|
||||||
|
|
|
@ -34,8 +34,9 @@ import {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
keywordCompletionSource,
|
keywordCompletionSource,
|
||||||
} from '@n8n/codemirror-lang-sql';
|
} from '@n8n/codemirror-lang-sql';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
const SQL_DIALECTS = {
|
const SQL_DIALECTS = {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
|
@ -111,6 +112,7 @@ const extensions = computed(() => {
|
||||||
foldGutter(),
|
foldGutter(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
|
mappingDropCursor(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return baseExtensions;
|
return baseExtensions;
|
||||||
|
@ -178,11 +180,28 @@ function highlightLine(lineNumber: number | 'final') {
|
||||||
selection: { anchor: lineToHighlight.from },
|
selection: { anchor: lineToHighlight.from },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDrop(value: string, event: MouseEvent) {
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
|
await dropInExpressionEditor(toRaw(editor.value), event, value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.sqlEditor">
|
<div :class="$style.sqlEditor">
|
||||||
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div>
|
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
|
||||||
|
<template #default="{ activeDrop, droppable }">
|
||||||
|
<div
|
||||||
|
ref="sqlEditor"
|
||||||
|
:class="[
|
||||||
|
$style.codemirror,
|
||||||
|
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||||
|
]"
|
||||||
|
data-test-id="sql-editor-container"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</DraggableTarget>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
<InlineExpressionEditorOutput
|
<InlineExpressionEditorOutput
|
||||||
v-if="!fullscreen"
|
v-if="!fullscreen"
|
||||||
|
@ -202,4 +221,21 @@ function highlightLine(lineNumber: number | 'final') {
|
||||||
.codemirror {
|
.codemirror {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codemirror.droppable {
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-color: var(--color-ndv-droppable-parameter);
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror.activeDrop {
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
border-style: solid;
|
||||||
|
cursor: grabbing;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,19 +1,12 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, ref } from 'vue';
|
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||||
import type { PropType, StyleValue } from 'vue';
|
import type { StyleValue } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
|
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
import { isNumber, isString } from '@/utils/typeGuards';
|
import { isNumber, isString } from '@/utils/typeGuards';
|
||||||
import type {
|
import type { INodeUi, XYPosition } from '@/Interface';
|
||||||
INodeUi,
|
|
||||||
INodeUpdatePropertiesInformation,
|
|
||||||
IUpdateInformation,
|
|
||||||
XYPosition,
|
|
||||||
} from '@/Interface';
|
|
||||||
|
|
||||||
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
|
|
||||||
import { QUICKSTART_NOTE_NAME } from '@/constants';
|
import { QUICKSTART_NOTE_NAME } from '@/constants';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -25,313 +18,280 @@ import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { assert } from '@/utils/assert';
|
import { assert } from '@/utils/assert';
|
||||||
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
|
||||||
import { useNodeBase } from '@/composables/useNodeBase';
|
import { useNodeBase } from '@/composables/useNodeBase';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'Sticky',
|
defineProps<{
|
||||||
props: {
|
nodeViewScale?: number;
|
||||||
nodeViewScale: {
|
gridSize?: number;
|
||||||
type: Number,
|
name: string;
|
||||||
default: 1,
|
instance: BrowserJsPlumbInstance;
|
||||||
},
|
isReadOnly?: boolean;
|
||||||
gridSize: {
|
isActive?: boolean;
|
||||||
type: Number,
|
hideActions?: boolean;
|
||||||
default: GRID_SIZE,
|
disableSelecting?: boolean;
|
||||||
},
|
showCustomTooltip?: boolean;
|
||||||
name: {
|
workflow: Workflow;
|
||||||
type: String,
|
}>(),
|
||||||
required: true,
|
{
|
||||||
},
|
nodeViewScale: 1,
|
||||||
instance: {
|
gridSize: GRID_SIZE,
|
||||||
type: Object as PropType<BrowserJsPlumbInstance>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isReadOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
isActive: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
hideActions: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
disableSelecting: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
showCustomTooltip: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
workflow: {
|
|
||||||
type: Object as PropType<Workflow>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
emits: { removeNode: null, nodeSelected: null },
|
);
|
||||||
setup(props, { emit }) {
|
|
||||||
const deviceSupport = useDeviceSupport();
|
|
||||||
const toast = useToast();
|
|
||||||
const forceActions = ref(false);
|
|
||||||
const isColorPopoverVisible = ref(false);
|
|
||||||
|
|
||||||
const stickOptions = ref<HTMLElement>();
|
defineOptions({ name: 'Sticky' });
|
||||||
|
|
||||||
const setForceActions = (value: boolean) => {
|
const emit = defineEmits<{
|
||||||
forceActions.value = value;
|
removeNode: [string];
|
||||||
};
|
nodeSelected: [string, boolean, boolean];
|
||||||
const setColorPopoverVisible = (value: boolean) => {
|
}>();
|
||||||
isColorPopoverVisible.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextMenu = useContextMenu((action) => {
|
const deviceSupport = useDeviceSupport();
|
||||||
if (action === 'change_color') {
|
const telemetry = useTelemetry();
|
||||||
setForceActions(true);
|
const toast = useToast();
|
||||||
setColorPopoverVisible(true);
|
const ndvStore = useNDVStore();
|
||||||
}
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
});
|
const uiStore = useUIStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
const nodeBase = useNodeBase({
|
const isResizing = ref<boolean>(false);
|
||||||
name: props.name,
|
const isTouchActive = ref<boolean>(false);
|
||||||
instance: props.instance,
|
const forceActions = ref(false);
|
||||||
workflowObject: props.workflow,
|
const isColorPopoverVisible = ref(false);
|
||||||
isReadOnly: props.isReadOnly,
|
const stickOptions = ref<HTMLElement>();
|
||||||
emit: emit as (event: string, ...args: unknown[]) => void,
|
|
||||||
});
|
|
||||||
|
|
||||||
onClickOutside(stickOptions, () => setColorPopoverVisible(false));
|
const setForceActions = (value: boolean) => {
|
||||||
|
forceActions.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
const setColorPopoverVisible = (value: boolean) => {
|
||||||
deviceSupport,
|
isColorPopoverVisible.value = value;
|
||||||
toast,
|
};
|
||||||
contextMenu,
|
|
||||||
forceActions,
|
|
||||||
...nodeBase,
|
|
||||||
setForceActions,
|
|
||||||
isColorPopoverVisible,
|
|
||||||
setColorPopoverVisible,
|
|
||||||
stickOptions,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isResizing: false,
|
|
||||||
isTouchActive: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(
|
|
||||||
useNodeTypesStore,
|
|
||||||
useUIStore,
|
|
||||||
useNDVStore,
|
|
||||||
useCanvasStore,
|
|
||||||
useWorkflowsStore,
|
|
||||||
useHistoryStore,
|
|
||||||
),
|
|
||||||
data(): INodeUi | null {
|
|
||||||
return this.workflowsStore.getNodeByName(this.name);
|
|
||||||
},
|
|
||||||
nodeId(): string {
|
|
||||||
return this.data?.id || '';
|
|
||||||
},
|
|
||||||
defaultText(): string {
|
|
||||||
if (!this.nodeType) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const properties = this.nodeType.properties;
|
|
||||||
const content = properties.find((property) => property.name === 'content');
|
|
||||||
|
|
||||||
return content && isString(content.default) ? content.default : '';
|
const contextMenu = useContextMenu((action) => {
|
||||||
},
|
if (action === 'change_color') {
|
||||||
isSelected(): boolean {
|
setForceActions(true);
|
||||||
return (
|
setColorPopoverVisible(true);
|
||||||
this.uiStore.getSelectedNodes.find((node: INodeUi) => node.name === this.data?.name) !==
|
}
|
||||||
undefined
|
|
||||||
);
|
|
||||||
},
|
|
||||||
nodeType(): INodeTypeDescription | null {
|
|
||||||
return this.data && this.nodeTypesStore.getNodeType(this.data.type, this.data.typeVersion);
|
|
||||||
},
|
|
||||||
node(): INodeUi | null {
|
|
||||||
// same as this.data but reactive..
|
|
||||||
return this.workflowsStore.getNodeByName(this.name);
|
|
||||||
},
|
|
||||||
position(): XYPosition {
|
|
||||||
if (this.node) {
|
|
||||||
return this.node.position;
|
|
||||||
} else {
|
|
||||||
return [0, 0];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
height(): number {
|
|
||||||
return this.node && isNumber(this.node.parameters.height) ? this.node.parameters.height : 0;
|
|
||||||
},
|
|
||||||
width(): number {
|
|
||||||
return this.node && isNumber(this.node.parameters.width) ? this.node.parameters.width : 0;
|
|
||||||
},
|
|
||||||
stickySize(): StyleValue {
|
|
||||||
const returnStyles: {
|
|
||||||
[key: string]: string | number;
|
|
||||||
} = {
|
|
||||||
height: this.height + 'px',
|
|
||||||
width: this.width + 'px',
|
|
||||||
};
|
|
||||||
|
|
||||||
return returnStyles;
|
|
||||||
},
|
|
||||||
stickyPosition(): StyleValue {
|
|
||||||
const returnStyles: {
|
|
||||||
[key: string]: string | number;
|
|
||||||
} = {
|
|
||||||
left: this.position[0] + 'px',
|
|
||||||
top: this.position[1] + 'px',
|
|
||||||
zIndex: this.isActive ? 9999999 : -1 * Math.floor((this.height * this.width) / 1000),
|
|
||||||
};
|
|
||||||
|
|
||||||
return returnStyles;
|
|
||||||
},
|
|
||||||
showActions(): boolean {
|
|
||||||
return (
|
|
||||||
!(this.hideActions || this.isReadOnly || this.workflowRunning || this.isResizing) ||
|
|
||||||
this.forceActions
|
|
||||||
);
|
|
||||||
},
|
|
||||||
workflowRunning(): boolean {
|
|
||||||
return this.uiStore.isActionActive['workflowRunning'];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Initialize the node
|
|
||||||
if (this.data !== null) {
|
|
||||||
try {
|
|
||||||
this.addNode(this.data);
|
|
||||||
} catch (error) {
|
|
||||||
// This breaks when new nodes are loaded into store but workflow tab is not currently active
|
|
||||||
// Shouldn't affect anything
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onShowPopover() {
|
|
||||||
this.setForceActions(true);
|
|
||||||
},
|
|
||||||
onHidePopover() {
|
|
||||||
this.setForceActions(false);
|
|
||||||
},
|
|
||||||
async deleteNode() {
|
|
||||||
assert(this.data);
|
|
||||||
// Wait a tick else vue causes problems because the data is gone
|
|
||||||
await this.$nextTick();
|
|
||||||
this.$emit('removeNode', this.data.name);
|
|
||||||
},
|
|
||||||
changeColor(index: number) {
|
|
||||||
this.workflowsStore.updateNodeProperties({
|
|
||||||
name: this.name,
|
|
||||||
properties: {
|
|
||||||
parameters: {
|
|
||||||
...this.node?.parameters,
|
|
||||||
color: index,
|
|
||||||
},
|
|
||||||
position: this.node?.position ?? [0, 0],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onEdit(edit: boolean) {
|
|
||||||
if (edit && !this.isActive && this.node) {
|
|
||||||
this.ndvStore.activeNodeName = this.node.name;
|
|
||||||
} else if (this.isActive && !edit) {
|
|
||||||
this.ndvStore.activeNodeName = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMarkdownClick(link: HTMLAnchorElement) {
|
|
||||||
if (link) {
|
|
||||||
const isOnboardingNote = this.name === QUICKSTART_NOTE_NAME;
|
|
||||||
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
|
|
||||||
const type =
|
|
||||||
isOnboardingNote && isWelcomeVideo
|
|
||||||
? 'welcome_video'
|
|
||||||
: isOnboardingNote && link.getAttribute('href') === '/templates'
|
|
||||||
? 'templates'
|
|
||||||
: 'other';
|
|
||||||
|
|
||||||
this.$telemetry.track('User clicked note link', { type });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onInputChange(content: string) {
|
|
||||||
if (!this.node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.node.parameters.content = content;
|
|
||||||
this.setParameters({ content });
|
|
||||||
},
|
|
||||||
onResizeStart() {
|
|
||||||
this.isResizing = true;
|
|
||||||
if (!this.isSelected && this.node) {
|
|
||||||
this.$emit('nodeSelected', this.node.name, false, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onResize({ height, width, dX, dY }: { width: number; height: number; dX: number; dY: number }) {
|
|
||||||
if (!this.node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dX !== 0 || dY !== 0) {
|
|
||||||
this.setPosition([this.node.position[0] + (dX || 0), this.node.position[1] + (dY || 0)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setParameters({ height, width });
|
|
||||||
},
|
|
||||||
onResizeEnd() {
|
|
||||||
this.isResizing = false;
|
|
||||||
},
|
|
||||||
setParameters(params: { content?: string; height?: number; width?: number; color?: string }) {
|
|
||||||
if (this.node) {
|
|
||||||
const nodeParameters = {
|
|
||||||
content: isString(params.content) ? params.content : this.node.parameters.content,
|
|
||||||
height: isNumber(params.height) ? params.height : this.node.parameters.height,
|
|
||||||
width: isNumber(params.width) ? params.width : this.node.parameters.width,
|
|
||||||
color: isString(params.color) ? params.color : this.node.parameters.color,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateInformation: IUpdateInformation = {
|
|
||||||
key: this.node.id,
|
|
||||||
name: this.node.name,
|
|
||||||
value: nodeParameters,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.workflowsStore.setNodeParameters(updateInformation);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setPosition(position: XYPosition) {
|
|
||||||
if (!this.node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateInformation: INodeUpdatePropertiesInformation = {
|
|
||||||
name: this.node.name,
|
|
||||||
properties: {
|
|
||||||
position,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.workflowsStore.updateNodeProperties(updateInformation);
|
|
||||||
},
|
|
||||||
touchStart() {
|
|
||||||
if (this.deviceSupport.isTouchDevice && !this.deviceSupport.isMacOs && !this.isTouchActive) {
|
|
||||||
this.isTouchActive = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isTouchActive = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onContextMenu(e: MouseEvent): void {
|
|
||||||
if (this.node && !this.isActive) {
|
|
||||||
this.contextMenu.open(e, { source: 'node-right-click', nodeId: this.node.id });
|
|
||||||
} else {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nodeBase = useNodeBase({
|
||||||
|
name: props.name,
|
||||||
|
instance: props.instance,
|
||||||
|
workflowObject: props.workflow,
|
||||||
|
isReadOnly: props.isReadOnly,
|
||||||
|
emit: emit as (event: string, ...args: unknown[]) => void,
|
||||||
|
});
|
||||||
|
|
||||||
|
onClickOutside(stickOptions, () => setColorPopoverVisible(false));
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
deviceSupport,
|
||||||
|
toast,
|
||||||
|
contextMenu,
|
||||||
|
forceActions,
|
||||||
|
...nodeBase,
|
||||||
|
setForceActions,
|
||||||
|
isColorPopoverVisible,
|
||||||
|
setColorPopoverVisible,
|
||||||
|
stickOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = computed(() => workflowsStore.getNodeByName(props.name));
|
||||||
|
// TODO: remove either node or data
|
||||||
|
const node = computed(() => workflowsStore.getNodeByName(props.name));
|
||||||
|
const nodeId = computed(() => data.value?.id);
|
||||||
|
const nodeType = computed(() => {
|
||||||
|
return data.value && nodeTypesStore.getNodeType(data.value.type, data.value.typeVersion);
|
||||||
|
});
|
||||||
|
const defaultText = computed(() => {
|
||||||
|
if (!nodeType.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const properties = nodeType.value.properties;
|
||||||
|
const content = properties.find((property) => property.name === 'content');
|
||||||
|
return content && isString(content.default) ? content.default : '';
|
||||||
|
});
|
||||||
|
const isSelected = computed(
|
||||||
|
() =>
|
||||||
|
uiStore.getSelectedNodes.find(({ name }: INodeUi) => name === data.value?.name) !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const position = computed<XYPosition>(() => (node.value ? node.value.position : [0, 0]));
|
||||||
|
|
||||||
|
const height = computed(() =>
|
||||||
|
node.value && isNumber(node.value.parameters.height) ? node.value.parameters.height : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const width = computed(() =>
|
||||||
|
node.value && isNumber(node.value.parameters.width) ? node.value.parameters.width : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stickySize = computed<StyleValue>(() => ({
|
||||||
|
height: height.value + 'px',
|
||||||
|
width: width.value + 'px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stickyPosition = computed<StyleValue>(() => ({
|
||||||
|
left: position.value[0] + 'px',
|
||||||
|
top: position.value[1] + 'px',
|
||||||
|
zIndex: props.isActive ? 9999999 : -1 * Math.floor((height.value * width.value) / 1000),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
|
||||||
|
|
||||||
|
const showActions = computed(
|
||||||
|
() =>
|
||||||
|
!(props.hideActions || props.isReadOnly || workflowRunning.value || isResizing.value) ||
|
||||||
|
forceActions.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Initialize the node
|
||||||
|
if (data.value !== null) {
|
||||||
|
try {
|
||||||
|
nodeBase.addNode(data.value);
|
||||||
|
} catch (error) {
|
||||||
|
// This breaks when new nodes are loaded into store but workflow tab is not currently active
|
||||||
|
// Shouldn't affect anything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onShowPopover = () => setForceActions(true);
|
||||||
|
const onHidePopover = () => setForceActions(false);
|
||||||
|
const deleteNode = async () => {
|
||||||
|
assert(data.value);
|
||||||
|
// Wait a tick else vue causes problems because the data is gone
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
emit('removeNode', data.value.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeColor = (index: number) => {
|
||||||
|
workflowsStore.updateNodeProperties({
|
||||||
|
name: props.name,
|
||||||
|
properties: {
|
||||||
|
parameters: {
|
||||||
|
...node.value?.parameters,
|
||||||
|
color: index,
|
||||||
|
},
|
||||||
|
position: node.value?.position ?? [0, 0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdit = (edit: boolean) => {
|
||||||
|
if (edit && !props.isActive && node.value) {
|
||||||
|
ndvStore.activeNodeName = node.value.name;
|
||||||
|
} else if (props.isActive && !edit) {
|
||||||
|
ndvStore.activeNodeName = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMarkdownClick = (link: HTMLAnchorElement) => {
|
||||||
|
if (link) {
|
||||||
|
const isOnboardingNote = props.name === QUICKSTART_NOTE_NAME;
|
||||||
|
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
|
||||||
|
const type =
|
||||||
|
isOnboardingNote && isWelcomeVideo
|
||||||
|
? 'welcome_video'
|
||||||
|
: isOnboardingNote && link.getAttribute('href') === '/templates'
|
||||||
|
? 'templates'
|
||||||
|
: 'other';
|
||||||
|
|
||||||
|
telemetry.track('User clicked note link', { type });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setParameters = (params: {
|
||||||
|
content?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
color?: string;
|
||||||
|
}) => {
|
||||||
|
if (node.value) {
|
||||||
|
const nodeParameters = {
|
||||||
|
content: isString(params.content) ? params.content : node.value.parameters.content,
|
||||||
|
height: isNumber(params.height) ? params.height : node.value.parameters.height,
|
||||||
|
width: isNumber(params.width) ? params.width : node.value.parameters.width,
|
||||||
|
color: isString(params.color) ? params.color : node.value.parameters.color,
|
||||||
|
};
|
||||||
|
|
||||||
|
workflowsStore.setNodeParameters({
|
||||||
|
key: node.value.id,
|
||||||
|
name: node.value.name,
|
||||||
|
value: nodeParameters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInputChange = (content: string) => {
|
||||||
|
if (!node.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.value.parameters.content = content;
|
||||||
|
setParameters({ content });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPosition = (newPosition: XYPosition) => {
|
||||||
|
if (!node.value) return;
|
||||||
|
|
||||||
|
workflowsStore.updateNodeProperties({
|
||||||
|
name: node.value.name,
|
||||||
|
properties: { position: newPosition },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResizeStart = () => {
|
||||||
|
isResizing.value = true;
|
||||||
|
if (!isSelected.value && node.value) {
|
||||||
|
emit('nodeSelected', node.value.name, false, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResize = ({
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
dX,
|
||||||
|
dY,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dX: number;
|
||||||
|
dY: number;
|
||||||
|
}) => {
|
||||||
|
if (!node.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dX !== 0 || dY !== 0) {
|
||||||
|
setPosition([node.value.position[0] + (dX || 0), node.value.position[1] + (dY || 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setParameters({ height, width });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResizeEnd = () => {
|
||||||
|
isResizing.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchStart = () => {
|
||||||
|
if (deviceSupport.isTouchDevice && !deviceSupport.isMacOs && !isTouchActive.value) {
|
||||||
|
isTouchActive.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
isTouchActive.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenu = (e: MouseEvent): void => {
|
||||||
|
if (node.value && !props.isActive) {
|
||||||
|
contextMenu.open(e, { source: 'node-right-click', nodeId: node.value.id });
|
||||||
|
} else {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -355,9 +315,9 @@ export default defineComponent({
|
||||||
<div v-show="isSelected" class="select-sticky-background" />
|
<div v-show="isSelected" class="select-sticky-background" />
|
||||||
<div
|
<div
|
||||||
v-touch:start="touchStart"
|
v-touch:start="touchStart"
|
||||||
v-touch:end="touchEnd"
|
v-touch:end="nodeBase.touchEnd"
|
||||||
class="sticky-box"
|
class="sticky-box"
|
||||||
@click.left="mouseLeftClick"
|
@click.left="nodeBase.mouseLeftClick"
|
||||||
@contextmenu="onContextMenu"
|
@contextmenu="onContextMenu"
|
||||||
>
|
>
|
||||||
<N8nResizeableSticky
|
<N8nResizeableSticky
|
||||||
|
|
|
@ -1,66 +1,111 @@
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||||
|
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||||
import { STORES } from '@/constants';
|
|
||||||
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
|
|
||||||
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
const twitterV1 = mock<INodeTypeDescription>({
|
import { useRootStore } from '@/stores/root.store';
|
||||||
version: 1,
|
import { useNodeTypesStore } from '../../stores/nodeTypes.store';
|
||||||
credentials: [{ name: 'twitterOAuth1Api', required: true }],
|
|
||||||
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
|
||||||
});
|
|
||||||
|
|
||||||
const twitterV2 = mock<INodeTypeDescription>({
|
|
||||||
version: 2,
|
|
||||||
credentials: [{ name: 'twitterOAuth2Api', required: true }],
|
|
||||||
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeTypes = groupNodeTypesByNameAndType([twitterV1, twitterV2]);
|
|
||||||
const initialState = {
|
|
||||||
[STORES.CREDENTIALS]: {},
|
|
||||||
[STORES.NODE_TYPES]: { nodeTypes },
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CredentialIcon, {
|
|
||||||
pinia: createTestingPinia({ initialState }),
|
|
||||||
global: {
|
|
||||||
stubs: ['n8n-tooltip'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CredentialIcon', () => {
|
describe('CredentialIcon', () => {
|
||||||
const findIcon = (baseElement: Element) => baseElement.querySelector('img');
|
const renderComponent = createComponentRenderer(CredentialIcon, {
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
global: {
|
||||||
|
stubs: ['n8n-tooltip'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let pinia: TestingPinia;
|
||||||
|
|
||||||
it('shows correct icon for credential type that is for the latest node type version', () => {
|
beforeEach(() => {
|
||||||
const { baseElement } = renderComponent({
|
pinia = createTestingPinia({ stubActions: false });
|
||||||
pinia: createTestingPinia({ initialState }),
|
|
||||||
props: {
|
|
||||||
credentialTypeName: 'twitterOAuth2Api',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(findIcon(baseElement)).toHaveAttribute(
|
|
||||||
'src',
|
|
||||||
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows correct icon for credential type that is for an older node type version', () => {
|
it('shows correct icon when iconUrl is set on credential', () => {
|
||||||
const { baseElement } = renderComponent({
|
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
|
||||||
pinia: createTestingPinia({ initialState }),
|
useCredentialsStore().setCredentialTypes([
|
||||||
|
mock<ICredentialType>({
|
||||||
|
name: 'test',
|
||||||
|
iconUrl: testIconUrl,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
props: {
|
props: {
|
||||||
credentialTypeName: 'twitterOAuth1Api',
|
credentialTypeName: 'test',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findIcon(baseElement)).toHaveAttribute(
|
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
|
||||||
'src',
|
});
|
||||||
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
|
|
||||||
);
|
it('shows correct icon when icon is set on credential', () => {
|
||||||
|
useCredentialsStore().setCredentialTypes([
|
||||||
|
mock<ICredentialType>({
|
||||||
|
name: 'test',
|
||||||
|
icon: 'fa:clock',
|
||||||
|
iconColor: 'azure',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
|
props: {
|
||||||
|
credentialTypeName: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = getByRole('img', { hidden: true });
|
||||||
|
expect(icon.tagName).toBe('svg');
|
||||||
|
expect(icon).toHaveClass('fa-clock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct icon when credential has an icon with node: prefix', () => {
|
||||||
|
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
|
||||||
|
useCredentialsStore().setCredentialTypes([
|
||||||
|
mock<ICredentialType>({
|
||||||
|
name: 'test',
|
||||||
|
icon: 'node:n8n-nodes-base.test',
|
||||||
|
iconColor: 'azure',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
useNodeTypesStore().setNodeTypes([
|
||||||
|
mock<INodeTypeDescription>({
|
||||||
|
version: 1,
|
||||||
|
name: 'n8n-nodes-base.test',
|
||||||
|
iconUrl: testIconUrl,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({
|
||||||
|
pinia,
|
||||||
|
props: {
|
||||||
|
credentialTypeName: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback icon when icon is not found', () => {
|
||||||
|
useCredentialsStore().setCredentialTypes([
|
||||||
|
mock<ICredentialType>({
|
||||||
|
name: 'test',
|
||||||
|
icon: 'node:n8n-nodes-base.test',
|
||||||
|
iconColor: 'azure',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { baseElement } = renderComponent({
|
||||||
|
pinia,
|
||||||
|
props: {
|
||||||
|
credentialTypeName: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(baseElement.querySelector('.nodeIconPlaceholder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -54,6 +54,7 @@ describe('ParameterInput.vue', () => {
|
||||||
type: 'test',
|
type: 'test',
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
},
|
},
|
||||||
|
isNDVDataEmpty: vi.fn(() => false),
|
||||||
};
|
};
|
||||||
mockNodeTypesState = {
|
mockNodeTypesState = {
|
||||||
allNodeTypes: [],
|
allNodeTypes: [],
|
||||||
|
@ -167,4 +168,47 @@ describe('ParameterInput.vue', () => {
|
||||||
// Nothing should be emitted
|
// Nothing should be emitted
|
||||||
expect(emitted('update')).toBeUndefined();
|
expect(emitted('update')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show message when can not load options without credentials', async () => {
|
||||||
|
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => {
|
||||||
|
throw new Error('Node does not have any credentials set');
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error Readonly property
|
||||||
|
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
|
||||||
|
displayName: 'Test',
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: 'openAiApi',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { emitted, container, getByTestId } = renderComponent(ParameterInput, {
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
props: {
|
||||||
|
path: 'columns',
|
||||||
|
parameter: {
|
||||||
|
displayName: 'Columns',
|
||||||
|
name: 'columns',
|
||||||
|
type: 'options',
|
||||||
|
typeOptions: { loadOptionsMethod: 'getColumnsMultiOptions' },
|
||||||
|
},
|
||||||
|
modelValue: 'id',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('parameter-input-field')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const input = container.querySelector('input') as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(input.value.toLowerCase()).not.toContain('error');
|
||||||
|
expect(input).toHaveValue('Set up credential to see options');
|
||||||
|
|
||||||
|
expect(emitted('update')).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { usePostHog } from '@/stores/posthog.store';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import type { Placement } from '@floating-ui/core';
|
import type { Placement } from '@floating-ui/core';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
|
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
|
||||||
|
|
||||||
export type ExecutionFilterProps = {
|
export type ExecutionFilterProps = {
|
||||||
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
|
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
|
import type { AnnotationVote, ExecutionSummary } from 'n8n-workflow';
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.vue';
|
import AnnotationTagsDropdown from '@/components/AnnotationTagsDropdown.ee.vue';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
import { createEventBus } from 'n8n-design-system';
|
||||||
import VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
|
import VoteButtons from '@/components/executions/workflow/VoteButtons.vue';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
|
@ -436,6 +436,10 @@ export function useCanvasMapping({
|
||||||
|
|
||||||
let status: CanvasConnectionData['status'];
|
let status: CanvasConnectionData['status'];
|
||||||
if (fromNode) {
|
if (fromNode) {
|
||||||
|
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
|
||||||
|
const runDataTotal =
|
||||||
|
nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0;
|
||||||
|
|
||||||
if (nodeExecutionRunningById.value[fromNode.id]) {
|
if (nodeExecutionRunningById.value[fromNode.id]) {
|
||||||
status = 'running';
|
status = 'running';
|
||||||
} else if (
|
} else if (
|
||||||
|
@ -445,7 +449,7 @@ export function useCanvasMapping({
|
||||||
status = 'pinned';
|
status = 'pinned';
|
||||||
} else if (nodeHasIssuesById.value[fromNode.id]) {
|
} else if (nodeHasIssuesById.value[fromNode.id]) {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
|
} else if (runDataTotal > 0) {
|
||||||
status = 'success';
|
status = 'success';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue