Merge remote-tracking branch 'origin/master' into pay-36-store-execution-metadata

# Conflicts:
#	packages/cli/src/databases/migrations/mysqldb/index.ts
#	packages/cli/src/databases/migrations/postgresdb/index.ts
#	packages/cli/src/databases/migrations/sqlite/index.ts
This commit is contained in:
Csaba Tuncsik 2023-02-27 12:55:03 +01:00
commit a1be6795a1
150 changed files with 2164 additions and 511 deletions

View file

@ -1,3 +1,87 @@
## [0.217.1](https://github.com/n8n-io/n8n/compare/n8n@0.217.0...n8n@0.217.1) (2023-02-24)
### Bug Fixes
* Prevent executions from displaying as running forever ([#5563](https://github.com/n8n-io/n8n/issues/5563)) ([b30db10](https://github.com/n8n-io/n8n/commit/b30db10d898fa791d99d13192ef411cace4f7c05))
# [0.217.0](https://github.com/n8n-io/n8n/compare/n8n@0.216.1...n8n@0.217.0) (2023-02-23)
### Bug Fixes
* **Baserow Node:** Fix issue with get all not correctly using filters ([#5519](https://github.com/n8n-io/n8n/issues/5519)) ([ee21b7a](https://github.com/n8n-io/n8n/commit/ee21b7a1cfed17936eb6bf50221b7f9983dd38e6))
* **Compare Datasets Node:** UI tweaks and fixes ([7ecd5e5](https://github.com/n8n-io/n8n/commit/7ecd5e59eca01ca2b1a01e0a3e3871bd5d322eea))
* **core:** Do not allow arbitrary path traversal in BinaryDataManager ([#5523](https://github.com/n8n-io/n8n/issues/5523)) ([eef2574](https://github.com/n8n-io/n8n/commit/eef257406730a989ec8e7a056c3d4234300fdb25))
* **core:** Do not allow arbitrary path traversal in the credential-translation endpoint ([#5522](https://github.com/n8n-io/n8n/issues/5522)) ([f0f8d59](https://github.com/n8n-io/n8n/commit/f0f8d59fee223c6bc9f8459890ed4a31ef8cb0af))
* **core:** Do not explicitly bypass auth on urls containing `.svg` ([#5525](https://github.com/n8n-io/n8n/issues/5525)) ([f58573d](https://github.com/n8n-io/n8n/commit/f58573dba30eba8fe3d844d1b7b2dbbb8d51b8b5))
* **core:** Do not remove empty output connections arrays in PurgeInvalidWorkflowConnections migration ([#5546](https://github.com/n8n-io/n8n/issues/5546)) ([0fbb3f0](https://github.com/n8n-io/n8n/commit/0fbb3f0f026432f1aea87b106a0c1f732f93c792))
* **core:** Fix execution status filters ([#5533](https://github.com/n8n-io/n8n/issues/5533)) ([17eff4d](https://github.com/n8n-io/n8n/commit/17eff4d7d6692bfdc251bfa16ce7334858642ce5))
* **core:** User update endpoint should only allow updating email, firstName, and lastName ([#5526](https://github.com/n8n-io/n8n/issues/5526)) ([510855d](https://github.com/n8n-io/n8n/commit/510855d9581f07e5081a7bc11377cd6216ba7edf))
* **Discord Node:** Fix wrong error message being displayed ([#5547](https://github.com/n8n-io/n8n/issues/5547)) ([e251439](https://github.com/n8n-io/n8n/commit/e2514393335e555af47c9aca4f81b31608df9cb5))
* **Discourse Node:** Fix issue with credential test not working ([#5520](https://github.com/n8n-io/n8n/issues/5520)) ([b3e1793](https://github.com/n8n-io/n8n/commit/b3e1793ac0f304ea72d565097b6766bc278e1238))
* **editor:** Apply correct IRunExecutionData to finished workflow ([#5552](https://github.com/n8n-io/n8n/issues/5552)) ([e2d7c18](https://github.com/n8n-io/n8n/commit/e2d7c1804f2d5da15d96edeefd50c5b8e2753fd1))
* **editor:** Fix an issue with zoom and canvas nodes connections ([#5548](https://github.com/n8n-io/n8n/issues/5548)) ([4998ab2](https://github.com/n8n-io/n8n/commit/4998ab23508adf9a244885509b2d5c7c9c9c48f0))
* **editor:** Fix unexpected date rendering on front-end ([#5528](https://github.com/n8n-io/n8n/issues/5528)) ([684d717](https://github.com/n8n-io/n8n/commit/684d71752064e25143e09666e539b91b3dcd5f71))
* **editor:** Remove 'crashed' status from filter ([#5524](https://github.com/n8n-io/n8n/issues/5524)) ([7c517cb](https://github.com/n8n-io/n8n/commit/7c517cb5300481908dd653426089a6a9291e79ca))
* fix typo in error messages when a property does not exist ([#4310](https://github.com/n8n-io/n8n/issues/4310)) ([3af3db1](https://github.com/n8n-io/n8n/commit/3af3db160b5798fe948159b6f3dd48ec743512e7))
* Fixes an issue when saving an active workflow without triggers would cause n8n to be stuck ([#5513](https://github.com/n8n-io/n8n/issues/5513)) ([75a094a](https://github.com/n8n-io/n8n/commit/75a094a8c03afc40b7872cd2115d82e69455286e))
* **Google Calendar Node:** Fix incorrect labels for start and end times when getting all events ([#5529](https://github.com/n8n-io/n8n/issues/5529)) ([f965469](https://github.com/n8n-io/n8n/commit/f965469e13a45d3a7b796dfd6be44573bf8b13d0))
* **Postgres Node:** Fix for tables containing field named json ([5d74a2f](https://github.com/n8n-io/n8n/commit/5d74a2f89a31ee1a386a52d0d71858f73d734e31))
* **S3 Node:** Fix issue with get many buckets not outputting data ([#5514](https://github.com/n8n-io/n8n/issues/5514)) ([1c47677](https://github.com/n8n-io/n8n/commit/1c476770a778b7d034924db847a8757c383bd281))
### Features
* Add new event hooks ([#5530](https://github.com/n8n-io/n8n/issues/5530)) ([d47d008](https://github.com/n8n-io/n8n/commit/d47d0086cc2b0af5338598de1fc496b9d825f9a4))
* Add Required path name mapping to multiple nodes ([#5369](https://github.com/n8n-io/n8n/issues/5369)) ([f1589d4](https://github.com/n8n-io/n8n/commit/f1589d4f0f9f7cc7beec12d9f6598f8286484989))
* **core:** Add configurable execution history limit ([#5505](https://github.com/n8n-io/n8n/issues/5505)) ([db70293](https://github.com/n8n-io/n8n/commit/db702932f3f2b14a097e7f4364c06cbb4f001ebc))
* **core:** Add execution runData recovery and status field ([#5112](https://github.com/n8n-io/n8n/issues/5112)) ([d143f3f](https://github.com/n8n-io/n8n/commit/d143f3f2ec9ce42cfa4db2b41dab019b7b42f379))
* **core:** Add saml feature flag ([#5494](https://github.com/n8n-io/n8n/issues/5494)) ([3a9c257](https://github.com/n8n-io/n8n/commit/3a9c257f55a87890c7456601de13f182cec89fde))
* Deprecate Read Binary File node ([#5490](https://github.com/n8n-io/n8n/issues/5490)) ([11b4671](https://github.com/n8n-io/n8n/commit/11b467137e7652c03c0578654b19dbc157b23220))
* **editor:** Unify regular and trigger node creator panels ([#5315](https://github.com/n8n-io/n8n/issues/5315)) ([9a1e7b5](https://github.com/n8n-io/n8n/commit/9a1e7b52f7ce698f1492af15d0139fb015ba5d30))
* Hide sensitive value in Auth Header Credentials and Auth Query Credentials ([#5534](https://github.com/n8n-io/n8n/issues/5534)) ([4a209e1](https://github.com/n8n-io/n8n/commit/4a209e1dd98ea4b43d0a4d9cd688615cd6d4d5dd))
* Support feature flag evaluation server side ([#5511](https://github.com/n8n-io/n8n/issues/5511)) ([26a20ed](https://github.com/n8n-io/n8n/commit/26a20ed47e8f580504b80150d7550ecb9a49be0d))
## [0.216.2](https://github.com/n8n-io/n8n/compare/n8n@0.216.1...n8n@0.216.2) (2023-02-23)
### Bug Fixes
* **core:** Do not remove empty output connections arrays in PurgeInvalidWorkflowConnections migration ([#5546](https://github.com/n8n-io/n8n/issues/5546)) ([ac86abe](https://github.com/n8n-io/n8n/commit/ac86abe2457d9f54fcd23ac0c8d5f39d565bdcdf))
## [0.215.3](https://github.com/n8n-io/n8n/compare/n8n@0.215.2...n8n@0.215.3) (2023-02-23)
### Bug Fixes
* **core:** Do not allow arbitrary path traversal in BinaryDataManager ([#5523](https://github.com/n8n-io/n8n/issues/5523)) ([f7079da](https://github.com/n8n-io/n8n/commit/f7079daecd210a3a2a94e07c4782d15ee2a995e0))
* **core:** Do not allow arbitrary path traversal in the credential-translation endpoint ([#5522](https://github.com/n8n-io/n8n/issues/5522)) ([14d2a88](https://github.com/n8n-io/n8n/commit/14d2a88120c966a6493c3a64a7a2925af0731b8f))
* **core:** Do not explicitly bypass auth on urls containing `.svg` ([#5525](https://github.com/n8n-io/n8n/issues/5525)) ([0b568ee](https://github.com/n8n-io/n8n/commit/0b568ee5c3d3259aaa43f757ded5583bf9d1e221))
* **core:** Do not remove empty output connections arrays in PurgeInvalidWorkflowConnections migration ([#5546](https://github.com/n8n-io/n8n/issues/5546)) ([a31cb05](https://github.com/n8n-io/n8n/commit/a31cb05fecb3c7fcb8f3def33206bb7676358561))
* **core:** User update endpoint should only allow updating email, firstName, and lastName ([#5526](https://github.com/n8n-io/n8n/issues/5526)) ([d530e20](https://github.com/n8n-io/n8n/commit/d530e20669e90e12a2d2895ae31d0018a53b817a))
## [0.214.4](https://github.com/n8n-io/n8n/compare/n8n@0.214.3...n8n@0.214.4) (2023-02-23)
### Bug Fixes
* **core:** Do not allow arbitrary path traversal in BinaryDataManager ([#5523](https://github.com/n8n-io/n8n/issues/5523)) ([df3f23e](https://github.com/n8n-io/n8n/commit/df3f23e2b8103a15632521e4ba6cf332693acf81))
* **core:** Do not allow arbitrary path traversal in the credential-translation endpoint ([#5522](https://github.com/n8n-io/n8n/issues/5522)) ([397e42d](https://github.com/n8n-io/n8n/commit/397e42d63e80577a0b897873a1d2f19533e27da7))
* **core:** Do not explicitly bypass auth on urls containing `.svg` ([#5525](https://github.com/n8n-io/n8n/issues/5525)) ([a8ca2b1](https://github.com/n8n-io/n8n/commit/a8ca2b1aea687256c7d7d3525a2c50659935d7b8))
* **core:** Do not remove empty output connections arrays in PurgeInvalidWorkflowConnections migration ([#5546](https://github.com/n8n-io/n8n/issues/5546)) ([e6a554f](https://github.com/n8n-io/n8n/commit/e6a554f884d0d8d1e5c3890745986ecc179846d5))
* **core:** User update endpoint should only allow updating email, firstName, and lastName ([#5526](https://github.com/n8n-io/n8n/issues/5526)) ([d622827](https://github.com/n8n-io/n8n/commit/d6228276a26d9dc1bf2b2c5452bc0644b6df0c63))
## [0.216.1](https://github.com/n8n-io/n8n/compare/n8n@0.216.0...n8n@0.216.1) (2023-02-21)

View file

@ -10,9 +10,12 @@ const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Undo/Redo', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
WorkflowPage.actions.visit();
cy.waitForLoad();
});
@ -38,7 +41,11 @@ describe('Undo/Redo', () => {
it('should undo/redo adding node in the middle', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_NAME, SET_NODE_NAME)
WorkflowPage.actions.addNodeBetweenNodes(
SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_NAME,
SET_NODE_NAME,
);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);

View file

@ -21,9 +21,12 @@ const ZOOM_OUT_X2_FACTOR = 0.64;
const RENAME_NODE_NAME = 'Something else';
describe('Canvas Actions', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
WorkflowPage.actions.visit();
cy.waitForLoad();
});
@ -46,14 +49,16 @@ describe('Canvas Actions', () => {
// Change connection from Set to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('input', SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`)
)
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`),
);
WorkflowPage.getters.canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`).should('have.class', 'jtk-endpoint-connected');
WorkflowPage.getters
.canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`)
.should('have.class', 'jtk-endpoint-connected');
cy.get('.jtk-connector').should('have.length', 1);
// Disconnect Set1
cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100])
cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100]);
cy.get('.jtk-connector').should('have.length', 0);
});
@ -67,7 +72,10 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true);
cy.drag(WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME), [100, 100])
cy.drag(
WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME),
[100, 100],
);
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
@ -79,18 +87,20 @@ describe('Canvas Actions', () => {
// Switch has 4 output endpoints
for (let i = 0; i < 4; i++) {
WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true })
WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true });
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, false);
WorkflowPage.actions.zoomToFit();
}
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload()
cy.reload();
cy.waitForLoad();
// Make sure all connections are there after reload
for (let i = 0; i < 4; i++) {
const setName = `${SET_NODE_NAME}${i > 0 ? i : ''}`;
WorkflowPage.getters.canvasNodeInputEndpointByName(setName).should('have.class', 'jtk-endpoint-connected');
WorkflowPage.getters
.canvasNodeInputEndpointByName(setName)
.should('have.class', 'jtk-endpoint-connected');
}
});
@ -109,26 +119,26 @@ describe('Canvas Actions', () => {
// Connect manual to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', MANUAL_TRIGGER_NODE_DISPLAY_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`)
)
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 2);
// Connect Set1 and Set2 to merge
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0)
)
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', `${SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1)
)
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload()
cy.reload();
cy.waitForLoad();
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
@ -156,7 +166,7 @@ describe('Canvas Actions', () => {
cy.get('.plus-draggable-endpoint').filter(':visible').should('not.have.class', 'ep-success');
cy.get('.jtk-connector.success').should('have.length', 3);
cy.get('.jtk-connector').should('have.length', 4);
})
});
it('should add a connected node using plus endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);

View file

@ -4,15 +4,18 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('Data pinning', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
workflowPage.actions.visit();
cy.waitForLoad();
});
it('Should be able to pin node output', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true});
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible');
@ -43,7 +46,7 @@ describe('Data pinning', () => {
});
it('Should be be able to set pinned data', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true});
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible');

View file

@ -4,15 +4,20 @@ const wf = new WorkflowPage();
const ndv = new NDV();
describe('Data transformation expressions', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
wf.actions.visit();
cy.waitForLoad();
cy.window()
// @ts-ignore
.then(win => win.onBeforeUnload && win.removeEventListener('beforeunload', win.onBeforeUnload));
.then(
(win) => win.onBeforeUnload && win.removeEventListener('beforeunload', win.onBeforeUnload),
);
});
it('$json + native string methods', () => {
@ -26,7 +31,7 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
});
@ -41,7 +46,7 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
});
@ -56,7 +61,7 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
});
@ -71,7 +76,7 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
});
@ -86,7 +91,7 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
});

View file

@ -9,15 +9,20 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('Data mapping', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
workflowPage.actions.visit();
cy.waitForLoad();
cy.window()
// @ts-ignore
.then(win => win.onBeforeUnload && win.removeEventListener('beforeunload', win.onBeforeUnload))
.then(
(win) => win.onBeforeUnload && win.removeEventListener('beforeunload', win.onBeforeUnload),
);
});
it('maps expressions from table header', () => {
@ -30,15 +35,29 @@ describe('Data mapping', () => {
ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist');
ndv.getters.nodeParameters().find('input[placeholder*="Add Value"]').click();
ndv.getters.nodeParameters().find('.el-select-dropdown__list li:nth-child(3)').should('have.text', 'String').click();
ndv.getters.parameterInput('name').should('have.length', 1).find('input').should('have.value', 'propertyName');
ndv.getters.parameterInput('value').should('have.length', 1).find('input').should('have.value', '');
ndv.getters
.nodeParameters()
.find('.el-select-dropdown__list li:nth-child(3)')
.should('have.text', 'String')
.click();
ndv.getters
.parameterInput('name')
.should('have.length', 1)
.find('input')
.should('have.value', 'propertyName');
ndv.getters
.parameterInput('value')
.should('have.length', 1)
.find('input')
.should('have.value', '');
ndv.actions.mapDataFromHeader(1, 'value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.timestamp }}');
ndv.actions.mapDataFromHeader(2, 'value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.timestamp }} {{ $json["Readable date"] }}');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', '{{ $json.timestamp }} {{ $json["Readable date"] }}');
});
it('maps expressions from table json, and resolves value based on hover', () => {
@ -50,23 +69,37 @@ describe('Data mapping', () => {
ndv.actions.switchInputMode('Table');
ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist');
ndv.getters.parameterInput('name').should('have.length', 1).find('input').should('have.value', 'other');
ndv.getters.parameterInput('value').should('have.length', 1).find('input').should('have.value', '');
ndv.getters
.parameterInput('name')
.should('have.length', 1)
.find('input')
.should('have.value', 'other');
ndv.getters
.parameterInput('value')
.should('have.length', 1)
.find('input')
.should('have.value', '');
ndv.getters.inputTbodyCell(1, 0).find('span').contains('count').trigger('mousedown', {force: true});
ndv.getters
.inputTbodyCell(1, 0)
.find('span')
.contains('count')
.trigger('mousedown', { force: true });
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0')
ndv.getters.parameterExpressionPreview('value').should('include.text', '0');
ndv.getters.inputTbodyCell(1, 0).realHover();
ndv.getters.parameterExpressionPreview('value')
ndv.getters
.parameterExpressionPreview('value')
.should('include.text', '0')
.invoke('css', 'color')
.should('equal', 'rgb(125, 125, 135)');
ndv.getters.inputTbodyCell(2, 0).realHover();
ndv.getters.parameterExpressionPreview('value')
ndv.getters
.parameterExpressionPreview('value')
.should('include.text', '1')
.invoke('css', 'color')
.should('equal', 'rgb(125, 125, 135)');
@ -74,13 +107,15 @@ describe('Data mapping', () => {
ndv.actions.execute();
ndv.getters.outputTbodyCell(1, 0).realHover();
ndv.getters.parameterExpressionPreview('value')
ndv.getters
.parameterExpressionPreview('value')
.should('include.text', '0')
.invoke('css', 'color')
.should('equal', 'rgb(125, 125, 135)'); // todo update color
ndv.getters.outputTbodyCell(2, 0).realHover();
ndv.getters.parameterExpressionPreview('value')
ndv.getters
.parameterExpressionPreview('value')
.should('include.text', '1')
.invoke('css', 'color')
.should('equal', 'rgb(125, 125, 135)');
@ -94,24 +129,34 @@ describe('Data mapping', () => {
workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('JSON');
ndv.getters.inputDataContainer().should('exist').find('.json-data')
.should('have.text', '[{"input":[{"count":0,"with space":"!!","with.dot":"!!","with"quotes":"!!"}]},{"input":[{"count":1}]}]')
.find('span').contains('"count"')
ndv.getters
.inputDataContainer()
.should('exist')
.find('.json-data')
.should(
'have.text',
'[{"input":[{"count":0,"with space":"!!","with.dot":"!!","with"quotes":"!!"}]},{"input":[{"count":1}]}]',
)
.find('span')
.contains('"count"')
.realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
ndv.getters.parameterExpressionPreview('value')
.should('include.text', '0');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0');
ndv.getters.inputDataContainer().find('.json-data')
.find('span').contains('"input"')
ndv.getters
.inputDataContainer()
.find('.json-data')
.find('span')
.contains('"input"')
.realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.getters.parameterExpressionPreview('value')
.should('include.text', '0 [object Object]');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0 [object Object]');
});
it('maps expressions from schema view', () => {
@ -123,25 +168,19 @@ describe('Data mapping', () => {
ndv.actions.clearParameterInput('value');
cy.get('body').type('{esc}');
ndv.getters.inputDataContainer()
.should('exist')
.find('span').contains('count')
.realMouseDown();
ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
ndv.getters.parameterExpressionPreview('value')
.should('include.text', '0');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0');
ndv.getters.inputDataContainer()
.find('span').contains('input')
.realMouseDown();
ndv.getters.inputDataContainer().find('span').contains('input').realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.getters.parameterExpressionPreview('value')
.should('include.text', '0 [object Object]');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0 [object Object]');
});
it('maps expressions from previous nodes', () => {
@ -150,32 +189,33 @@ describe('Data mapping', () => {
ndv.actions.selectInputNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.getters.inputDataContainer()
.find('span').contains('count')
.realMouseDown();
ndv.getters.inputDataContainer().find('span').contains('count').realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }}`);
ndv.getters.parameterExpressionPreview('value')
.should('not.exist');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }}`);
ndv.getters.parameterExpressionPreview('value').should('not.exist');
ndv.actions.switchInputMode('Table');
ndv.actions.mapDataFromHeader(1, 'value');
ndv.getters.inlineExpressionEditorInput().should('have.text', `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }} {{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input }}`);
ndv.getters.parameterExpressionPreview('value')
.should('not.exist');
ndv.getters
.inlineExpressionEditorInput()
.should(
'have.text',
`{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }} {{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input }}`,
);
ndv.getters.parameterExpressionPreview('value').should('not.exist');
ndv.actions.selectInputNode('Set');
ndv.actions.executePrevious();
ndv.getters.executingLoader().should('not.exist');
ndv.getters.inputDataContainer().should('exist');
ndv.getters.parameterExpressionPreview('value')
.should('include.text', '0 [object Object]');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0 [object Object]');
ndv.getters.inputTbodyCell(2, 0).realHover();
ndv.getters.parameterExpressionPreview('value')
.should('include.text', '1 [object Object]');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1 [object Object]');
});
it('maps keys to path', () => {
@ -186,20 +226,20 @@ describe('Data mapping', () => {
{
input: [
{
"hello.world": {
"my count": 0,
'hello.world': {
'my count': 0,
},
}
]
},
],
},
{
input: [
{
"hello.world": {
"my count": 1,
}
}
]
'hello.world': {
'my count': 1,
},
},
],
},
]);
@ -208,21 +248,18 @@ describe('Data mapping', () => {
workflowPage.actions.addNodeToCanvas('Item Lists');
workflowPage.actions.openNode('Item Lists');
ndv.getters.parameterInput('operation')
.click()
.find('li').contains('Sort')
.click();
ndv.getters.parameterInput('operation').click().find('li').contains('Sort').click();
ndv.getters.nodeParameters().find('button').contains('Add Field To Sort By').click();
ndv.getters.inputDataContainer()
.find('span').contains('my count')
.realMouseDown();
ndv.getters.inputDataContainer().find('span').contains('my count').realMouseDown();
ndv.actions.mapToParameter('fieldName');
ndv.getters.inlineExpressionEditorInput().should('have.length', 0);
ndv.getters.parameterInput('fieldName')
.find('input').should('have.value', 'input[0]["hello.world"]["my count"]');
ndv.getters
.parameterInput('fieldName')
.find('input')
.should('have.value', 'input[0]["hello.world"]["my count"]');
});
});

View file

@ -5,7 +5,7 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('Schedule Trigger node', async () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
@ -42,28 +42,38 @@ describe('Schedule Trigger node', async () => {
workflowPage.actions.activateWorkflow();
workflowPage.getters.activatorSwitch().should('have.class', 'is-checked');
cy.request("GET", '/rest/workflows').then((response) => {
cy.request('GET', '/rest/workflows')
.then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data).to.have.length(1);
const workflowId = response.body.data[0].id.toString();
expect(workflowId).to.not.be.empty;
return workflowId;
}).then((workflowId) => {
})
.then((workflowId) => {
cy.wait(1200);
cy.request("GET", '/rest/executions').then((response) => {
cy.request('GET', '/rest/executions')
.then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data.results.length).to.be.greaterThan(0);
const matchingExecutions = response.body.data.results.filter((execution: any) => execution.workflowId === workflowId);
const matchingExecutions = response.body.data.results.filter(
(execution: any) => execution.workflowId === workflowId,
);
expect(matchingExecutions).to.have.length(1);
return workflowId;
}).then((workflowId) => {
})
.then((workflowId) => {
cy.wait(1200);
cy.request("GET", '/rest/executions').then((response) => {
cy.request('GET', '/rest/executions')
.then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data.results.length).to.be.greaterThan(0);
const matchingExecutions = response.body.data.results.filter((execution: any) => execution.workflowId === workflowId);
const matchingExecutions = response.body.data.results.filter(
(execution: any) => execution.workflowId === workflowId,
);
expect(matchingExecutions).to.have.length(2);
}).then(()=>{
})
.then(() => {
workflowPage.actions.activateWorkflow();
workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked');
cy.visit(workflowsPage.url);

View file

@ -83,7 +83,7 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request(method, '/webhook-test/'+ webhookPath).then((response) => {
cy.request(method, '/webhook-test/' + webhookPath).then((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
@ -91,36 +91,41 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
};
describe('Webhook Trigger node', async () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
workflowPage.actions.visit();
cy.waitForLoad();
cy.window()
// @ts-ignore
.then(win => win.onBeforeUnload && win.removeEventListener('beforeunload', win.onBeforeUnload));
.then(
(win) => win.onBeforeUnload && win.removeEventListener('beforeunload', win.onBeforeUnload),
);
});
it('should listen for a GET request', () => {
simpleWebhookCall({method: 'GET', webhookPath: uuid(), executeNow: true});
simpleWebhookCall({ method: 'GET', webhookPath: uuid(), executeNow: true });
});
it('should listen for a POST request', () => {
simpleWebhookCall({method: 'POST', webhookPath: uuid(), executeNow: true});
simpleWebhookCall({ method: 'POST', webhookPath: uuid(), executeNow: true });
});
it('should listen for a DELETE request', () => {
simpleWebhookCall({method: 'DELETE', webhookPath: uuid(), executeNow: true});
simpleWebhookCall({ method: 'DELETE', webhookPath: uuid(), executeNow: true });
});
it('should listen for a HEAD request', () => {
simpleWebhookCall({method: 'HEAD', webhookPath: uuid(), executeNow: true});
simpleWebhookCall({ method: 'HEAD', webhookPath: uuid(), executeNow: true });
});
it('should listen for a PATCH request', () => {
simpleWebhookCall({method: 'PATCH', webhookPath: uuid(), executeNow: true});
simpleWebhookCall({ method: 'PATCH', webhookPath: uuid(), executeNow: true });
});
it('should listen for a PUT request', () => {
simpleWebhookCall({method: 'PUT', webhookPath: uuid(), executeNow: true});
simpleWebhookCall({ method: 'PUT', webhookPath: uuid(), executeNow: true });
});
it('should listen for a GET request and respond with Respond to Webhook node', () => {
@ -138,7 +143,10 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.openNode('Set');
cy.get('.add-option').click();
cy.get('.add-option').find('.el-select-dropdown__item').contains('Number').click();
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-name').clear().type('MyValue');
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-name')
.clear()
.type('MyValue');
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-value').clear().type('1234');
ndv.getters.backToCanvas().click();
@ -147,7 +155,7 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/'+ webhookPath).then((response) => {
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.eq(1234);
});
@ -165,7 +173,7 @@ describe('Webhook Trigger node', async () => {
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/'+ webhookPath).then((response) => {
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
expect(response.status).to.eq(201);
});
});
@ -184,14 +192,17 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.openNode('Set');
cy.get('.add-option').click();
cy.get('.add-option').find('.el-select-dropdown__item').contains('Number').click();
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-name').clear().type('MyValue');
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-name')
.clear()
.type('MyValue');
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-value').clear().type('1234');
ndv.getters.backToCanvas().click();
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/'+ webhookPath).then((response) => {
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.eq(1234);
});
@ -213,10 +224,14 @@ describe('Webhook Trigger node', async () => {
cy.get('.add-option').click();
cy.get('.add-option').find('.el-select-dropdown__item').contains('String').click();
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-name').clear().type('data');
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-value').clear().find('input').invoke('val', cowBase64).trigger('blur');
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-value')
.clear()
.find('input')
.invoke('val', cowBase64)
.trigger('blur');
ndv.getters.backToCanvas().click();
workflowPage.actions.addNodeToCanvas('Move Binary Data');
workflowPage.actions.zoomToFit();
@ -232,7 +247,7 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/'+ webhookPath).then((response) => {
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
expect(response.status).to.eq(200);
expect(Object.keys(response.body).includes('data')).to.be.true;
});
@ -249,7 +264,7 @@ describe('Webhook Trigger node', async () => {
});
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request('GET', '/webhook-test/'+ webhookPath).then((response) => {
cy.request('GET', '/webhook-test/' + webhookPath).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.be.undefined;
});
@ -273,22 +288,23 @@ describe('Webhook Trigger node', async () => {
cy.wait(waitForWebhook);
cy.request({
method: 'GET',
url: '/webhook-test/'+ webhookPath,
url: '/webhook-test/' + webhookPath,
auth: {
'user': 'username',
'pass': 'password',
user: 'username',
pass: 'password',
},
failOnStatusCode: false,
})
.then((response) => {
expect(response.status).to.eq(403);
}).then(() => {
})
.then(() => {
cy.request({
method: 'GET',
url: '/webhook-test/'+ webhookPath,
url: '/webhook-test/' + webhookPath,
auth: {
'user': 'test',
'pass': 'test',
user: 'test',
pass: 'test',
},
failOnStatusCode: true,
}).then((response) => {
@ -315,7 +331,7 @@ describe('Webhook Trigger node', async () => {
cy.wait(waitForWebhook);
cy.request({
method: 'GET',
url: '/webhook-test/'+ webhookPath,
url: '/webhook-test/' + webhookPath,
headers: {
test: 'wrong',
},
@ -323,10 +339,11 @@ describe('Webhook Trigger node', async () => {
})
.then((response) => {
expect(response.status).to.eq(403);
}).then(() => {
})
.then(() => {
cy.request({
method: 'GET',
url: '/webhook-test/'+ webhookPath,
url: '/webhook-test/' + webhookPath,
headers: {
test: 'test',
},

View file

@ -5,9 +5,12 @@ const wf = new WorkflowPage();
const TEST_TAGS = ['Tag 1', 'Tag 2', 'Tag 3'];
describe('Workflow tags', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
wf.actions.visit();
cy.waitForLoad();
});

View file

@ -42,8 +42,8 @@ const updatedPersonalData = {
newLastName: 'Else',
newEmail: 'something_else@acme.corp',
newPassword: 'Keybo4rd',
invalidPasswords: ['abc', 'longEnough', 'longenough123']
}
invalidPasswords: ['abc', 'longEnough', 'longenough123'],
};
const usersSettingsPage = new SettingsUsersPage();
const workflowPage = new WorkflowPage();
@ -67,7 +67,7 @@ describe('User Management', () => {
});
it('should prevent non-owners to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit(users[0].email, users[0].password, false)
usersSettingsPage.actions.loginAndVisit(users[0].email, users[0].password, false);
});
it('should allow instance owner to access UM settings', () => {
@ -79,7 +79,10 @@ describe('User Management', () => {
// All items in user list should be there
usersSettingsPage.getters.userListItems().should('have.length', 3);
// List item for current user should have the `Owner` badge
usersSettingsPage.getters.userItem(instanceOwner.email).find('.n8n-badge:contains("Owner")').should('exist');
usersSettingsPage.getters
.userItem(instanceOwner.email)
.find('.n8n-badge:contains("Owner")')
.should('exist');
// Other users list items should contain action pop-up list
usersSettingsPage.getters.userActionsToggle(users[0].email).should('exist');
usersSettingsPage.getters.userActionsToggle(users[1].email).should('exist');
@ -106,8 +109,13 @@ describe('User Management', () => {
it(`should allow user to change their personal data`, () => {
personalSettingsPage.actions.loginAndVisit(instanceOwner.email, instanceOwner.password);
personalSettingsPage.actions.updateFirstAndLastName(updatedPersonalData.newFirstName, updatedPersonalData.newLastName);
personalSettingsPage.getters.currentUserName().should('contain', `${updatedPersonalData.newFirstName} ${updatedPersonalData.newLastName}`);
personalSettingsPage.actions.updateFirstAndLastName(
updatedPersonalData.newFirstName,
updatedPersonalData.newLastName,
);
personalSettingsPage.getters
.currentUserName()
.should('contain', `${updatedPersonalData.newFirstName} ${updatedPersonalData.newLastName}`);
workflowPage.getters.successToast().should('contain', 'Personal details updated');
});
@ -121,18 +129,30 @@ describe('User Management', () => {
it(`shouldn't allow user to change password if old password is wrong`, () => {
personalSettingsPage.actions.loginAndVisit(instanceOwner.email, instanceOwner.password);
personalSettingsPage.actions.updatePassword('iCannotRemember', updatedPersonalData.newPassword);
workflowPage.getters.errorToast().closest('div').should('contain', 'Provided current password is incorrect.');
workflowPage.getters
.errorToast()
.closest('div')
.should('contain', 'Provided current password is incorrect.');
});
it(`should change current user password`, () => {
personalSettingsPage.actions.loginAndVisit(instanceOwner.email, instanceOwner.password);
personalSettingsPage.actions.updatePassword(instanceOwner.password, updatedPersonalData.newPassword);
personalSettingsPage.actions.updatePassword(
instanceOwner.password,
updatedPersonalData.newPassword,
);
workflowPage.getters.successToast().should('contain', 'Password updated');
personalSettingsPage.actions.loginWithNewData(instanceOwner.email, updatedPersonalData.newPassword);
personalSettingsPage.actions.loginWithNewData(
instanceOwner.email,
updatedPersonalData.newPassword,
);
});
it(`shouldn't allow users to set invalid email`, () => {
personalSettingsPage.actions.loginAndVisit(instanceOwner.email, updatedPersonalData.newPassword);
personalSettingsPage.actions.loginAndVisit(
instanceOwner.email,
updatedPersonalData.newPassword,
);
// try without @ part
personalSettingsPage.actions.tryToSetInvalidEmail(updatedPersonalData.newEmail.split('@')[0]);
// try without domain
@ -140,9 +160,15 @@ describe('User Management', () => {
});
it(`should change user email`, () => {
personalSettingsPage.actions.loginAndVisit(instanceOwner.email, updatedPersonalData.newPassword);
personalSettingsPage.actions.loginAndVisit(
instanceOwner.email,
updatedPersonalData.newPassword,
);
personalSettingsPage.actions.updateEmail(updatedPersonalData.newEmail);
workflowPage.getters.successToast().should('contain', 'Personal details updated');
personalSettingsPage.actions.loginWithNewData(updatedPersonalData.newEmail, updatedPersonalData.newPassword);
personalSettingsPage.actions.loginWithNewData(
updatedPersonalData.newEmail,
updatedPersonalData.newPassword,
);
});
});

View file

@ -5,10 +5,13 @@ const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Execution',() => {
beforeEach(() => {
describe('Execution', () => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
cy.visit('/');
});
@ -34,17 +37,36 @@ describe('Execution',() => {
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters.canvasNodeByName('Manual').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-sync-alt')).should('be.visible');
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
cy.wait(2000);
// Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start)
workflowPage.getters.canvasNodeByName('Manual').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check'))
.should('be.visible');
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
@ -77,20 +99,39 @@ describe('Execution',() => {
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters.canvasNodeByName('Manual').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-sync-alt')).should('be.visible');
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
cy.wait(1000);
workflowPage.getters.stopExecutionButton().click();
// Check canvas nodes after workflow stopped
workflowPage.getters.canvasNodeByName('Manual').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-sync-alt').should('not.visible'));
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
@ -140,17 +181,36 @@ describe('Execution',() => {
});
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters.canvasNodeByName('Webhook').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-sync-alt')).should('be.visible');
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
cy.wait(2000);
// Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start)
workflowPage.getters.canvasNodeByName('Webhook').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check'))
.should('be.visible');
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
@ -200,19 +260,39 @@ describe('Execution',() => {
});
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters.canvasNodeByName('Webhook').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-sync-alt')).should('be.visible');
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
cy.wait(1000);
workflowPage.getters.stopExecutionWaitingForWebhookButton().click();
// Check canvas nodes after workflow stopped
workflowPage.getters.canvasNodeByName('Webhook').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-check')).should('be.visible');
workflowPage.getters.canvasNodeByName('Wait').within(() => cy.get('.fa-sync-alt').should('not.visible'));
workflowPage.getters.canvasNodeByName('Set').within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check'))
.should('be.visible');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');

View file

@ -259,7 +259,7 @@ describe('Credentials', () => {
cy.contains('Create New Credential').click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API');
})
});
it('should render custom node with custom credential', () => {
workflowPage.actions.visit();
@ -269,5 +269,5 @@ describe('Credentials', () => {
cy.contains('Create New Credential').click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential');
})
});
});

View file

@ -1,5 +1,5 @@
import { WorkflowPage } from "../pages";
import { WorkflowExecutionsTab } from "../pages/workflow-executions-tab";
import { WorkflowPage } from '../pages';
import { WorkflowExecutionsTab } from '../pages/workflow-executions-tab';
const workflowPage = new WorkflowPage();
const executionsTab = new WorkflowExecutionsTab();
@ -9,6 +9,9 @@ describe('Current Workflow Executions', () => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
workflowPage.actions.visit();
cy.waitForLoad();
cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', `My test workflow`);
@ -20,12 +23,14 @@ describe('Current Workflow Executions', () => {
executionsTab.getters.executionListItems().should('have.length', 11);
executionsTab.getters.successfulExecutionListItems().should('have.length', 9);
executionsTab.getters.failedExecutionListItems().should('have.length', 2);
executionsTab.getters.executionListItems().first().invoke('attr','class').should('match', /_active_/);
executionsTab.getters
.executionListItems()
.first()
.invoke('attr', 'class')
.should('match', /_active_/);
});
});
const createMockExecutions = () => {
workflowPage.actions.turnOnManualExecutionSaving();
executionsTab.actions.createManualExecutions(5);
@ -37,4 +42,4 @@ const createMockExecutions = () => {
executionsTab.actions.createManualExecutions(4);
executionsTab.actions.switchToExecutionsTab();
cy.waitForLoad();
}
};

View file

@ -28,11 +28,7 @@ describe('Node Creator', () => {
it('should open node creator on trigger tab if no trigger is on canvas', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters
.nodeCreator()
.contains('Select a trigger')
.should('be.visible');
nodeCreatorFeature.getters.nodeCreator().contains('Select a trigger').should('be.visible');
});
it('should navigate subcategory', () => {
@ -86,20 +82,14 @@ describe('Node Creator', () => {
// TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('be.hidden');
nodeCreatorFeature.actions.openNodeCreator()
nodeCreatorFeature.getters
.nodeCreator()
.contains('What happens next?')
.should('be.visible');
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible');
nodeCreatorFeature.getters.getCreatorItem('Add another trigger').click();
nodeCreatorFeature.getters.nodeCreator().contains('Select a trigger').should('be.visible');
nodeCreatorFeature.getters.activeSubcategory().find('button').should('exist');
nodeCreatorFeature.getters.activeSubcategory().find('button').click();
nodeCreatorFeature.getters
.nodeCreator()
.contains('What happens next?')
.should('be.visible');
nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible');
});
it('should add node to canvas from actions panel', () => {
@ -110,7 +100,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.activeSubcategory().should('have.text', editImageNode);
nodeCreatorFeature.getters.getCreatorItem('Crop Image').click();
NDVModal.getters.parameterInput('operation').should('contain.text', 'Crop');
})
});
it('should search through actions and confirm added action', () => {
nodeCreatorFeature.actions.openNodeCreator();

View file

@ -6,16 +6,17 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('NDV', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.renameWorkflow(uuid());
workflowPage.actions.saveWorkflowOnButtonClick();
});
it('should show up when double clicked on a node and close when Back to canvas clicked', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.getters.canvasNodes().first().dblclick();

View file

@ -5,7 +5,7 @@ const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Code node', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});

View file

@ -14,9 +14,12 @@ const DUPLICATE_WORKFLOW_TAG = 'Duplicate';
const WorkflowPage = new WorkflowPageClass();
describe('Workflow Actions', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
WorkflowPage.actions.visit();
cy.waitForLoad();
});

View file

@ -5,7 +5,7 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('HTTP Request node', () => {
beforeEach(() => {
before(() => {
cy.resetAll();
cy.skipSetup();
});

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.216.1",
"version": "0.217.1",
"private": true,
"homepage": "https://n8n.io",
"engines": {

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.216.1",
"version": "0.217.1",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -76,8 +76,8 @@
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^9.0.1",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.6",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.get": "^4.4.6",
"@types/lodash.intersection": "^4.4.7",
"@types/lodash.iteratee": "^4.7.7",
"@types/lodash.merge": "^4.6.6",
@ -191,6 +191,7 @@
"psl": "^1.8.0",
"reflect-metadata": "^0.1.13",
"replacestream": "^4.0.3",
"samlify": "^2.8.9",
"semver": "^7.3.8",
"shelljs": "^0.8.5",
"source-map-support": "^0.5.21",

View file

@ -190,7 +190,7 @@ export function send<T, R extends Request, S extends Response>(
try {
const data = await processFunction(req, res);
sendSuccessResponse(res, data, raw);
if (!res.headersSent) sendSuccessResponse(res, data, raw);
} catch (error) {
if (error instanceof Error) {
if (!(error instanceof ResponseError) || error.httpStatusCode > 404) {

View file

@ -1,10 +0,0 @@
import { getLicense } from '../License';
import { isUserManagementEnabled } from '../UserManagement/UserManagementHelper';
/**
* Check whether the SAML feature is licensed and enabled in the instance
*/
export function isSamlEnabled(): boolean {
const license = getLicense();
return isUserManagementEnabled() && license.isSamlEnabled();
}

View file

@ -142,10 +142,13 @@ import { setupBasicAuth } from './middlewares/basicAuth';
import { setupExternalJWTAuth } from './middlewares/externalJWTAuth';
import { PostHogClient } from './posthog';
import { eventBus } from './eventbus';
import { isSamlEnabled } from './Saml/helpers';
import { Container } from 'typedi';
import { InternalHooks } from './InternalHooks';
import { getStatusUsingPreviousExecutionStatusMethod } from './executions/executionHelpers';
import { isSamlLicensed } from './sso/saml/samlHelpers';
import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee';
import { SamlService } from './sso/saml/saml.service.ee';
import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee';
const exec = promisify(callbackExec);
@ -318,7 +321,7 @@ class Server extends AbstractServer {
sharing: isSharingEnabled(),
logStreaming: isLogStreamingEnabled(),
ldap: isLdapEnabled(),
saml: isSamlEnabled(),
saml: isSamlLicensed(),
});
if (isLdapEnabled()) {
@ -495,6 +498,19 @@ class Server extends AbstractServer {
this.app.use(`/${this.restEndpoint}/ldap`, ldapController);
}
// ----------------------------------------
// SAML
// ----------------------------------------
// initialize SamlService
await SamlService.getInstance().init();
// public SAML endpoints
this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerPublic);
this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerProtected);
// ----------------------------------------
// Returns parameter values which normally get loaded from an external API or
// get generated dynamically
this.app.get(

View file

@ -44,7 +44,7 @@ import {
import pick from 'lodash.pick';
import type { FindOptionsWhere } from 'typeorm';
import { LessThanOrEqual } from 'typeorm';
import { LessThanOrEqual, In } from 'typeorm';
import { DateUtils } from 'typeorm/util/DateUtils';
import config from '@/config';
import * as Db from '@/Db';
@ -212,7 +212,9 @@ async function pruneExecutionData(this: WorkflowHooks): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const utcDate = DateUtils.mixedDateToUtcDatetimeString(date);
const toPrune: FindOptionsWhere<IExecutionFlattedDb> = { stoppedAt: LessThanOrEqual(utcDate) };
const toPrune: Array<FindOptionsWhere<IExecutionFlattedDb>> = [
{ stoppedAt: LessThanOrEqual(utcDate) },
];
if (maxCount > 0) {
const executions = await Db.collections.Execution.find({
@ -223,27 +225,29 @@ async function pruneExecutionData(this: WorkflowHooks): Promise<void> {
});
if (executions[0]) {
toPrune.id = LessThanOrEqual(executions[0].id);
toPrune.push({ id: LessThanOrEqual(executions[0].id) });
}
}
const isBinaryModeDefaultMode = config.getEnv('binaryDataManager.mode') === 'default';
try {
const executions = isBinaryModeDefaultMode
? []
: await Db.collections.Execution.find({
select: ['id'],
where: toPrune,
});
await Db.collections.Execution.delete(toPrune);
setTimeout(() => {
throttling = false;
}, timeout * 1000);
let executionIds: Array<IExecutionFlattedDb['id']>;
do {
executionIds = (
await Db.collections.Execution.find({
select: ['id'],
where: toPrune,
take: 100,
})
).map(({ id }) => id);
await Db.collections.Execution.delete({ id: In(executionIds) });
// Mark binary data for deletion for all executions
if (!isBinaryModeDefaultMode)
await BinaryDataManager.getInstance().markDataForDeletionByExecutionIds(
executions.map(({ id }) => id),
);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionIds(executionIds);
} while (executionIds.length > 0);
} catch (error) {
ErrorReporter.error(error);
throttling = false;
@ -472,7 +476,6 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx
fullExecutionData.status = 'running';
const flattenedExecutionData = ResponseHelper.flattenExecutionData(fullExecutionData);
await Db.collections.Execution.update(
this.executionId,
flattenedExecutionData as IExecutionFlattedDb,
@ -578,7 +581,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
saveDataSuccessExecution;
}
const workflowDidSucceed = !fullRunData.data.resultData.error;
const workflowHasCrashed = fullRunData.status === 'crashed';
const workflowDidSucceed = !fullRunData.data.resultData.error && !workflowHasCrashed;
let workflowStatusFinal: ExecutionStatus = workflowDidSucceed ? 'success' : 'failed';
if (workflowHasCrashed) workflowStatusFinal = 'crashed';
if (
(workflowDidSucceed && saveDataSuccessExecution === 'none') ||
(!workflowDidSucceed && saveDataErrorExecution === 'none')
@ -626,7 +633,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
stoppedAt: fullRunData.stoppedAt,
workflowData: pristineWorkflowData,
waitTill: fullRunData.waitTill,
status: fullRunData.status,
status: workflowStatusFinal,
};
if (this.retryOf !== undefined) {

View file

@ -237,7 +237,7 @@ export class Start extends BaseCommand {
// Load settings from database and set them to config.
const databaseSettings = await Db.collections.Settings.findBy({ loadOnStartup: true });
databaseSettings.forEach((setting) => {
config.set(setting.key, jsonParse(setting.value));
config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value }));
});
config.set('nodes.packagesMissing', '');

View file

@ -813,6 +813,11 @@ export const schema = {
},
},
},
authenticationMethod: {
doc: 'How to authenticate users (e.g. "email", "ldap", "saml")',
format: ['email', 'ldap', 'saml'] as const,
default: 'email',
},
},
externalFrontendHooksUrls: {
@ -1006,6 +1011,27 @@ export const schema = {
},
},
sso: {
justInTimeProvisioning: {
format: Boolean,
default: true,
doc: 'Whether to automatically create users when they login via SSO.',
},
redirectLoginToSso: {
format: Boolean,
default: true,
doc: 'Whether to automatically redirect users from login dialog to initialize SSO flow.',
},
saml: {
enabled: {
format: Boolean,
default: false,
doc: 'Whether to enable SAML SSO.',
},
},
},
// TODO: move into sso settings
ldap: {
loginEnabled: {
format: Boolean,

View file

@ -19,6 +19,8 @@ import type {
} from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import type { PostHogClient } from '@/posthog';
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers';
import { SamlUrls } from '../sso/saml/constants';
@RestController()
export class AuthController {
@ -57,14 +59,34 @@ export class AuthController {
* Authless endpoint.
*/
@Post('/login')
async login(req: LoginRequest, res: Response): Promise<PublicUser> {
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
const { email, password } = req.body;
if (!email) throw new Error('Email is required to log in');
if (!password) throw new Error('Password is required to log in');
const user =
(await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password));
let user: User | undefined;
if (isSamlCurrentAuthenticationMethod()) {
// attempt to fetch user data with the credentials, but don't log in yet
const preliminaryUser = await handleEmailLogin(email, password);
// if the user is an owner, continue with the login
if (preliminaryUser?.globalRole?.name === 'owner') {
user = preliminaryUser;
} else {
// TODO:SAML - uncomment this block when we have a way to redirect users to the SSO flow
// if (doRedirectUsersFromLoginToSsoFlow()) {
res.redirect(SamlUrls.restInitSSO);
return;
// return withFeatureFlags(this.postHog, sanitizeUser(preliminaryUser));
// } else {
// throw new AuthError(
// 'Login with username and password is disabled due to SAML being the default authentication method. Please use SAML to log in.',
// );
// }
}
} else {
user = (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password));
}
if (user) {
await issueCookie(res, user);
return withFeatureFlags(this.postHog, sanitizeUser(user));

View file

@ -2,7 +2,7 @@ import { Column, Entity, ManyToOne, PrimaryColumn, Unique } from 'typeorm';
import { AbstractEntity } from './AbstractEntity';
import { User } from './User';
export type AuthProviderType = 'ldap' | 'email'; //| 'saml' | 'google';
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
@Entity()
@Unique(['providerId', 'providerType'])

View file

@ -111,9 +111,6 @@ export class User extends AbstractEntity implements IUser {
@AfterLoad()
@AfterUpdate()
computeIsPending(): void {
this.isPending =
this.globalRole?.name === 'owner' && this.globalRole.scope === 'global'
? false
: this.password === null;
this.isPending = this.password === null;
}
}

View file

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config';
export class UpdateRunningExecutionStatus1677236788851 implements MigrationInterface {
name = 'UpdateRunningExecutionStatus1677236788851';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`UPDATE \`${tablePrefix}execution_entity\` SET status='failed' WHERE status = 'running' AND finished=0 AND \`stoppedAt\` IS NOT NULL;`,
);
await queryRunner.query(
`UPDATE \`${tablePrefix}execution_entity\` SET status='success' WHERE status = 'running' AND finished=1 AND \`stoppedAt\` IS NOT NULL;`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -33,6 +33,7 @@ import { CreateLdapEntities1674509946020 } from './1674509946020-CreateLdapEntit
import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-PurgeInvalidWorkflowConnections';
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1674133106779 } from './1674133106779-CreateExecutionMetadataTable';
export const mysqlMigrations = [
@ -71,6 +72,7 @@ export const mysqlMigrations = [
PurgeInvalidWorkflowConnections1675940580449,
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236788851,
PurgeInvalidWorkflowConnections1675940580449,
CreateExecutionMetadataTable1674133106779,
];

View file

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config';
export class UpdateRunningExecutionStatus1677236854063 implements MigrationInterface {
name = 'UpdateRunningExecutionStatus1677236854063';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`UPDATE "${tablePrefix}execution_entity" SET "status" = 'failed' WHERE "status" = 'running' AND "finished"=false AND "stoppedAt" IS NOT NULL;`,
);
await queryRunner.query(
`UPDATE "${tablePrefix}execution_entity" SET "status" = 'success' WHERE "status" = 'running' AND "finished"=true AND "stoppedAt" IS NOT NULL;`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -31,6 +31,7 @@ import { CreateLdapEntities1674509946020 } from './1674509946020-CreateLdapEntit
import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-PurgeInvalidWorkflowConnections';
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1674133106778 } from './1674133106778-CreateExecutionMetadataTable';
export const postgresMigrations = [
@ -67,6 +68,7 @@ export const postgresMigrations = [
PurgeInvalidWorkflowConnections1675940580449,
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236854063,
PurgeInvalidWorkflowConnections1675940580449,
CreateExecutionMetadataTable1674133106778,
];

View file

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config';
export class UpdateRunningExecutionStatus1677237073720 implements MigrationInterface {
name = 'UpdateRunningExecutionStatus1677237073720';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`UPDATE "${tablePrefix}execution_entity" SET "status" = 'failed' WHERE "status" = 'running' AND "finished"=0 AND "stoppedAt" IS NOT NULL;`,
);
await queryRunner.query(
`UPDATE "${tablePrefix}execution_entity" SET "status" = 'success' WHERE "status" = 'running' AND "finished"=1 AND "stoppedAt" IS NOT NULL;`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -30,6 +30,7 @@ import { CreateLdapEntities1674509946020 } from './1674509946020-CreateLdapEntit
import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-PurgeInvalidWorkflowConnections';
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1674133106777 } from './1674133106777-CreateExecutionMetadataTable';
const sqliteMigrations = [
@ -65,6 +66,7 @@ const sqliteMigrations = [
PurgeInvalidWorkflowConnections1675940580449,
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677237073720,
CreateExecutionMetadataTable1674133106777,
];

View file

@ -18,6 +18,7 @@ import {
} from '@/UserManagement/UserManagementHelper';
import type { Repository } from 'typeorm';
import type { User } from '@db/entities/User';
import { SamlUrls } from '../sso/saml/constants';
const jwtFromRequest = (req: Request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@ -95,6 +96,9 @@ export const setupAuthMiddlewares = (
req.url.startsWith(`/${restEndpoint}/change-password`) ||
req.url.startsWith(`/${restEndpoint}/oauth2-credential/callback`) ||
req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`) ||
req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.metadata}`) ||
req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.initSSO}`) ||
req.url.startsWith(`/${restEndpoint}/sso/saml${SamlUrls.acs}`) ||
isAuthExcluded(req.url, ignoredEndpoints)
) {
return next();

View file

@ -0,0 +1,25 @@
export class SamlUrls {
static readonly samlRESTRoot = '/rest/sso/saml';
static readonly initSSO = '/initsso';
static readonly restInitSSO = this.samlRESTRoot + this.initSSO;
static readonly acs = '/acs';
static readonly restAcs = this.samlRESTRoot + this.acs;
static readonly metadata = '/metadata';
static readonly restMetadata = this.samlRESTRoot + this.metadata;
static readonly config = '/config';
static readonly restConfig = this.samlRESTRoot + this.config;
static readonly defaultRedirect = '/';
static readonly samlOnboarding = '/settings/personal'; // TODO:SAML: implement signup page
}
export const SAML_PREFERENCES_DB_KEY = 'features.saml';

View file

@ -0,0 +1,24 @@
import type { RequestHandler } from 'express';
import type { AuthenticatedRequest } from '../../../requests';
import { isSamlCurrentAuthenticationMethod } from '../../ssoHelpers';
import { isSamlEnabled, isSamlLicensed } from '../samlHelpers';
export const samlLicensedOwnerMiddleware: RequestHandler = (
req: AuthenticatedRequest,
res,
next,
) => {
if (isSamlLicensed() && req.user?.globalRole.name === 'owner') {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};
export const samlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => {
if (isSamlEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod()) {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};

View file

@ -0,0 +1,105 @@
import express from 'express';
import {
samlLicensedAndEnabledMiddleware,
samlLicensedOwnerMiddleware,
} from '../middleware/samlEnabledMiddleware';
import { SamlService } from '../saml.service.ee';
import { SamlUrls } from '../constants';
import type { SamlConfiguration } from '../types/requests';
import { AuthError } from '../../../ResponseHelper';
import { issueCookie } from '../../../auth/jwt';
export const samlControllerProtected = express.Router();
/**
* GET /sso/saml/config
* Return SAML config
*/
samlControllerProtected.get(
SamlUrls.config,
samlLicensedOwnerMiddleware,
async (req: SamlConfiguration.Read, res: express.Response) => {
const prefs = await SamlService.getInstance().getSamlPreferences();
return res.send(prefs);
},
);
/**
* POST /sso/saml/config
* Return SAML config
*/
samlControllerProtected.post(
SamlUrls.config,
samlLicensedOwnerMiddleware,
async (req: SamlConfiguration.Update, res: express.Response) => {
const result = await SamlService.getInstance().setSamlPreferences({
metadata: req.body.metadata,
mapping: req.body.mapping,
});
return res.send(result);
},
);
/**
* GET /sso/saml/acs
* Assertion Consumer Service endpoint
*/
samlControllerProtected.get(
SamlUrls.acs,
samlLicensedAndEnabledMiddleware,
async (req: express.Request, res: express.Response) => {
const loginResult = await SamlService.getInstance().handleSamlLogin(req, 'redirect');
if (loginResult) {
if (loginResult.authenticatedUser) {
await issueCookie(res, loginResult.authenticatedUser);
if (loginResult.onboardingRequired) {
return res.redirect(SamlUrls.samlOnboarding);
} else {
return res.redirect(SamlUrls.defaultRedirect);
}
}
}
throw new AuthError('SAML Authentication failed');
},
);
/**
* POST /sso/saml/acs
* Assertion Consumer Service endpoint
*/
samlControllerProtected.post(
SamlUrls.acs,
samlLicensedAndEnabledMiddleware,
async (req: express.Request, res: express.Response) => {
const loginResult = await SamlService.getInstance().handleSamlLogin(req, 'post');
if (loginResult) {
if (loginResult.authenticatedUser) {
await issueCookie(res, loginResult.authenticatedUser);
if (loginResult.onboardingRequired) {
return res.redirect(SamlUrls.samlOnboarding);
} else {
return res.redirect(SamlUrls.defaultRedirect);
}
}
}
throw new AuthError('SAML Authentication failed');
},
);
/**
* GET /sso/saml/initsso
* Access URL for implementing SP-init SSO
*/
samlControllerProtected.get(
SamlUrls.initSSO,
samlLicensedAndEnabledMiddleware,
async (req: express.Request, res: express.Response) => {
const url = SamlService.getInstance().getRedirectLoginRequestUrl();
if (url) {
// TODO:SAML: redirect to the URL on the client side
return res.status(301).send(url);
} else {
throw new AuthError('SAML redirect failed, please check your SAML configuration.');
}
},
);

View file

@ -0,0 +1,17 @@
import express from 'express';
import { SamlUrls } from '../constants';
import { getServiceProviderInstance } from '../serviceProvider.ee';
/**
* SSO Endpoints that are public
*/
export const samlControllerPublic = express.Router();
/**
* GET /sso/saml/metadata
* Return Service Provider metadata
*/
samlControllerPublic.get(SamlUrls.metadata, async (req: express.Request, res: express.Response) => {
return res.header('Content-Type', 'text/xml').send(getServiceProviderInstance().getMetadata());
});

View file

@ -0,0 +1,228 @@
import type express from 'express';
import * as Db from '@/Db';
import type { User } from '@/databases/entities/User';
import { jsonParse, LoggerProxy } from 'n8n-workflow';
import { AuthError } from '@/ResponseHelper';
import { getServiceProviderInstance } from './serviceProvider.ee';
import type { SamlUserAttributes } from './types/samlUserAttributes';
import type { SamlAttributeMapping } from './types/samlAttributeMapping';
import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers';
import type { SamlPreferences } from './types/samlPreferences';
import { SAML_PREFERENCES_DB_KEY } from './constants';
import type { IdentityProviderInstance } from 'samlify';
import { IdentityProvider } from 'samlify';
import {
createUserFromSamlAttributes,
getMappedSamlAttributesFromFlowResult,
updateUserFromSamlAttributes,
} from './samlHelpers';
export class SamlService {
private static instance: SamlService;
private identityProviderInstance: IdentityProviderInstance | undefined;
private _attributeMapping: SamlAttributeMapping = {
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname',
lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname',
userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn',
};
public get attributeMapping(): SamlAttributeMapping {
return this._attributeMapping;
}
public set attributeMapping(mapping: SamlAttributeMapping) {
// TODO:SAML: add validation
this._attributeMapping = mapping;
}
private _metadata = '';
public get metadata(): string {
return this._metadata;
}
public set metadata(metadata: string) {
this._metadata = metadata;
}
constructor() {
this.loadSamlPreferences()
.then(() => {
LoggerProxy.debug('Initializing SAML service');
})
.catch(() => {
LoggerProxy.error('Error initializing SAML service');
});
}
static getInstance(): SamlService {
if (!SamlService.instance) {
SamlService.instance = new SamlService();
}
return SamlService.instance;
}
async init(): Promise<void> {
await this.loadSamlPreferences();
}
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
if (this.identityProviderInstance === undefined || forceRecreate) {
this.identityProviderInstance = IdentityProvider({
metadata: this.metadata,
});
}
return this.identityProviderInstance;
}
getRedirectLoginRequestUrl(): string {
const loginRequest = getServiceProviderInstance().createLoginRequest(
this.getIdentityProviderInstance(),
'redirect',
);
//TODO:SAML: debug logging
LoggerProxy.debug(loginRequest.context);
return loginRequest.context;
}
async handleSamlLogin(
req: express.Request,
binding: 'post' | 'redirect',
): Promise<
| {
authenticatedUser: User | undefined;
attributes: SamlUserAttributes;
onboardingRequired: boolean;
}
| undefined
> {
const attributes = await this.getAttributesFromLoginResponse(req, binding);
if (attributes.email) {
const user = await Db.collections.User.findOne({
where: { email: attributes.email },
relations: ['globalRole', 'authIdentities'],
});
if (user) {
// Login path for existing users that are fully set up
if (
user.authIdentities.find(
(e) => e.providerType === 'saml' && e.providerId === attributes.userPrincipalName,
)
) {
return {
authenticatedUser: user,
attributes,
onboardingRequired: false,
};
} else {
// Login path for existing users that are NOT fully set up for SAML
const updatedUser = await updateUserFromSamlAttributes(user, attributes);
return {
authenticatedUser: updatedUser,
attributes,
onboardingRequired: true,
};
}
} else {
// New users to be created JIT based on SAML attributes
if (isSsoJustInTimeProvisioningEnabled()) {
const newUser = await createUserFromSamlAttributes(attributes);
return {
authenticatedUser: newUser,
attributes,
onboardingRequired: true,
};
}
}
}
return undefined;
}
async getSamlPreferences(): Promise<SamlPreferences> {
return {
mapping: this.attributeMapping,
metadata: this.metadata,
};
}
async setSamlPreferences(prefs: SamlPreferences): Promise<void> {
this.attributeMapping = prefs.mapping;
this.metadata = prefs.metadata;
this.getIdentityProviderInstance(true);
await this.saveSamlPreferences();
}
async loadSamlPreferences(): Promise<SamlPreferences | undefined> {
const samlPreferences = await Db.collections.Settings.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
if (samlPreferences) {
const prefs = jsonParse<SamlPreferences>(samlPreferences.value);
if (prefs) {
this.attributeMapping = prefs.mapping;
this.metadata = prefs.metadata;
}
return prefs;
}
return;
}
async saveSamlPreferences(): Promise<void> {
const samlPreferences = await Db.collections.Settings.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
if (samlPreferences) {
samlPreferences.value = JSON.stringify({
mapping: this.attributeMapping,
metadata: this.metadata,
});
samlPreferences.loadOnStartup = true;
await Db.collections.Settings.save(samlPreferences);
} else {
await Db.collections.Settings.save({
key: SAML_PREFERENCES_DB_KEY,
value: JSON.stringify({
mapping: this.attributeMapping,
metadata: this.metadata,
}),
loadOnStartup: true,
});
}
}
async getAttributesFromLoginResponse(
req: express.Request,
binding: 'post' | 'redirect',
): Promise<SamlUserAttributes> {
let parsedSamlResponse;
try {
parsedSamlResponse = await getServiceProviderInstance().parseLoginResponse(
this.getIdentityProviderInstance(),
binding,
req,
);
} catch (error) {
throw error;
// throw new AuthError('SAML Authentication failed. Could not parse SAML response.');
}
const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult(
parsedSamlResponse,
this.attributeMapping,
);
if (!attributes) {
throw new AuthError('SAML Authentication failed. Invalid SAML response.');
}
if (!attributes.email && missingAttributes.length > 0) {
throw new AuthError(
`SAML Authentication failed. Invalid SAML response (missing attributes: ${missingAttributes.join(
', ',
)}).`,
);
}
return attributes;
}
}

View file

@ -0,0 +1,136 @@
import config from '@/config';
import * as Db from '@/Db';
import { AuthIdentity } from '../../databases/entities/AuthIdentity';
import { User } from '../../databases/entities/User';
import { getLicense } from '../../License';
import { AuthError } from '../../ResponseHelper';
import { hashPassword, isUserManagementEnabled } from '../../UserManagement/UserManagementHelper';
import type { SamlPreferences } from './types/samlPreferences';
import type { SamlUserAttributes } from './types/samlUserAttributes';
import type { FlowResult } from 'samlify/types/src/flow';
import type { SamlAttributeMapping } from './types/samlAttributeMapping';
/**
* Check whether the SAML feature is licensed and enabled in the instance
*/
export function isSamlEnabled(): boolean {
return config.getEnv('sso.saml.enabled');
}
export function isSamlLicensed(): boolean {
const license = getLicense();
return (
isUserManagementEnabled() &&
(license.isSamlEnabled() || config.getEnv('enterprise.features.saml'))
);
}
export const isSamlPreferences = (candidate: unknown): candidate is SamlPreferences => {
const o = candidate as SamlPreferences;
return typeof o === 'object' && typeof o.metadata === 'string' && typeof o.mapping === 'object';
};
export function generatePassword(): string {
const length = 18;
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const charsetNoNumbers = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const randomNumber = Math.floor(Math.random() * 10);
const randomUpper = charset.charAt(Math.floor(Math.random() * charsetNoNumbers.length));
const randomNumberPosition = Math.floor(Math.random() * length);
const randomUpperPosition = Math.floor(Math.random() * length);
let password = '';
for (let i = 0, n = charset.length; i < length; ++i) {
password += charset.charAt(Math.floor(Math.random() * n));
}
password =
password.substring(0, randomNumberPosition) +
randomNumber.toString() +
password.substring(randomNumberPosition);
password =
password.substring(0, randomUpperPosition) +
randomUpper +
password.substring(randomUpperPosition);
return password;
}
export async function createUserFromSamlAttributes(attributes: SamlUserAttributes): Promise<User> {
const user = new User();
const authIdentity = new AuthIdentity();
user.email = attributes.email;
user.firstName = attributes.firstName;
user.lastName = attributes.lastName;
user.globalRole = await Db.collections.Role.findOneOrFail({
where: { name: 'member', scope: 'global' },
});
// generates a password that is not used or known to the user
user.password = await hashPassword(generatePassword());
authIdentity.providerId = attributes.userPrincipalName;
authIdentity.providerType = 'saml';
authIdentity.user = user;
const resultAuthIdentity = await Db.collections.AuthIdentity.save(authIdentity);
if (!resultAuthIdentity) throw new AuthError('Could not create AuthIdentity');
user.authIdentities = [authIdentity];
const resultUser = await Db.collections.User.save(user);
if (!resultUser) throw new AuthError('Could not create User');
return resultUser;
}
export async function updateUserFromSamlAttributes(
user: User,
attributes: SamlUserAttributes,
): Promise<User> {
if (!attributes.email) throw new AuthError('Email is required to update user');
if (!user) throw new AuthError('User not found');
let samlAuthIdentity = user?.authIdentities.find((e) => e.providerType === 'saml');
if (!samlAuthIdentity) {
samlAuthIdentity = new AuthIdentity();
samlAuthIdentity.providerId = attributes.userPrincipalName;
samlAuthIdentity.providerType = 'saml';
samlAuthIdentity.user = user;
user.authIdentities.push(samlAuthIdentity);
} else {
samlAuthIdentity.providerId = attributes.userPrincipalName;
}
await Db.collections.AuthIdentity.save(samlAuthIdentity);
user.firstName = attributes.firstName;
user.lastName = attributes.lastName;
const resultUser = await Db.collections.User.save(user);
if (!resultUser) throw new AuthError('Could not create User');
return resultUser;
}
type GetMappedSamlReturn = {
attributes: SamlUserAttributes | undefined;
missingAttributes: string[];
};
export function getMappedSamlAttributesFromFlowResult(
flowResult: FlowResult,
attributeMapping: SamlAttributeMapping,
): GetMappedSamlReturn {
const result: GetMappedSamlReturn = {
attributes: undefined,
missingAttributes: [] as string[],
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (flowResult?.extract?.attributes) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const attributes = flowResult.extract.attributes as { [key: string]: string };
// TODO:SAML: fetch mapped attributes from flowResult.extract.attributes and create or login user
const email = attributes[attributeMapping.email];
const firstName = attributes[attributeMapping.firstName];
const lastName = attributes[attributeMapping.lastName];
const userPrincipalName = attributes[attributeMapping.userPrincipalName];
result.attributes = {
email,
firstName,
lastName,
userPrincipalName,
};
if (!email) result.missingAttributes.push(attributeMapping.email);
if (!userPrincipalName) result.missingAttributes.push(attributeMapping.userPrincipalName);
if (!firstName) result.missingAttributes.push(attributeMapping.firstName);
if (!lastName) result.missingAttributes.push(attributeMapping.lastName);
}
return result;
}

View file

@ -0,0 +1,39 @@
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import type { ServiceProviderInstance } from 'samlify';
import { ServiceProvider, setSchemaValidator } from 'samlify';
import { SamlUrls } from './constants';
let serviceProviderInstance: ServiceProviderInstance | undefined;
setSchemaValidator({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validate: async (response: string) => {
// TODO:SAML: implment validation
return Promise.resolve('skipped');
},
});
const metadata = `
<EntityDescriptor
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="${getInstanceBaseUrl() + SamlUrls.restMetadata}">
<SPSSODescriptor WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService isDefault="true" index="0" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${
getInstanceBaseUrl() + SamlUrls.restAcs
}"/>
</SPSSODescriptor>
</EntityDescriptor>
`;
export function getServiceProviderInstance(): ServiceProviderInstance {
if (serviceProviderInstance === undefined) {
serviceProviderInstance = ServiceProvider({
metadata,
});
}
return serviceProviderInstance;
}

View file

@ -0,0 +1,7 @@
import type { AuthenticatedRequest } from '../../../requests';
import type { SamlPreferences } from './samlPreferences';
export declare namespace SamlConfiguration {
type Read = AuthenticatedRequest<{}, {}, {}, {}>;
type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>;
}

View file

@ -0,0 +1,6 @@
export interface SamlAttributeMapping {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
}

View file

@ -0,0 +1,7 @@
import type { SamlAttributeMapping } from './samlAttributeMapping';
export interface SamlPreferences {
mapping: SamlAttributeMapping;
metadata: string;
//TODO:SAML: add fields for separate SAML settins to generate metadata from
}

View file

@ -0,0 +1,6 @@
export interface SamlUserAttributes {
email: string;
firstName: string;
lastName: string;
userPrincipalName: string;
}

View file

@ -0,0 +1,13 @@
import config from '@/config';
export function isSamlCurrentAuthenticationMethod(): boolean {
return config.getEnv('userManagement.authenticationMethod') === 'saml';
}
export function isSsoJustInTimeProvisioningEnabled(): boolean {
return config.getEnv('sso.justInTimeProvisioning');
}
export function doRedirectUsersFromLoginToSsoFlow(): boolean {
return config.getEnv('sso.redirectLoginToSso');
}

View file

@ -164,7 +164,7 @@ export class WorkflowsService {
if (!config.getEnv('workflowTagsDisabled')) {
relations.push('tags');
select.tags = { name: true };
select.tags = { id: true, name: true };
}
if (isSharingEnabled()) {

View file

@ -401,6 +401,7 @@ export async function createManyWorkflows(
/**
* Store a workflow in the DB (without a trigger) and optionally assign it to a user.
* @param attributes workflow attributes
* @param user user to assign the workflow to
*/
export async function createWorkflow(attributes: Partial<WorkflowEntity> = {}, user?: User) {

View file

@ -154,6 +154,7 @@ describe('GET /workflows', () => {
test('should return workflows without nodes, sharing and credential usage details', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const tag = await testDb.createTag({ name: 'test' });
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
@ -175,6 +176,7 @@ describe('GET /workflows', () => {
},
},
],
tags: [tag],
},
owner,
);
@ -193,6 +195,14 @@ describe('GET /workflows', () => {
expect(fetchedWorkflow.sharedWith).not.toBeDefined()
expect(fetchedWorkflow.usedCredentials).not.toBeDefined()
expect(fetchedWorkflow.nodes).not.toBeDefined()
expect(fetchedWorkflow.tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
name: expect.any(String)
})
])
)
});
});

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "0.155.1",
"version": "0.156.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "0.54.0",
"version": "0.55.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.182.1",
"version": "0.183.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -56,11 +56,6 @@
"jquery": "^3.4.1",
"jsonpath": "^1.1.1",
"lodash-es": "^4.17.21",
"lodash.camelcase": "^4.3.0",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.orderby": "^4.6.0",
"lodash.set": "^4.3.2",
"luxon": "^3.1.0",
"monaco-editor": "^0.33.0",
"n8n-design-system": "workspace:*",

View file

@ -52,7 +52,7 @@ import { deepCopy, INodeProperties, INodePropertyOptions } from 'n8n-workflow';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { get } from 'lodash';
import { get } from 'lodash-es';
import mixins from 'vue-typed-mixins';
import { Component } from 'vue';

View file

@ -265,7 +265,7 @@ import {
IWorkflowShortResponse,
} from '@/Interface';
import type { ExecutionStatus, IDataObject } from 'n8n-workflow';
import { range as _range } from 'lodash';
import { range as _range } from 'lodash-es';
import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';

View file

@ -48,7 +48,7 @@ import { showMessage } from '@/mixins/showMessage';
import { v4 as uuid } from 'uuid';
import { Route } from 'vue-router';
import { executionHelpers } from '@/mixins/executionsHelpers';
import { range as _range } from 'lodash';
import { range as _range } from 'lodash-es';
import { debounceHelper } from '@/mixins/debounce';
import { getNodeViewTab, NO_NETWORK_ERROR_CODE } from '@/utils';
import { workflowHelpers } from '@/mixins/workflowHelpers';

View file

@ -124,7 +124,7 @@ import {
isINodePropertyCollectionList,
} from 'n8n-workflow';
import { get } from 'lodash';
import { get } from 'lodash-es';
export default Vue.extend({
name: 'FixedCollectionParameter',

View file

@ -90,7 +90,7 @@ import { deepCopy, INodeParameters, INodeProperties } from 'n8n-workflow';
import CollectionParameter from '@/components/CollectionParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { get } from 'lodash';
import { get } from 'lodash-es';
export default Vue.extend({
name: 'MultipleParameter',

View file

@ -38,7 +38,7 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import { get } from 'lodash';
import { get } from 'lodash-es';
import { INodeTypeDescription } from 'n8n-workflow';
import PanelDragButton from './PanelDragButton.vue';

View file

@ -189,7 +189,7 @@ import TitledList from '@/components/TitledList.vue';
import mixins from 'vue-typed-mixins';
import { get } from 'lodash';
import { get } from 'lodash-es';
import { getStyleTokenValue, getTriggerNodeServiceName } from '@/utils';
import {
IExecutionsSummary,

View file

@ -95,7 +95,7 @@ import {
onUnmounted,
nextTick,
} from 'vue';
import camelcase from 'lodash.camelcase';
import { camelCase } from 'lodash-es';
import { externalHooks } from '@/mixins/externalHooks';
import { INodeTypeDescription } from 'n8n-workflow';
import ItemIterator from './ItemIterator.vue';
@ -184,7 +184,7 @@ const activeSubcategoryTitle = computed<string>(() => {
if (!activeSubcategory.value || !activeSubcategory.value.properties) return '';
const subcategory = (activeSubcategory.value.properties as ISubcategoryItemProps).subcategory;
const subcategoryName = camelcase(subcategory);
const subcategoryName = camelCase(subcategory);
const titleLocaleKey = `nodeCreator.subcategoryTitles.${subcategoryName}` as BaseTextKey;
const nameLocaleKey = `nodeCreator.subcategoryNames.${subcategoryName}` as BaseTextKey;

View file

@ -14,14 +14,14 @@
<script setup lang="ts">
import { ISubcategoryItemProps } from '@/Interface';
import camelcase from 'lodash.camelcase';
import { camelCase } from 'lodash-es';
import { computed } from 'vue';
export interface Props {
item: ISubcategoryItemProps;
}
const props = defineProps<Props>();
const subcategoryName = computed(() => camelcase(props.item.subcategory));
const subcategoryName = computed(() => camelCase(props.item.subcategory));
</script>
<style lang="scss" module>

View file

@ -188,7 +188,7 @@ import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue';
import { get, set, unset } from 'lodash';
import { get, set, unset } from 'lodash-es';
import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers';

View file

@ -319,7 +319,7 @@
<script lang="ts">
/* eslint-disable prefer-spread */
import { get } from 'lodash';
import { get } from 'lodash-es';
import { INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import {

View file

@ -1,12 +1,15 @@
<template>
<n8n-text size="small" color="text-base" tag="div" v-if="hint">
<div v-if="!renderHTML" :class="{ [$style.hint]: true, [$style.highlight]: highlight }">
<div
v-if="!renderHTML"
:class="{ [$style.singleline]: singleLine, [$style.highlight]: highlight }"
>
{{ hint }}
</div>
<div
v-else
ref="hint"
:class="{ [$style.hint]: true, [$style.highlight]: highlight }"
:class="{ [$style.singleline]: singleLine, [$style.highlight]: highlight }"
v-html="sanitizeHtml(hint)"
></div>
</n8n-text>
@ -25,6 +28,9 @@ export default Vue.extend({
highlight: {
type: Boolean,
},
singleLine: {
type: Boolean,
},
renderHTML: {
type: Boolean,
default: false,
@ -42,12 +48,11 @@ export default Vue.extend({
</script>
<style lang="scss" module>
.hint {
.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.highlight {
color: var(--color-secondary);
}

View file

@ -124,7 +124,7 @@ import { workflowHelpers } from '@/mixins/workflowHelpers';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ImportParameter from '@/components/ImportParameter.vue';
import { get, set } from 'lodash';
import { get, set } from 'lodash-es';
import mixins from 'vue-typed-mixins';
import { Component, PropType } from 'vue';

View file

@ -29,6 +29,7 @@
data-test-id="parameter-expression-preview"
:highlight="!!(expressionOutput && targetItem)"
:hint="expressionOutput"
:singleLine="true"
/>
<input-hint
v-else-if="parameterHint"

View file

@ -172,7 +172,7 @@
</template>
<script lang="ts">
import { get, set, unset } from 'lodash';
import { get, set, unset } from 'lodash-es';
import { mapStores } from 'pinia';
import mixins from 'vue-typed-mixins';
import { useLogStreamingStore } from '../../stores/logStreamingStore';

View file

@ -3,7 +3,7 @@
* defined on the component which uses this mixin
*/
import Vue from 'vue';
import { debounce } from 'lodash';
import { debounce } from 'lodash-es';
export const copyPaste = Vue.extend({
data() {

View file

@ -1,4 +1,4 @@
import { debounce } from 'lodash';
import { debounce } from 'lodash-es';
import Vue from 'vue';
export const debounceHelper = Vue.extend({

View file

@ -34,7 +34,7 @@ import {
import { restApi } from '@/mixins/restApi';
import { get } from 'lodash';
import { get } from 'lodash-es';
import mixins from 'vue-typed-mixins';
import { isObjectLiteral } from '@/utils';

View file

@ -48,7 +48,7 @@ import { restApi } from '@/mixins/restApi';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { showMessage } from '@/mixins/showMessage';
import { isEqual } from 'lodash';
import { isEqual } from 'lodash-es';
import mixins from 'vue-typed-mixins';
import { v4 as uuid } from 'uuid';

View file

@ -12,7 +12,7 @@ import {
} from '@jsplumb/core';
import { AnchorPlacement, ConnectorOptions, Geometry, PaintAxis } from '@jsplumb/common';
import { BezierSegment } from '@jsplumb/connector-bezier';
import { isArray } from 'lodash';
import { isArray } from 'lodash-es';
import { deepCopy } from 'n8n-workflow';
export interface N8nConnectorOptions extends ConnectorOptions {}

View file

@ -1,4 +1,4 @@
import { startCase } from 'lodash';
import { startCase } from 'lodash-es';
import { defineStore } from 'pinia';
import {
INodePropertyCollection,

View file

@ -16,11 +16,6 @@ const ignoreChunks = [
'@fontsource/open-sans',
'normalize-wheel',
'stream-browserify',
'lodash.camelcase',
'lodash.debounce',
'lodash.get',
'lodash.orderby',
'lodash.set',
];
const isScopedPackageToIgnore = (str: string) => /@codemirror\//.test(str);
@ -44,11 +39,6 @@ function renderChunks() {
const publicPath = process.env.VUE_APP_PUBLIC_PATH || '/';
const lodashAliases = ['orderBy', 'camelCase', 'cloneDeep', 'isEqual', 'startCase'].map((name) => ({
find: new RegExp(`^lodash.${name}$`, 'i'),
replacement: require.resolve(`lodash-es/${name}`),
}));
const { NODE_ENV } = process.env;
export default mergeConfig(
@ -78,9 +68,12 @@ export default mergeConfig(
find: /^n8n-design-system\//,
replacement: resolve(__dirname, '..', 'design-system', 'src') + '/',
},
...lodashAliases,
...['orderBy', 'camelCase', 'cloneDeep', 'isEqual', 'startCase'].map((name) => ({
find: new RegExp(`^lodash.${name}$`, 'i'),
replacement: require.resolve(`lodash-es/${name}`),
})),
{
find: /^lodash.(.+)$/,
find: /^lodash\.(.+)$/,
replacement: 'lodash-es/$1',
},
{

View file

@ -1,6 +1,6 @@
{
"name": "n8n-node-dev",
"version": "0.94.1",
"version": "0.95.0",
"description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -4,7 +4,8 @@ import type { IDataObject, ILoadOptionsFunctions } from 'n8n-workflow';
import type { OptionsWithUri } from 'request';
import { flow, omit } from 'lodash';
import flow from 'lodash.flow';
import omit from 'lodash.omit';
import type {
AllFieldsUi,

View file

@ -7,7 +7,7 @@ import type {
INodePropertyOptions,
} from 'n8n-workflow';
import { get } from 'lodash';
import get from 'lodash.get';
/**
* Make an API request to Asana

View file

@ -12,7 +12,7 @@ import { jsonParse, NodeOperationError } from 'n8n-workflow';
import { awsApiRequestSOAP } from './GenericFunctions';
import { get } from 'lodash';
import get from 'lodash.get';
export class AwsSnsTrigger implements INodeType {
description: INodeTypeDescription = {

View file

@ -1,4 +1,4 @@
import { get } from 'lodash';
import get from 'lodash.get';
import type {
IExecuteFunctions,

View file

@ -1,4 +1,4 @@
import { get } from 'lodash';
import get from 'lodash.get';
import { parseString } from 'xml2js';

View file

@ -1,4 +1,4 @@
import { get } from 'lodash';
import get from 'lodash.get';
import { parseString } from 'xml2js';

View file

@ -1,4 +1,4 @@
import { get } from 'lodash';
import get from 'lodash.get';
import { parseString } from 'xml2js';

View file

@ -10,7 +10,7 @@ import type {
import type { IDataObject, IHttpRequestOptions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { get } from 'lodash';
import get from 'lodash.get';
export async function awsApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,

View file

@ -15,7 +15,7 @@ import type {
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { get } from 'lodash';
import get from 'lodash.get';
function getEndpointForService(
service: string,

View file

@ -33,7 +33,8 @@ import type {
} from './descriptions/MemberDescription';
import { memberFields, memberOperations } from './descriptions/MemberDescription';
import { isEmpty, partialRight } from 'lodash';
import isEmpty from 'lodash.isempty';
import partialRight from 'lodash.partialright';
export class Bitwarden implements INodeType {
description: INodeTypeDescription = {

View file

@ -10,7 +10,7 @@ import type {
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { upperFirst } from 'lodash';
import upperFirst from 'lodash.upperfirst';
import { createHash } from 'crypto';

View file

@ -1,6 +1,12 @@
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { difference, get, intersection, isEmpty, omit, set, union } from 'lodash';
import difference from 'lodash.difference';
import get from 'lodash.get';
import intersection from 'lodash.intersection';
import isEmpty from 'lodash.isempty';
import omit from 'lodash.omit';
import set from 'lodash.set';
import union from 'lodash.union';
import { fuzzyCompare } from '../../utils/utilities';
type PairToMatch = {

View file

@ -13,7 +13,8 @@ import type {
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { flow, omit } from 'lodash';
import flow from 'lodash.flow';
import omit from 'lodash.omit';
import type {
AddressFixedCollection,

View file

@ -18,7 +18,7 @@ import { responderFields, respondersOperations } from './ResponderDescription';
import { jobFields, jobOperations } from './JobDescription';
import { upperFirst } from 'lodash';
import upperFirst from 'lodash.upperfirst';
import type { IJob } from './AnalyzerInterface';

View file

@ -1,4 +1,4 @@
import { set } from 'lodash';
import set from 'lodash.set';
import type { IExecuteFunctions } from 'n8n-core';

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions, IHookFunctions, ILoadOptionsFunctions } from 'n
import type { IDataObject, IHttpRequestMethods, IHttpRequestOptions } from 'n8n-workflow';
import { get } from 'lodash';
import get from 'lodash.get';
export async function customerIoApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,

View file

@ -9,7 +9,7 @@ import type {
} from 'n8n-workflow';
import { deepCopy, NodeOperationError } from 'n8n-workflow';
import { set } from 'lodash';
import set from 'lodash.set';
import moment from 'moment-timezone';

View file

@ -1,4 +1,6 @@
import { flow, sortBy, uniqBy } from 'lodash';
import flow from 'lodash.flow';
import sortBy from 'lodash.sortby';
import uniqBy from 'lodash.uniqby';
export type DocumentProperties = {
customProperty: Array<{ field: string; value: string }>;

View file

@ -14,7 +14,7 @@ import { documentFields, documentOperations, indexFields, indexOperations } from
import type { DocumentGetAllOptions, FieldsUiValues } from './types';
import { omit } from 'lodash';
import omit from 'lodash.omit';
export class Elasticsearch implements INodeType {
description: INodeTypeDescription = {

View file

@ -22,7 +22,8 @@ import { connect as imapConnect, getParts } from 'imap-simple';
import type { Source as ParserSource } from 'mailparser';
import { simpleParser } from 'mailparser';
import _ from 'lodash';
import isEmpty from 'lodash.isempty';
import find from 'lodash.find';
export async function parseRawEmail(
this: ITriggerFunctions,
@ -241,7 +242,7 @@ export class EmailReadImapV1 implements INodeType {
if (credentials.secure) {
tlsOptions.servername = credentials.host as string;
}
if (!_.isEmpty(tlsOptions)) {
if (!isEmpty(tlsOptions)) {
config.imap.tlsOptions = tlsOptions;
}
const conn = imapConnect(config).then(async (entry) => {
@ -385,7 +386,7 @@ export class EmailReadImapV1 implements INodeType {
) {
staticData.lastMessageUid = message.attributes.uid;
}
const part = _.find(message.parts, { which: '' });
const part = find(message.parts, { which: '' });
if (part === undefined) {
throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.');
@ -474,7 +475,7 @@ export class EmailReadImapV1 implements INodeType {
) {
staticData.lastMessageUid = message.attributes.uid;
}
const part = _.find(message.parts, { which: 'TEXT' });
const part = find(message.parts, { which: 'TEXT' });
if (part === undefined) {
throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.');
@ -569,7 +570,7 @@ export class EmailReadImapV1 implements INodeType {
tlsOptions.servername = credentials.host as string;
}
if (!_.isEmpty(tlsOptions)) {
if (!isEmpty(tlsOptions)) {
config.imap.tlsOptions = tlsOptions;
}

View file

@ -21,7 +21,8 @@ import { connect as imapConnect, getParts } from 'imap-simple';
import type { Source as ParserSource } from 'mailparser';
import { simpleParser } from 'mailparser';
import _ from 'lodash';
import isEmpty from 'lodash.isempty';
import find from 'lodash.find';
import type { ICredentialsDataImap } from '../../../credentials/Imap.credentials';
import { isCredentialsDataImap } from '../../../credentials/Imap.credentials';
@ -240,7 +241,7 @@ export class EmailReadImapV2 implements INodeType {
if (credentials.secure) {
tlsOptions.servername = credentials.host;
}
if (!_.isEmpty(tlsOptions)) {
if (!isEmpty(tlsOptions)) {
config.imap.tlsOptions = tlsOptions;
}
const connection = await imapConnect(config);
@ -393,7 +394,7 @@ export class EmailReadImapV2 implements INodeType {
) {
staticData.lastMessageUid = message.attributes.uid;
}
const part = _.find(message.parts, { which: '' });
const part = find(message.parts, { which: '' });
if (part === undefined) {
throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.');
@ -482,7 +483,7 @@ export class EmailReadImapV2 implements INodeType {
) {
staticData.lastMessageUid = message.attributes.uid;
}
const part = _.find(message.parts, { which: 'TEXT' });
const part = find(message.parts, { which: 'TEXT' });
if (part === undefined) {
throw new NodeOperationError(this.getNode(), 'Email part could not be parsed.');
@ -580,7 +581,7 @@ export class EmailReadImapV2 implements INodeType {
tlsOptions.servername = credentials.host;
}
if (!_.isEmpty(tlsOptions)) {
if (!isEmpty(tlsOptions)) {
config.imap.tlsOptions = tlsOptions;
}

View file

@ -14,7 +14,7 @@ import { campaignFields, campaignOperations } from './CampaignDescription';
import { contactListFields, contactListOperations } from './ContactListDescription';
import { isEmpty } from 'lodash';
import isEmpty from 'lodash.isempty';
export class Emelia implements INodeType {
description: INodeTypeDescription = {

View file

@ -12,7 +12,7 @@ import type {
import type { OptionsWithUri } from 'request';
import { omit } from 'lodash';
import omit from 'lodash.omit';
export async function freshserviceApiRequest(
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,

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