diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba0cebe54..4b7bd29603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 541da069c4..b8f238b8f2 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -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); diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 13d1bc6392..e0f984d13a 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -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); diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index f21320ca9c..cea2c4fef7 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -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'); diff --git a/cypress/e2e/14-data-transformation-expressions.cy.ts b/cypress/e2e/14-data-transformation-expressions.cy.ts index a5186a8af5..1082765393 100644 --- a/cypress/e2e/14-data-transformation-expressions.cy.ts +++ b/cypress/e2e/14-data-transformation-expressions.cy.ts @@ -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); }); diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 846eafb6e5..ff6ee35a52 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -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,40 +69,56 @@ 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') - .should('include.text', '1') - .invoke('css', 'color') - .should('equal', 'rgb(125, 125, 135)'); + ndv.getters + .parameterExpressionPreview('value') + .should('include.text', '1') + .invoke('css', 'color') + .should('equal', 'rgb(125, 125, 135)'); 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') - .should('include.text', '1') - .invoke('css', 'color') - .should('equal', 'rgb(125, 125, 135)'); + ndv.getters + .parameterExpressionPreview('value') + .should('include.text', '1') + .invoke('css', 'color') + .should('equal', 'rgb(125, 125, 135)'); }); it('maps expressions from json view', () => { @@ -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"]'); }); }); diff --git a/cypress/e2e/15-scheduler-node.cy.ts b/cypress/e2e/15-scheduler-node.cy.ts index 65dae8f4d7..922972263a 100644 --- a/cypress/e2e/15-scheduler-node.cy.ts +++ b/cypress/e2e/15-scheduler-node.cy.ts @@ -5,7 +5,7 @@ const workflowPage = new WorkflowPage(); const ndv = new NDV(); describe('Schedule Trigger node', async () => { - beforeEach(() => { + before(() => { cy.resetAll(); cy.skipSetup(); }); @@ -42,34 +42,44 @@ describe('Schedule Trigger node', async () => { workflowPage.actions.activateWorkflow(); workflowPage.getters.activatorSwitch().should('have.class', 'is-checked'); - 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) => { - cy.wait(1200); - cy.request("GET", '/rest/executions').then((response) => { + cy.request('GET', '/rest/workflows') + .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); - expect(matchingExecutions).to.have.length(1); + 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) => { - 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); - expect(matchingExecutions).to.have.length(2); - }).then(()=>{ - workflowPage.actions.activateWorkflow(); - workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked'); - cy.visit(workflowsPage.url); - workflowsPage.actions.deleteWorkFlow('Schedule Trigger Workflow'); - }); + 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, + ); + expect(matchingExecutions).to.have.length(1); + return workflowId; + }) + .then((workflowId) => { + cy.wait(1200); + 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, + ); + expect(matchingExecutions).to.have.length(2); + }) + .then(() => { + workflowPage.actions.activateWorkflow(); + workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked'); + cy.visit(workflowsPage.url); + workflowsPage.actions.deleteWorkFlow('Schedule Trigger Workflow'); + }); + }); }); - }); }); }); diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 07495cc4b0..3ed3c342cf 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -27,7 +27,7 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => { respondWith, responseData, executeNow = true, - } = options; + } = options; workflowPage.actions.addInitialNodeToCanvas('Webhook'); workflowPage.actions.openNode('Webhook'); @@ -47,43 +47,43 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => { if (authentication) { cy.getByTestId('parameter-input-authentication').click(); cy.getByTestId('parameter-input-authentication') - .find('.el-select-dropdown') - .find('.option-headline') - .contains(authentication) - .click(); + .find('.el-select-dropdown') + .find('.option-headline') + .contains(authentication) + .click(); } if (responseCode) { cy.getByTestId('parameter-input-responseCode') - .find('.parameter-input') - .find('input') - .clear() - .type(responseCode.toString()); + .find('.parameter-input') + .find('input') + .clear() + .type(responseCode.toString()); } if (respondWith) { cy.getByTestId('parameter-input-responseMode').click(); cy.getByTestId('parameter-input-responseMode') - .find('.el-select-dropdown') - .find('.option-headline') - .contains(respondWith) - .click(); + .find('.el-select-dropdown') + .find('.option-headline') + .contains(respondWith) + .click(); } if (responseData) { cy.getByTestId('parameter-input-responseData').click(); cy.getByTestId('parameter-input-responseData') - .find('.el-select-dropdown') - .find('.option-headline') - .contains(responseData) - .click(); + .find('.el-select-dropdown') + .find('.option-headline') + .contains(responseData) + .click(); } if (executeNow) { 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,28 +288,29 @@ 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(() => { - cy.request({ - method: 'GET', - url: '/webhook-test/'+ webhookPath, - auth: { - 'user': 'test', - 'pass': 'test', - }, - failOnStatusCode: true, - }).then((response) => { - expect(response.status).to.eq(200); + .then((response) => { + expect(response.status).to.eq(403); + }) + .then(() => { + cy.request({ + method: 'GET', + url: '/webhook-test/' + webhookPath, + auth: { + user: 'test', + pass: 'test', + }, + failOnStatusCode: true, + }).then((response) => { + expect(response.status).to.eq(200); + }); }); - }); }); it('should listen for a GET request with Header Authentication', () => { @@ -315,25 +331,26 @@ describe('Webhook Trigger node', async () => { cy.wait(waitForWebhook); cy.request({ method: 'GET', - url: '/webhook-test/'+ webhookPath, + url: '/webhook-test/' + webhookPath, headers: { test: 'wrong', }, failOnStatusCode: false, }) - .then((response) => { - expect(response.status).to.eq(403); - }).then(() => { - cy.request({ - method: 'GET', - url: '/webhook-test/'+ webhookPath, - headers: { - test: 'test', - }, - failOnStatusCode: true, - }).then((response) => { - expect(response.status).to.eq(200); + .then((response) => { + expect(response.status).to.eq(403); + }) + .then(() => { + cy.request({ + method: 'GET', + url: '/webhook-test/' + webhookPath, + headers: { + test: 'test', + }, + failOnStatusCode: true, + }).then((response) => { + expect(response.status).to.eq(200); + }); }); - }); }); }); diff --git a/cypress/e2e/17-workflow-tags.cy.ts b/cypress/e2e/17-workflow-tags.cy.ts index 87926bf0f2..27c3e0b0b5 100644 --- a/cypress/e2e/17-workflow-tags.cy.ts +++ b/cypress/e2e/17-workflow-tags.cy.ts @@ -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(); }); diff --git a/cypress/e2e/18-user-management.cy.ts b/cypress/e2e/18-user-management.cy.ts index f01cee7844..f06ba3d655 100644 --- a/cypress/e2e/18-user-management.cy.ts +++ b/cypress/e2e/18-user-management.cy.ts @@ -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, + ); }); }); diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 37b9561f6f..79b60d9dc6 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -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'); diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index 2ab6473702..82cfa427a3 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -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'); - }) + }); }); diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index bd7dc0a210..b40f26be91 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -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(); -} +}; diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 4068b2c1fd..9850ab14c1 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -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.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'); }); 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(); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 350ad3a185..9025725e55 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -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(); diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 540e4825a0..0e4a16d933 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -5,7 +5,7 @@ const WorkflowPage = new WorkflowPageClass(); const ndv = new NDV(); describe('Code node', () => { - beforeEach(() => { + before(() => { cy.resetAll(); cy.skipSetup(); }); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index aeb3752ec2..4742bb3cbc 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -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(); }); diff --git a/cypress/e2e/8-http-request-node.cy.ts b/cypress/e2e/8-http-request-node.cy.ts index cc62b5182f..11c9619472 100644 --- a/cypress/e2e/8-http-request-node.cy.ts +++ b/cypress/e2e/8-http-request-node.cy.ts @@ -5,7 +5,7 @@ const workflowPage = new WorkflowPage(); const ndv = new NDV(); describe('HTTP Request node', () => { - beforeEach(() => { + before(() => { cy.resetAll(); cy.skipSetup(); }); diff --git a/package.json b/package.json index 880b34fd2b..0b205e4a35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.216.1", + "version": "0.217.1", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/cli/package.json b/packages/cli/package.json index bc8ae65e17..2469d344da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index b7b22336d5..f69349de26 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -190,7 +190,7 @@ export function send( 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) { diff --git a/packages/cli/src/Saml/helpers.ts b/packages/cli/src/Saml/helpers.ts deleted file mode 100644 index c12f02cdba..0000000000 --- a/packages/cli/src/Saml/helpers.ts +++ /dev/null @@ -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(); -} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index efeabb4f42..13cb901ac6 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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( diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index b4cff8dc29..cd846facac 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -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 { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const utcDate = DateUtils.mixedDateToUtcDatetimeString(date); - const toPrune: FindOptionsWhere = { stoppedAt: LessThanOrEqual(utcDate) }; + const toPrune: Array> = [ + { stoppedAt: LessThanOrEqual(utcDate) }, + ]; if (maxCount > 0) { const executions = await Db.collections.Execution.find({ @@ -223,27 +225,29 @@ async function pruneExecutionData(this: WorkflowHooks): Promise { }); 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); - // Mark binary data for deletion for all executions - if (!isBinaryModeDefaultMode) - await BinaryDataManager.getInstance().markDataForDeletionByExecutionIds( - executions.map(({ id }) => id), - ); + let executionIds: Array; + 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(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) { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index c653838169..ada08e48ec 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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', ''); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 1d660eb724..78e728c33a 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -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, diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 0db2cbee35..5092d1ea66 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -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 { + async login(req: LoginRequest, res: Response): Promise { 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)); diff --git a/packages/cli/src/databases/entities/AuthIdentity.ts b/packages/cli/src/databases/entities/AuthIdentity.ts index cfe8ab270a..7069206287 100644 --- a/packages/cli/src/databases/entities/AuthIdentity.ts +++ b/packages/cli/src/databases/entities/AuthIdentity.ts @@ -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']) diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index d62bf8482f..6cba438f79 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -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; } } diff --git a/packages/cli/src/databases/migrations/mysqldb/1677236788851-UpdateRunningExecutionStatus.ts b/packages/cli/src/databases/migrations/mysqldb/1677236788851-UpdateRunningExecutionStatus.ts new file mode 100644 index 0000000000..12225e7623 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1677236788851-UpdateRunningExecutionStatus.ts @@ -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 { + 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 {} +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 4adf1945ad..f71692d7ef 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -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, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1677236854063-UpdateRunningExecutionStatus.ts b/packages/cli/src/databases/migrations/postgresdb/1677236854063-UpdateRunningExecutionStatus.ts new file mode 100644 index 0000000000..cc01754903 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1677236854063-UpdateRunningExecutionStatus.ts @@ -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 { + 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 {} +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index b96b37e226..137a5a0ecf 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -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, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1677237073720-UpdateRunningExecutionStatus.ts b/packages/cli/src/databases/migrations/sqlite/1677237073720-UpdateRunningExecutionStatus.ts new file mode 100644 index 0000000000..335fd27c1f --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1677237073720-UpdateRunningExecutionStatus.ts @@ -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 { + 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 {} +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 0b418ccd14..3c647459f9 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -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, ]; diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index 070b86dd09..34c76ea592 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -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(); diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso/saml/constants.ts new file mode 100644 index 0000000000..16565fa712 --- /dev/null +++ b/packages/cli/src/sso/saml/constants.ts @@ -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'; diff --git a/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts new file mode 100644 index 0000000000..bcd1005e1f --- /dev/null +++ b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts @@ -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' }); + } +}; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts new file mode 100644 index 0000000000..9879cbe82c --- /dev/null +++ b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts @@ -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.'); + } + }, +); diff --git a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts new file mode 100644 index 0000000000..33e9427f70 --- /dev/null +++ b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts @@ -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()); +}); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts new file mode 100644 index 0000000000..b5a2fe637b --- /dev/null +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -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 { + 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 { + return { + mapping: this.attributeMapping, + metadata: this.metadata, + }; + } + + async setSamlPreferences(prefs: SamlPreferences): Promise { + this.attributeMapping = prefs.mapping; + this.metadata = prefs.metadata; + this.getIdentityProviderInstance(true); + await this.saveSamlPreferences(); + } + + async loadSamlPreferences(): Promise { + const samlPreferences = await Db.collections.Settings.findOne({ + where: { key: SAML_PREFERENCES_DB_KEY }, + }); + if (samlPreferences) { + const prefs = jsonParse(samlPreferences.value); + if (prefs) { + this.attributeMapping = prefs.mapping; + this.metadata = prefs.metadata; + } + return prefs; + } + return; + } + + async saveSamlPreferences(): Promise { + 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 { + 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; + } +} diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts new file mode 100644 index 0000000000..20733e1fe5 --- /dev/null +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts new file mode 100644 index 0000000000..b99bc71a18 --- /dev/null +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -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 = ` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +`; + +export function getServiceProviderInstance(): ServiceProviderInstance { + if (serviceProviderInstance === undefined) { + serviceProviderInstance = ServiceProvider({ + metadata, + }); + } + + return serviceProviderInstance; +} diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts new file mode 100644 index 0000000000..c9beab0c21 --- /dev/null +++ b/packages/cli/src/sso/saml/types/requests.ts @@ -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, {}>; +} diff --git a/packages/cli/src/sso/saml/types/samlAttributeMapping.ts b/packages/cli/src/sso/saml/types/samlAttributeMapping.ts new file mode 100644 index 0000000000..af7dd76e23 --- /dev/null +++ b/packages/cli/src/sso/saml/types/samlAttributeMapping.ts @@ -0,0 +1,6 @@ +export interface SamlAttributeMapping { + email: string; + firstName: string; + lastName: string; + userPrincipalName: string; +} diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts new file mode 100644 index 0000000000..d57f10b9ae --- /dev/null +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -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 +} diff --git a/packages/cli/src/sso/saml/types/samlUserAttributes.ts b/packages/cli/src/sso/saml/types/samlUserAttributes.ts new file mode 100644 index 0000000000..fa3c849f65 --- /dev/null +++ b/packages/cli/src/sso/saml/types/samlUserAttributes.ts @@ -0,0 +1,6 @@ +export interface SamlUserAttributes { + email: string; + firstName: string; + lastName: string; + userPrincipalName: string; +} diff --git a/packages/cli/src/sso/ssoHelpers.ts b/packages/cli/src/sso/ssoHelpers.ts new file mode 100644 index 0000000000..f00ddc0fe5 --- /dev/null +++ b/packages/cli/src/sso/ssoHelpers.ts @@ -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'); +} diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 2b736faf15..58a0a56a7b 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -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()) { diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index a0e9dc52f4..4b26eb1625 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -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 = {}, user?: User) { diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index b03bbebc74..d43e9f790e 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -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) + }) + ]) + ) }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 60ec9df01b..861e9386ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index e820e7b0c2..23c8c28333 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 7863b7193a..47942dbbcf 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -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:*", diff --git a/packages/editor-ui/src/components/CollectionParameter.vue b/packages/editor-ui/src/components/CollectionParameter.vue index a9ef55d9bb..edcadf6472 100644 --- a/packages/editor-ui/src/components/CollectionParameter.vue +++ b/packages/editor-ui/src/components/CollectionParameter.vue @@ -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'; diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index fcd3f8214c..b890db34ce 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -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'; diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue index 53b97ca84b..badfa3bf4d 100644 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue @@ -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'; diff --git a/packages/editor-ui/src/components/FixedCollectionParameter.vue b/packages/editor-ui/src/components/FixedCollectionParameter.vue index 01938d77af..fdae7a368f 100644 --- a/packages/editor-ui/src/components/FixedCollectionParameter.vue +++ b/packages/editor-ui/src/components/FixedCollectionParameter.vue @@ -124,7 +124,7 @@ import { isINodePropertyCollectionList, } from 'n8n-workflow'; -import { get } from 'lodash'; +import { get } from 'lodash-es'; export default Vue.extend({ name: 'FixedCollectionParameter', diff --git a/packages/editor-ui/src/components/MultipleParameter.vue b/packages/editor-ui/src/components/MultipleParameter.vue index a7e0741570..3d8e912d0a 100644 --- a/packages/editor-ui/src/components/MultipleParameter.vue +++ b/packages/editor-ui/src/components/MultipleParameter.vue @@ -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', diff --git a/packages/editor-ui/src/components/NDVDraggablePanels.vue b/packages/editor-ui/src/components/NDVDraggablePanels.vue index 5ff5cc40e5..25fd966240 100644 --- a/packages/editor-ui/src/components/NDVDraggablePanels.vue +++ b/packages/editor-ui/src/components/NDVDraggablePanels.vue @@ -38,7 +38,7 @@