🔀 Merge branch 'master' into 'feature/n8n-public-api''

This commit is contained in:
ricardo 2022-04-25 17:49:38 -04:00
commit b434cfb9d6
820 changed files with 12357 additions and 8027 deletions

View file

@ -1,3 +1,2 @@
packages/nodes-base
packages/editor-ui
packages/design-system

View file

@ -1,4 +1,6 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true,
@ -21,6 +23,28 @@ module.exports = {
'**/migrations/**',
],
overrides: [
{
files: './packages/*(cli|core|workflow|node-dev)/**/*.ts',
plugins: [
/**
* Plugin with lint rules for import/export syntax
* https://github.com/import-js/eslint-plugin-import
*/
'eslint-plugin-import',
/**
* @typescript-eslint/eslint-plugin is required by eslint-config-airbnb-typescript
* See step 2: https://github.com/iamturns/eslint-config-airbnb-typescript#2-install-eslint-plugins
*/
'@typescript-eslint',
/**
* Plugin to report formatting violations as lint violations
* https://github.com/prettier/eslint-plugin-prettier
*/
'eslint-plugin-prettier',
],
extends: [
/**
* Config for typescript-eslint recommended ruleset (without type checking)
@ -51,27 +75,6 @@ module.exports = {
*/
'eslint-config-prettier',
],
plugins: [
/**
* Plugin with lint rules for import/export syntax
* https://github.com/import-js/eslint-plugin-import
*/
'eslint-plugin-import',
/**
* @typescript-eslint/eslint-plugin is required by eslint-config-airbnb-typescript
* See step 2: https://github.com/iamturns/eslint-config-airbnb-typescript#2-install-eslint-plugins
*/
'@typescript-eslint',
/**
* Plugin to report formatting violations as lint violations
* https://github.com/prettier/eslint-plugin-prettier
*/
'eslint-plugin-prettier',
],
rules: {
// ******************************************************************
// required by prettier plugin
@ -122,7 +125,7 @@ module.exports = {
'undefined',
],
'no-void': ['error', { 'allowAsStatement': true }],
'no-void': ['error', { allowAsStatement: true }],
// ----------------------------------
// @typescript-eslint
@ -186,7 +189,10 @@ module.exports = {
/**
* https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/explicit-member-accessibility.md
*/
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
'@typescript-eslint/explicit-member-accessibility': [
'error',
{ accessibility: 'no-public' },
],
/**
* https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/member-delimiter-style.md
@ -363,4 +369,32 @@ module.exports = {
*/
'import/prefer-default-export': 'off',
},
},
{
files: ['./packages/nodes-base/nodes/**/*.ts'],
plugins: ['eslint-plugin-n8n-nodes-base'],
rules: {
"n8n-nodes-base/node-param-default-missing": "error",
"n8n-nodes-base/node-class-description-missing-subtitle": "error",
"n8n-nodes-base/node-class-description-inputs-wrong-trigger-node": "error",
"n8n-nodes-base/node-class-description-inputs-wrong-regular-node": "error",
"n8n-nodes-base/node-class-description-outputs-wrong": "error",
"n8n-nodes-base/node-execute-block-double-assertion-for-items": "error",
"n8n-nodes-base/node-param-default-wrong-for-collection": "error",
"n8n-nodes-base/node-param-default-wrong-for-boolean": "error",
"n8n-nodes-base/node-param-collection-type-unsorted-items": "error",
"n8n-nodes-base/node-param-default-wrong-for-fixed-collection": "error",
"n8n-nodes-base/node-param-default-wrong-for-multi-options": "error",
"n8n-nodes-base/node-param-description-excess-inner-whitespace": "error",
"n8n-nodes-base/node-param-description-empty-string": "error",
"n8n-nodes-base/node-param-description-comma-separated-hyphen": "error",
"n8n-nodes-base/node-param-default-wrong-for-simplify": "error",
"n8n-nodes-base/node-param-description-missing-for-return-all": "error",
"n8n-nodes-base/node-param-description-missing-final-period": "error",
"n8n-nodes-base/node-param-description-missing-for-simplify": "error",
"n8n-nodes-base/node-param-description-missing-for-ignore-ssl-issues": "error",
"n8n-nodes-base/node-param-description-identical-to-display-name": "error",
}
},
],
};

View file

@ -1,27 +1,90 @@
# [0.171.0](https://github.com/n8n-io/n8n/compare/n8n@0.170.0...n8n@0.171.0) (2022-04-03)
# [0.174.0](https://github.com/n8n-io/n8n/compare/n8n@0.173.1...n8n@0.174.0) (2022-04-25)
### Bug Fixes
* **core:** Fix crash on webhook when last node did not return data ([c50d04a](https://github.com/n8n-io/n8n/commit/c50d04af9eb033d82860c336fc7350b5c3f22242))
* **EmailReadImap Node:** Fix issue that crashed process if node was configured wrong ([#3079](https://github.com/n8n-io/n8n/issues/3079)) ([85f15d4](https://github.com/n8n-io/n8n/commit/85f15d49896d876fa3ab84e9fa1846f856851274))
* **Google Tasks Node:** Fix "Show Completed" option and hide title field where not needed ([#2741](https://github.com/n8n-io/n8n/issues/2741)) ([9d703e3](https://github.com/n8n-io/n8n/commit/9d703e366b8e191e0f588469892ebb7b6d03c1d3))
* **NocoDB Node:** Fix pagination ([#3081](https://github.com/n8n-io/n8n/issues/3081)) ([5f44b0d](https://github.com/n8n-io/n8n/commit/5f44b0dad5254fe9f985b314db8f7d43ab48c712))
* **Salesforce Node:** Fix issue that "status" did not get used for Case => Create & Update ([#2212](https://github.com/n8n-io/n8n/issues/2212)) ([1018146](https://github.com/n8n-io/n8n/commit/1018146f21c47eda9f888bd19e92d1106c49267a))
- **core:** Open oauth callback endpoints to be public ([#3168](https://github.com/n8n-io/n8n/issues/3168)) ([01807d6](https://github.com/n8n-io/n8n/commit/01807d613654eb14dad0eb82defa4fab234a1d71))
- **MicrosoftOneDrive Node:** Fix issue with filenames that contain special characters from uploading ([#3183](https://github.com/n8n-io/n8n/issues/3183)) ([ff26a98](https://github.com/n8n-io/n8n/commit/ff26a987fe244b30f67d6516d84f1f43fed3ec43))
- **Slack Node:** Fix credential test ([#3151](https://github.com/n8n-io/n8n/issues/3151)) ([15e6d92](https://github.com/n8n-io/n8n/commit/15e6d9274ad0627dd5ebc30e70757878368042bc))
### Features
* **editor:** Add download button for binary data ([#2992](https://github.com/n8n-io/n8n/issues/2992)) ([13a9db7](https://github.com/n8n-io/n8n/commit/13a9db774576a00d4e3ce1988557654d00067073))
* **Emelia Node:** Add Campaign > Duplicate functionality ([#3000](https://github.com/n8n-io/n8n/issues/3000)) ([0b08be1](https://github.com/n8n-io/n8n/commit/0b08be1c0b2961f235fc2446a36afe3995b4d847)), closes [#3065](https://github.com/n8n-io/n8n/issues/3065) [#2741](https://github.com/n8n-io/n8n/issues/2741) [#3075](https://github.com/n8n-io/n8n/issues/3075)
* **FTP Node:** Add option to recursively create directories on rename ([#3001](https://github.com/n8n-io/n8n/issues/3001)) ([39a6f41](https://github.com/n8n-io/n8n/commit/39a6f417203b76cfa2c68816c49e86dc7236aba4))
* **Mautic Node:** Add credential test and allow trailing slash in host ([#3080](https://github.com/n8n-io/n8n/issues/3080)) ([0a75539](https://github.com/n8n-io/n8n/commit/0a75539cc3d696a8946d7db5ff5842ff54835134))
* **Microsoft Teams Node:** Add chat message support ([#2635](https://github.com/n8n-io/n8n/issues/2635)) ([984f62d](https://github.com/n8n-io/n8n/commit/984f62df9ed92cdf297b3b56300c9f23bf128d2d))
* **Mocean Node:** Add "Delivery Report URL" option and credential tests ([#3075](https://github.com/n8n-io/n8n/issues/3075)) ([c89d2b1](https://github.com/n8n-io/n8n/commit/c89d2b10f2461ff8e90209b8f29c222f9430dba5))
* **ServiceNow Node:** Add basicAuth support and fix getColumns loadOptions ([#2712](https://github.com/n8n-io/n8n/issues/2712)) ([2c72584](https://github.com/n8n-io/n8n/commit/2c72584b55521b437baa20ddad7c919807fd9f8f)), closes [#2741](https://github.com/n8n-io/n8n/issues/2741) [#3075](https://github.com/n8n-io/n8n/issues/3075) [#3000](https://github.com/n8n-io/n8n/issues/3000) [#3065](https://github.com/n8n-io/n8n/issues/3065) [#2741](https://github.com/n8n-io/n8n/issues/2741) [#3075](https://github.com/n8n-io/n8n/issues/3075) [#3071](https://github.com/n8n-io/n8n/issues/3071) [#3001](https://github.com/n8n-io/n8n/issues/3001) [#2635](https://github.com/n8n-io/n8n/issues/2635) [#3080](https://github.com/n8n-io/n8n/issues/3080) [#3061](https://github.com/n8n-io/n8n/issues/3061) [#3081](https://github.com/n8n-io/n8n/issues/3081) [#2582](https://github.com/n8n-io/n8n/issues/2582) [#2212](https://github.com/n8n-io/n8n/issues/2212)
* **Strava Node:** Add "Get Streams" operation ([#2582](https://github.com/n8n-io/n8n/issues/2582)) ([6bbb4df](https://github.com/n8n-io/n8n/commit/6bbb4df05925362404f844a23a695f186d27b72e))
- **All AWS Nodes:** Enable support for AWS temporary credentials ([#2587](https://github.com/n8n-io/n8n/issues/2587)) ([ce79e6b](https://github.com/n8n-io/n8n/commit/ce79e6b74f6d94694f16988c8601f7c0639a04b3))
- **editor:** Add Workflow Stickies (Notes) ([#3154](https://github.com/n8n-io/n8n/issues/3154)) ([31dd01f](https://github.com/n8n-io/n8n/commit/31dd01f9cb7e1b6908a89c3402c78515a6475e61))
- **Google Sheets Node:** Add upsert support ([#2733](https://github.com/n8n-io/n8n/issues/2733)) ([aeb5a12](https://github.com/n8n-io/n8n/commit/aeb5a1234aa610b333525512085fe3b3bd60abef))
- **Microsoft Teams Node:** Enhancements and cleanup ([#2940](https://github.com/n8n-io/n8n/issues/2940)) ([d446f9e](https://github.com/n8n-io/n8n/commit/d446f9e28176e6ae2875d526cf4b6ac769dc750c))
- **MongoDB Node:** Allow parsing dates using dot notation ([#2487](https://github.com/n8n-io/n8n/issues/2487)) ([83998a1](https://github.com/n8n-io/n8n/commit/83998a15b0b4bea94aa07984136bdc56523d4f89))
# [0.173.1](https://github.com/n8n-io/n8n/compare/n8n@0.173.0...n8n@0.173.1) (2022-04-19)
### Bug Fixes
- **Discord Node:** Fix icon name
# [0.173.0](https://github.com/n8n-io/n8n/compare/n8n@0.172.0...n8n@0.173.0) (2022-04-19)
### Bug Fixes
- **core:** Add "rawBody" also for xml requests ([#3143](https://github.com/n8n-io/n8n/issues/3143)) ([5719e44](https://github.com/n8n-io/n8n/commit/5719e44b5999bb84e2fd50c8a469cc8934539747))
- **core:** Make email for UM case insensitive ([#3078](https://github.com/n8n-io/n8n/issues/3078)) ([8532b00](https://github.com/n8n-io/n8n/commit/8532b0030dbdeb85b2f74ce078adb44f43a7c4d3))
- **Discourse Node:** Fix issue with not all posts getting returned and add credential test ([#3007](https://github.com/n8n-io/n8n/issues/3007)) ([d68b7a4](https://github.com/n8n-io/n8n/commit/d68b7a4cf4b1025ce19e23f6bfc9e423595b6c0b))
- **editor:** Fix breaking Drop-downs after removing expressions ([#3094](https://github.com/n8n-io/n8n/issues/3094)) ([17b0cd8](https://github.com/n8n-io/n8n/commit/17b0cd8f765ce262241c827a635e64c189acc0f8))
- **Postgres Node:** Fix issue with columns containing spaces ([#2989](https://github.com/n8n-io/n8n/issues/2989)) ([0081d02](https://github.com/n8n-io/n8n/commit/0081d02b979ff5d98c5a834c60d8d8b5e83924ef))
- **ui:** Reset text-edit input value when pressing esc key to have matching input values ([#3098](https://github.com/n8n-io/n8n/issues/3098)) ([29fdd77](https://github.com/n8n-io/n8n/commit/29fdd77d7b4ac3bbb9faae73b0932183d48ae9a6))
- **ZendeskTrigger Node:** Fix deprecated targets, replaced with webhooks ([#3025](https://github.com/n8n-io/n8n/issues/3025)) ([794ad7c](https://github.com/n8n-io/n8n/commit/794ad7c756c68e0459d8f105acc3bcc1347d1e59))
- **Zoho Node:** Fix pagination issue ([#3129](https://github.com/n8n-io/n8n/issues/3129)) ([47bbe98](https://github.com/n8n-io/n8n/commit/47bbe9857b5f3321c9402595041afcb6b96411c4))
### Features
- **Discord Node:** Add additional options ([#2918](https://github.com/n8n-io/n8n/issues/2918)) ([310bffe](https://github.com/n8n-io/n8n/commit/310bffe7137f6baf36b93719c1e5abe8596dd346))
- **editor:** Add drag and drop from nodes panel ([#3123](https://github.com/n8n-io/n8n/issues/3123)) ([f566569](https://github.com/n8n-io/n8n/commit/f56656929992b98a3473944fd2a395e05d5c42f0))
- **Google Cloud Realtime Database Node:** Make it possible to select region ([#3096](https://github.com/n8n-io/n8n/issues/3096)) ([176538e](https://github.com/n8n-io/n8n/commit/176538e5f21f14ea3e5964dbe905fe4af89faaef))
- **GoogleBigQuery Node:** Add support for service account authentication ([#3128](https://github.com/n8n-io/n8n/issues/3128)) ([ac5f357](https://github.com/n8n-io/n8n/commit/ac5f357001b6887d649f65bc32a30e30aa75584b))
- **Markdown Node:** Add new node to covert between Markdown <> HTML ([#1728](https://github.com/n8n-io/n8n/issues/1728)) ([5d1ddb0](https://github.com/n8n-io/n8n/commit/5d1ddb0e9b56d999ec4d9278b81262aafceb43a9))
- **PagerDuty Node:** Add support for additional details in incidents ([#3140](https://github.com/n8n-io/n8n/issues/3140)) ([6ca7454](https://github.com/n8n-io/n8n/commit/6ca74540782623ac2301550b62f3382e88b8ed83)), closes [#3094](https://github.com/n8n-io/n8n/issues/3094) [#3105](https://github.com/n8n-io/n8n/issues/3105) [#3112](https://github.com/n8n-io/n8n/issues/3112) [#3078](https://github.com/n8n-io/n8n/issues/3078) [#3133](https://github.com/n8n-io/n8n/issues/3133) [#2918](https://github.com/n8n-io/n8n/issues/2918)
- **Slack Node:** Add blocks to slack message update ([#2182](https://github.com/n8n-io/n8n/issues/2182)) ([b5b6000](https://github.com/n8n-io/n8n/commit/b5b60008d680cd843a418390d451743fc13cac9c)), closes [#1728](https://github.com/n8n-io/n8n/issues/1728)
# [0.172.0](https://github.com/n8n-io/n8n/compare/n8n@0.171.1...n8n@0.172.0) (2022-04-11)
### Bug Fixes
- **Action Network Node:** Fix pagination issue and add credential test ([#3011](https://github.com/n8n-io/n8n/issues/3011)) ([9ef339e](https://github.com/n8n-io/n8n/commit/9ef339e5257e4aa79600554c815cb32fd226753d))
- **core:** Set correct timezone in luxon ([#3115](https://github.com/n8n-io/n8n/issues/3115)) ([3763f81](https://github.com/n8n-io/n8n/commit/3763f815bd14dcc45786efb9b97bb85695bbf734))
- **editor:** Fix i18n issues ([#3072](https://github.com/n8n-io/n8n/issues/3072)) ([4ae0f5b](https://github.com/n8n-io/n8n/commit/4ae0f5b6fba65bfa8f236657d89358f53e465c69)), closes [#3097](https://github.com/n8n-io/n8n/issues/3097)
### Features
- **editor:** Refactor Node Output Panel [PR#3097](https://github.com/PR/issues/3097)
- **Magento 2 Node:** Add credential tests ([#3086](https://github.com/n8n-io/n8n/issues/3086)) ([a11b00a](https://github.com/n8n-io/n8n/commit/a11b00a0374359f0ba8fe91a1df402f32de61b15))
- **PayPal Node:** Add auth test, fix typo and update API URL ([#3084](https://github.com/n8n-io/n8n/issues/3084)) ([c7a037e](https://github.com/n8n-io/n8n/commit/c7a037e9feed94b641e6aab92301c8a647a2934c)), closes [PR#2568](https://github.com/PR/issues/2568)
## [0.171.1](https://github.com/n8n-io/n8n/compare/n8n@0.171.0...n8n@0.171.1) (2022-04-06)
### Bug Fixes
- **core:** Fix issue with current executions not getting displayed ([#3093](https://github.com/n8n-io/n8n/issues/3093)) ([4af5168](https://github.com/n8n-io/n8n/commit/4af5168b3bc92578dc807bab1c11e3d90e151928))
- **core:** Fix issue with falsely skip authorizing ([#3087](https://github.com/n8n-io/n8n/issues/3087)) ([358a683](https://github.com/n8n-io/n8n/commit/358a683f381aa8eb7edd4886d6bdfe7ada61ec35))
- **WooCommerce Node:** Fix pagination issue with "Get All" operation ([#2529](https://github.com/n8n-io/n8n/issues/2529)) ([c2a5e0d](https://github.com/n8n-io/n8n/commit/c2a5e0d1b6a89cb7397b93bbb0f0be9be0df9c86))
# [0.171.0](https://github.com/n8n-io/n8n/compare/n8n@0.170.0...n8n@0.171.0) (2022-04-03)
### Bug Fixes
- **core:** Fix crash on webhook when last node did not return data ([c50d04a](https://github.com/n8n-io/n8n/commit/c50d04af9eb033d82860c336fc7350b5c3f22242))
- **EmailReadImap Node:** Fix issue that crashed process if node was configured wrong ([#3079](https://github.com/n8n-io/n8n/issues/3079)) ([85f15d4](https://github.com/n8n-io/n8n/commit/85f15d49896d876fa3ab84e9fa1846f856851274))
- **Google Tasks Node:** Fix "Show Completed" option and hide title field where not needed ([#2741](https://github.com/n8n-io/n8n/issues/2741)) ([9d703e3](https://github.com/n8n-io/n8n/commit/9d703e366b8e191e0f588469892ebb7b6d03c1d3))
- **NocoDB Node:** Fix pagination ([#3081](https://github.com/n8n-io/n8n/issues/3081)) ([5f44b0d](https://github.com/n8n-io/n8n/commit/5f44b0dad5254fe9f985b314db8f7d43ab48c712))
- **Salesforce Node:** Fix issue that "status" did not get used for Case => Create & Update ([#2212](https://github.com/n8n-io/n8n/issues/2212)) ([1018146](https://github.com/n8n-io/n8n/commit/1018146f21c47eda9f888bd19e92d1106c49267a))
### Features
- **editor:** Add download button for binary data ([#2992](https://github.com/n8n-io/n8n/issues/2992)) ([13a9db7](https://github.com/n8n-io/n8n/commit/13a9db774576a00d4e3ce1988557654d00067073))
- **Emelia Node:** Add Campaign > Duplicate functionality ([#3000](https://github.com/n8n-io/n8n/issues/3000)) ([0b08be1](https://github.com/n8n-io/n8n/commit/0b08be1c0b2961f235fc2446a36afe3995b4d847)), closes [#3065](https://github.com/n8n-io/n8n/issues/3065) [#2741](https://github.com/n8n-io/n8n/issues/2741) [#3075](https://github.com/n8n-io/n8n/issues/3075)
- **FTP Node:** Add option to recursively create directories on rename ([#3001](https://github.com/n8n-io/n8n/issues/3001)) ([39a6f41](https://github.com/n8n-io/n8n/commit/39a6f417203b76cfa2c68816c49e86dc7236aba4))
- **Mautic Node:** Add credential test and allow trailing slash in host ([#3080](https://github.com/n8n-io/n8n/issues/3080)) ([0a75539](https://github.com/n8n-io/n8n/commit/0a75539cc3d696a8946d7db5ff5842ff54835134))
- **Microsoft Teams Node:** Add chat message support ([#2635](https://github.com/n8n-io/n8n/issues/2635)) ([984f62d](https://github.com/n8n-io/n8n/commit/984f62df9ed92cdf297b3b56300c9f23bf128d2d))
- **Mocean Node:** Add "Delivery Report URL" option and credential tests ([#3075](https://github.com/n8n-io/n8n/issues/3075)) ([c89d2b1](https://github.com/n8n-io/n8n/commit/c89d2b10f2461ff8e90209b8f29c222f9430dba5))
- **ServiceNow Node:** Add basicAuth support and fix getColumns loadOptions ([#2712](https://github.com/n8n-io/n8n/issues/2712)) ([2c72584](https://github.com/n8n-io/n8n/commit/2c72584b55521b437baa20ddad7c919807fd9f8f)), closes [#2741](https://github.com/n8n-io/n8n/issues/2741) [#3075](https://github.com/n8n-io/n8n/issues/3075) [#3000](https://github.com/n8n-io/n8n/issues/3000) [#3065](https://github.com/n8n-io/n8n/issues/3065) [#2741](https://github.com/n8n-io/n8n/issues/2741) [#3075](https://github.com/n8n-io/n8n/issues/3075) [#3071](https://github.com/n8n-io/n8n/issues/3071) [#3001](https://github.com/n8n-io/n8n/issues/3001) [#2635](https://github.com/n8n-io/n8n/issues/2635) [#3080](https://github.com/n8n-io/n8n/issues/3080) [#3061](https://github.com/n8n-io/n8n/issues/3061) [#3081](https://github.com/n8n-io/n8n/issues/3081) [#2582](https://github.com/n8n-io/n8n/issues/2582) [#2212](https://github.com/n8n-io/n8n/issues/2212)
- **Strava Node:** Add "Get Streams" operation ([#2582](https://github.com/n8n-io/n8n/issues/2582)) ([6bbb4df](https://github.com/n8n-io/n8n/commit/6bbb4df05925362404f844a23a695f186d27b72e))
# [0.170.0](https://github.com/n8n-io/n8n/compare/n8n@0.169.0...n8n@0.170.0) (2022-03-27)

6452
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -108,8 +108,7 @@ export class Execute extends Command {
if (flags.id) {
// Id of workflow is given
workflowId = flags.id;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
workflowData = await Db.collections.Workflow!.findOne(workflowId);
workflowData = await Db.collections.Workflow.findOne(workflowId);
if (workflowData === undefined) {
console.info(`The workflow with the id "${workflowId}" does not exist.`);
process.exit(1);

View file

@ -297,7 +297,7 @@ export class ExecuteBatch extends Command {
let allWorkflows;
const query = Db.collections.Workflow!.createQueryBuilder('workflows');
const query = Db.collections.Workflow.createQueryBuilder('workflows');
if (ids.length > 0) {
query.andWhere(`workflows.id in (:...ids)`, { ids });

View file

@ -119,13 +119,10 @@ export class ExportCredentialsCommand extends Command {
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const credentials = await Db.collections.Credentials!.find(findQuery);
const credentials = await Db.collections.Credentials.find(findQuery);
if (flags.decrypted) {
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to decrypt the credentials!');
}
for (let i = 0; i < credentials.length; i++) {
const { name, type, nodesAccess, data } = credentials[i];

View file

@ -111,7 +111,7 @@ export class ExportWorkflowsCommand extends Command {
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const workflows = await Db.collections.Workflow!.find(findQuery);
const workflows = await Db.collections.Workflow.find(findQuery);
if (workflows.length === 0) {
throw new Error('No workflows found with specified filters.');

View file

@ -2,7 +2,6 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable no-await-in-loop */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable no-console */
@ -86,9 +85,6 @@ export class ImportCredentialsCommand extends Command {
await UserSettings.prepareUserSettings();
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key found to encrypt the credentials!');
}
if (flags.separate) {
const files = await glob(
@ -150,7 +146,7 @@ export class ImportCredentialsCommand extends Command {
}
private async initOwnerCredentialRole() {
const ownerCredentialRole = await Db.collections.Role!.findOne({
const ownerCredentialRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'credential' },
});
@ -180,11 +176,11 @@ export class ImportCredentialsCommand extends Command {
}
private async getOwner() {
const ownerGlobalRole = await Db.collections.Role!.findOne({
const ownerGlobalRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'global' },
});
const owner = await Db.collections.User!.findOne({ globalRole: ownerGlobalRole });
const owner = await Db.collections.User.findOne({ globalRole: ownerGlobalRole });
if (!owner) {
throw new Error(`Failed to find owner. ${FIX_INSTRUCTION}`);
@ -194,7 +190,7 @@ export class ImportCredentialsCommand extends Command {
}
private async getAssignee(userId: string) {
const user = await Db.collections.User!.findOne(userId);
const user = await Db.collections.User.findOne(userId);
if (!user) {
throw new Error(`Failed to find user with ID ${userId}`);

View file

@ -157,7 +157,7 @@ export class ImportWorkflowsCommand extends Command {
}
private async initOwnerWorkflowRole() {
const ownerWorkflowRole = await Db.collections.Role!.findOne({
const ownerWorkflowRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'workflow' },
});
@ -187,11 +187,11 @@ export class ImportWorkflowsCommand extends Command {
}
private async getOwner() {
const ownerGlobalRole = await Db.collections.Role!.findOne({
const ownerGlobalRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'global' },
});
const owner = await Db.collections.User!.findOne({ globalRole: ownerGlobalRole });
const owner = await Db.collections.User.findOne({ globalRole: ownerGlobalRole });
if (!owner) {
throw new Error(`Failed to find owner. ${FIX_INSTRUCTION}`);
@ -201,7 +201,7 @@ export class ImportWorkflowsCommand extends Command {
}
private async getAssignee(userId: string) {
const user = await Db.collections.User!.findOne(userId);
const user = await Db.collections.User.findOne(userId);
if (!user) {
throw new Error(`Failed to find user with ID ${userId}`);

View file

@ -42,8 +42,7 @@ export class ListWorkflowCommand extends Command {
findQuery.active = flags.active === 'true';
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const workflows = await Db.collections.Workflow!.find(findQuery);
const workflows = await Db.collections.Workflow.find(findQuery);
if (flags.onlyId) {
workflows.forEach((workflow) => console.log(workflow.id));
} else {

View file

@ -33,7 +33,6 @@ import {
} from '../src';
import { getLogger } from '../src/Logger';
import { RESPONSE_ERROR_MESSAGES } from '../src/constants';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires
const open = require('open');
@ -173,9 +172,6 @@ export class Start extends Command {
// If we don't have a JWT secret set, generate
// one based and save to config.
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new Error('Fatal error setting up user management: no encryption key set.');
}
// For a key off every other letter from encryption key
// CAREFUL: do not change this or it breaks all existing tokens.
@ -210,14 +206,10 @@ export class Start extends Command {
// Wait till the database is ready
await startDbInitPromise;
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY);
}
await UserSettings.getEncryptionKey();
// Load settings from database and set them to config.
const databaseSettings = await Db.collections.Settings!.find({ loadOnStartup: true });
const databaseSettings = await Db.collections.Settings.find({ loadOnStartup: true });
databaseSettings.forEach((setting) => {
config.set(setting.key, JSON.parse(setting.value));
});
@ -287,8 +279,8 @@ export class Start extends Command {
if (dbType === 'sqlite') {
const shouldRunVacuum = config.getEnv('database.sqlite.executeVacuumOnStartup');
if (shouldRunVacuum) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-non-null-assertion
await Db.collections.Execution!.query('VACUUM;');
// eslint-disable-next-line @typescript-eslint/no-floating-promises
await Db.collections.Execution.query('VACUUM;');
}
}

View file

@ -72,8 +72,7 @@ export class UpdateWorkflowCommand extends Command {
findQuery.active = true;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await Db.collections.Workflow!.update(findQuery, updateQuery);
await Db.collections.Workflow.update(findQuery, updateQuery);
console.info('Done');
} catch (e) {
console.error('Error updating database. See log messages for details.');

View file

@ -1,5 +1,4 @@
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import Command from '@oclif/command';
import { Not } from 'typeorm';
@ -27,33 +26,33 @@ export class Reset extends Command {
try {
const owner = await this.getInstanceOwner();
const ownerWorkflowRole = await Db.collections.Role!.findOneOrFail({
const ownerWorkflowRole = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
const ownerCredentialRole = await Db.collections.Role!.findOneOrFail({
const ownerCredentialRole = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'credential',
});
await Db.collections.SharedWorkflow!.update(
await Db.collections.SharedWorkflow.update(
{ user: { id: Not(owner.id) }, role: ownerWorkflowRole },
{ user: owner },
);
await Db.collections.SharedCredentials!.update(
await Db.collections.SharedCredentials.update(
{ user: { id: Not(owner.id) }, role: ownerCredentialRole },
{ user: owner },
);
await Db.collections.User!.delete({ id: Not(owner.id) });
await Db.collections.User!.save(Object.assign(owner, this.defaultUserProps));
await Db.collections.User.delete({ id: Not(owner.id) });
await Db.collections.User.save(Object.assign(owner, this.defaultUserProps));
await Db.collections.Settings!.update(
await Db.collections.Settings.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: 'false' },
);
await Db.collections.Settings!.update(
await Db.collections.Settings.update(
{ key: 'userManagement.skipInstanceOwnerSetup' },
{ value: 'false' },
);
@ -68,19 +67,19 @@ export class Reset extends Command {
}
private async getInstanceOwner(): Promise<User> {
const globalRole = await Db.collections.Role!.findOneOrFail({
const globalRole = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'global',
});
const owner = await Db.collections.User!.findOne({ globalRole });
const owner = await Db.collections.User.findOne({ globalRole });
if (owner) return owner;
const user = new User();
await Db.collections.User!.save(Object.assign(user, { ...this.defaultUserProps, globalRole }));
await Db.collections.User.save(Object.assign(user, { ...this.defaultUserProps, globalRole }));
return Db.collections.User!.findOneOrFail({ globalRole });
return Db.collections.User.findOneOrFail({ globalRole });
}
}

View file

@ -119,7 +119,7 @@ export class Worker extends Command {
async runJob(job: Bull.Job, nodeTypes: INodeTypes): Promise<IBullJobResponse> {
const jobData = job.data as IBullJobData;
const executionDb = await Db.collections.Execution!.findOne(jobData.executionId);
const executionDb = await Db.collections.Execution.findOne(jobData.executionId);
if (!executionDb) {
LoggerProxy.error('Worker failed to find execution data in database. Cannot continue.', {
@ -139,7 +139,7 @@ export class Worker extends Command {
const findOptions = {
select: ['id', 'staticData'],
} as FindOneOptions;
const workflowData = await Db.collections.Workflow!.findOne(
const workflowData = await Db.collections.Workflow.findOne(
currentExecutionDb.workflowData.id,
findOptions,
);

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.171.1",
"version": "0.174.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -126,10 +126,10 @@
"lodash.get": "^4.4.2",
"lodash.merge": "^4.6.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.112.0",
"n8n-editor-ui": "~0.138.0",
"n8n-nodes-base": "~0.169.1",
"n8n-workflow": "~0.94.0",
"n8n-core": "~0.115.0",
"n8n-editor-ui": "~0.141.0",
"n8n-nodes-base": "~0.172.0",
"n8n-workflow": "~0.97.0",
"nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",

View file

@ -69,7 +69,7 @@ export class ActiveWorkflowRunner {
// NOTE
// Here I guess we can have a flag on the workflow table like hasTrigger
// so intead of pulling all the active wehhooks just pull the actives that have a trigger
const workflowsData: IWorkflowDb[] = (await Db.collections.Workflow!.find({
const workflowsData: IWorkflowDb[] = (await Db.collections.Workflow.find({
where: { active: true },
relations: ['shared', 'shared.user', 'shared.user.globalRole'],
})) as IWorkflowDb[];
@ -256,7 +256,7 @@ export class ActiveWorkflowRunner {
});
}
const workflowData = await Db.collections.Workflow!.findOne(webhook.workflowId, {
const workflowData = await Db.collections.Workflow.findOne(webhook.workflowId, {
relations: ['shared', 'shared.user', 'shared.user.globalRole'],
});
if (workflowData === undefined) {
@ -332,7 +332,7 @@ export class ActiveWorkflowRunner {
* @memberof ActiveWorkflowRunner
*/
async getWebhookMethods(path: string): Promise<string[]> {
const webhooks = (await Db.collections.Webhook?.find({ webhookPath: path })) as IWebhookDb[];
const webhooks = await Db.collections.Webhook?.find({ webhookPath: path });
// Gather all request methods in string array
const webhookMethods: string[] = webhooks.map((webhook) => webhook.method);
@ -349,12 +349,12 @@ export class ActiveWorkflowRunner {
let activeWorkflows: WorkflowEntity[] = [];
if (!user || user.globalRole.name === 'owner') {
activeWorkflows = await Db.collections.Workflow!.find({
activeWorkflows = await Db.collections.Workflow.find({
select: ['id'],
where: { active: true },
});
} else {
const shared = await Db.collections.SharedWorkflow!.find({
const shared = await Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: whereClause({
user,
@ -379,7 +379,7 @@ export class ActiveWorkflowRunner {
* @memberof ActiveWorkflowRunner
*/
async isActive(id: string): Promise<boolean> {
const workflow = await Db.collections.Workflow!.findOne(id);
const workflow = await Db.collections.Workflow.findOne(id);
return !!workflow?.active;
}
@ -512,7 +512,7 @@ export class ActiveWorkflowRunner {
* @memberof ActiveWorkflowRunner
*/
async removeWorkflowWebhooks(workflowId: string): Promise<void> {
const workflowData = await Db.collections.Workflow!.findOne(workflowId, {
const workflowData = await Db.collections.Workflow.findOne(workflowId, {
relations: ['shared', 'shared.user', 'shared.user.globalRole'],
});
if (workflowData === undefined) {
@ -715,7 +715,7 @@ export class ActiveWorkflowRunner {
let workflowInstance: Workflow;
try {
if (workflowData === undefined) {
workflowData = (await Db.collections.Workflow!.findOne(workflowId, {
workflowData = (await Db.collections.Workflow.findOne(workflowId, {
relations: ['shared', 'shared.user', 'shared.user.globalRole'],
})) as IWorkflowDb;
}

View file

@ -6,7 +6,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { Credentials, NodeExecuteFunctions } from 'n8n-core';
// eslint-disable-next-line import/no-extraneous-dependencies
import { get } from 'lodash';
import { NodeVersionedType } from 'n8n-nodes-base';
import {
@ -235,13 +236,11 @@ export class CredentialsHelper extends ICredentialsHelper {
}
const credential = userId
? await Db.collections
.SharedCredentials!.findOneOrFail({
? await Db.collections.SharedCredentials.findOneOrFail({
relations: ['credentials'],
where: { credentials: { id: nodeCredential.id, type }, user: { id: userId } },
})
.then((shared) => shared.credentials)
: await Db.collections.Credentials!.findOneOrFail({ id: nodeCredential.id, type });
}).then((shared) => shared.credentials)
: await Db.collections.Credentials.findOneOrFail({ id: nodeCredential.id, type });
if (!credential) {
throw new Error(
@ -425,7 +424,7 @@ export class CredentialsHelper extends ICredentialsHelper {
// eslint-disable-next-line @typescript-eslint/await-thenable
const credentials = await this.getCredentials(nodeCredentials, type);
if (Db.collections.Credentials === null) {
if (!Db.isInitialized) {
// The first time executeWorkflow gets called the Database has
// to get initialized first
await Db.init();
@ -445,7 +444,7 @@ export class CredentialsHelper extends ICredentialsHelper {
type,
};
await Db.collections.Credentials!.update(findQuery, newCredentialsData);
await Db.collections.Credentials.update(findQuery, newCredentialsData);
}
getCredentialTestFunction(
@ -635,8 +634,10 @@ export class CredentialsHelper extends ICredentialsHelper {
mode,
);
let response: INodeExecutionData[][] | null | undefined;
try {
await routingNode.runNode(
response = await routingNode.runNode(
inputData,
runIndex,
nodeTypeCopy,
@ -685,6 +686,24 @@ export class CredentialsHelper extends ICredentialsHelper {
};
}
if (
credentialTestFunction.testRequest.rules &&
Array.isArray(credentialTestFunction.testRequest.rules)
) {
// Special testing rules are defined so check all in order
for (const rule of credentialTestFunction.testRequest.rules) {
if (rule.type === 'responseSuccessBody') {
const responseData = response![0][0].json;
if (get(responseData, rule.properties.key) === rule.properties.value) {
return {
status: 'Error',
message: rule.properties.message,
};
}
}
}
}
return {
status: 'OK',
message: 'Connection successful!',
@ -721,8 +740,7 @@ export async function getCredentialForUser(
credentialId: string,
user: User,
): Promise<ICredentialsDb | null> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sharedCredential = await Db.collections.SharedCredentials!.findOne({
const sharedCredential = await Db.collections.SharedCredentials.findOne({
relations: ['credentials'],
where: whereClause({
user,
@ -735,3 +753,13 @@ export async function getCredentialForUser(
return sharedCredential.credentials as ICredentialsDb;
}
/**
* Get a credential without user check
*/
export async function getCredentialWithoutUser(
credentialId: string,
): Promise<ICredentialsDb | undefined> {
const credential = await Db.collections.Credentials.findOne(credentialId);
return credential;
}

View file

@ -1,3 +1,4 @@
/* eslint-disable import/no-mutable-exports */
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
@ -28,18 +29,8 @@ import { postgresMigrations } from './databases/postgresdb/migrations';
import { mysqlMigrations } from './databases/mysqldb/migrations';
import { sqliteMigrations } from './databases/sqlite/migrations';
export const collections: IDatabaseCollections = {
Credentials: null,
Execution: null,
Workflow: null,
Webhook: null,
Tag: null,
Role: null,
User: null,
SharedCredentials: null,
SharedWorkflow: null,
Settings: null,
};
export let isInitialized = false;
export const collections = {} as IDatabaseCollections;
let connection: Connection;
@ -202,5 +193,7 @@ export async function init(
collections.SharedWorkflow = linkRepository(entities.SharedWorkflow);
collections.Settings = linkRepository(entities.Settings);
isInitialized = true;
return collections;
}

View file

@ -165,8 +165,8 @@ export async function generateUniqueName(
const found: Array<WorkflowEntity | ICredentialsDb> =
entityType === 'workflow'
? await Db.collections.Workflow!.find(findConditions)
: await Db.collections.Credentials!.find(findConditions);
? await Db.collections.Workflow.find(findConditions)
: await Db.collections.Credentials.find(findConditions);
// name is unique
if (found.length === 0) {

View file

@ -72,16 +72,16 @@ export interface ICredentialsOverwrite {
}
export interface IDatabaseCollections {
Credentials: Repository<ICredentialsDb> | null;
Execution: Repository<IExecutionFlattedDb> | null;
Workflow: Repository<WorkflowEntity> | null;
Webhook: Repository<IWebhookDb> | null;
Tag: Repository<TagEntity> | null;
Role: Repository<Role> | null;
User: Repository<User> | null;
SharedCredentials: Repository<SharedCredentials> | null;
SharedWorkflow: Repository<SharedWorkflow> | null;
Settings: Repository<Settings> | null;
Credentials: Repository<ICredentialsDb>;
Execution: Repository<IExecutionFlattedDb>;
Workflow: Repository<WorkflowEntity>;
Webhook: Repository<IWebhookDb>;
Tag: Repository<TagEntity>;
Role: Repository<Role>;
User: Repository<User>;
SharedCredentials: Repository<SharedCredentials>;
SharedWorkflow: Repository<SharedWorkflow>;
Settings: Repository<Settings>;
}
export interface IWebhookDb {

View file

@ -84,11 +84,18 @@ export class InternalHooksClass implements IInternalHooksClass {
async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
const notesCount = Object.keys(nodeGraph.notes).length;
const overlappingCount = Object.values(nodeGraph.notes).filter(
(note) => note.overlapping,
).length;
return this.telemetry.track('User saved workflow', {
user_id: userId,
workflow_id: workflow.id,
node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
notes_count_overlapping: overlappingCount,
notes_count_non_overlapping: notesCount - overlappingCount,
version_cli: this.versionCli,
num_tags: workflow.tags?.length ?? 0,
});

View file

@ -30,7 +30,7 @@
/* eslint-disable no-await-in-loop */
import express from 'express';
import { readFileSync } from 'fs';
import { readFileSync, promises } from 'fs';
import { readFile } from 'fs/promises';
import _, { cloneDeep } from 'lodash';
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path';
@ -137,6 +137,8 @@ import {
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
getCredentialForUser,
getCredentialWithoutUser,
} from '.';
import config from '../config';
@ -641,6 +643,10 @@ class App {
normalizeTags: true, // Transform tags to lowercase
explicitArray: false, // Only put properties in array if length > 1
},
verify: (req: express.Request, res: any, buf: any) => {
// @ts-ignore
req.rawBody = buf;
},
}),
);
@ -699,7 +705,7 @@ class App {
// eslint-disable-next-line consistent-return
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (Db.collections.Workflow === null) {
if (!Db.isInitialized) {
const error = new ResponseHelper.ResponseError('Database is not ready!', undefined, 503);
return ResponseHelper.sendErrorResponse(res, error);
}
@ -1531,10 +1537,17 @@ class App {
async (req: express.Request, res: express.Response): Promise<object | void> => {
const packagesPath = pathJoin(__dirname, '..', '..', '..');
const headersPath = pathJoin(packagesPath, 'nodes-base', 'dist', 'nodes', 'headers');
try {
await promises.access(`${headersPath}.js`);
} catch (_) {
return; // no headers available
}
try {
return require(headersPath);
} catch (error) {
res.status(500).send('Failed to find headers file');
res.status(500).send('Failed to load headers file');
}
},
),
@ -1724,13 +1737,11 @@ class App {
);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
500,
);
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(error.message, undefined, 500);
}
const mode: WorkflowExecuteMode = 'internal';
@ -1841,18 +1852,18 @@ class App {
LoggerProxy.error(
'OAuth1 callback failed because of insufficient parameters received',
{
userId: req.user.id,
userId: req.user?.id,
credentialId,
},
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const credential = await getCredentialForUser(credentialId, req.user);
const credential = await getCredentialWithoutUser(credentialId);
if (!credential) {
LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user.id,
userId: req.user?.id,
credentialId,
});
const errorResponse = new ResponseHelper.ResponseError(
@ -1863,15 +1874,11 @@ class App {
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
const errorResponse = new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
503,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(error.message, undefined, 500);
}
const mode: WorkflowExecuteMode = 'internal';
@ -1906,7 +1913,7 @@ class App {
oauthToken = await requestPromise(options);
} catch (error) {
LoggerProxy.error('Unable to fetch tokens for OAuth1 callback', {
userId: req.user.id,
userId: req.user?.id,
credentialId,
});
const errorResponse = new ResponseHelper.ResponseError(
@ -1936,13 +1943,13 @@ class App {
await Db.collections.Credentials!.update(credentialId, newCredentialsData);
LoggerProxy.verbose('OAuth1 callback successful for new credential', {
userId: req.user.id,
userId: req.user?.id,
credentialId,
});
res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html'));
} catch (error) {
LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user.id,
userId: req.user?.id,
credentialId: req.query.cid,
});
// Error response
@ -1983,13 +1990,11 @@ class App {
);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
500,
);
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(error.message, undefined, 500);
}
const mode: WorkflowExecuteMode = 'internal';
@ -2109,11 +2114,11 @@ class App {
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const credential = await getCredentialForUser(state.cid, req.user);
const credential = await getCredentialWithoutUser(state.cid);
if (!credential) {
LoggerProxy.error('OAuth2 callback failed because of insufficient permissions', {
userId: req.user.id,
userId: req.user?.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.ResponseError(
@ -2124,15 +2129,11 @@ class App {
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
const errorResponse = new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
503,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(error.message, undefined, 500);
}
const mode: WorkflowExecuteMode = 'internal';
@ -2158,7 +2159,7 @@ class App {
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
) {
LoggerProxy.debug('OAuth2 callback state is invalid', {
userId: req.user.id,
userId: req.user?.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.ResponseError(
@ -2209,7 +2210,7 @@ class App {
if (oauthToken === undefined) {
LoggerProxy.error('OAuth2 callback failed: unable to get access tokens', {
userId: req.user.id,
userId: req.user?.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.ResponseError(
@ -2243,7 +2244,7 @@ class App {
// Save the credentials in DB
await Db.collections.Credentials!.update(state.cid, newCredentialsData);
LoggerProxy.verbose('OAuth2 callback successful for new credential', {
userId: req.user.id,
userId: req.user?.id,
credentialId: state.cid,
});

View file

@ -15,7 +15,7 @@ import * as config from '../../config';
import { getWebhookBaseUrl } from '../WebhookHelpers';
export async function getWorkflowOwner(workflowId: string | number): Promise<User> {
const sharedWorkflow = await Db.collections.SharedWorkflow!.findOneOrFail({
const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({
where: { workflow: { id: workflowId } },
relations: ['user', 'user.globalRole'],
});
@ -33,7 +33,7 @@ export function isEmailSetUp(): boolean {
}
async function getInstanceOwnerRole(): Promise<Role> {
const ownerRole = await Db.collections.Role!.findOneOrFail({
const ownerRole = await Db.collections.Role.findOneOrFail({
where: {
name: 'owner',
scope: 'global',
@ -45,7 +45,7 @@ async function getInstanceOwnerRole(): Promise<Role> {
export async function getInstanceOwner(): Promise<User> {
const ownerRole = await getInstanceOwnerRole();
const owner = await Db.collections.User!.findOneOrFail({
const owner = await Db.collections.User.findOneOrFail({
relations: ['globalRole'],
where: {
globalRole: ownerRole,
@ -121,7 +121,7 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
}
export async function getUserById(userId: string): Promise<User> {
const user = await Db.collections.User!.findOneOrFail(userId, {
const user = await Db.collections.User.findOneOrFail(userId, {
relations: ['globalRole'],
});
return user;
@ -174,7 +174,7 @@ export async function checkPermissionsForExecution(
}
// Check for the user's permission to all used credentials
const credentialCount = await Db.collections.SharedCredentials!.count({
const credentialCount = await Db.collections.SharedCredentials.count({
where: {
user: { id: userId },
credentials: In(ids),

View file

@ -37,7 +37,7 @@ export function issueJWT(user: User): JwtToken {
}
export async function resolveJwtContent(jwtPayload: JwtPayload): Promise<User> {
const user = await Db.collections.User!.findOne(jwtPayload.id, {
const user = await Db.collections.User.findOne(jwtPayload.id, {
relations: ['globalRole'],
});

View file

@ -12,6 +12,7 @@ import {
} from './Interfaces';
import { NodeMailer } from './NodeMailer';
// TODO: make function fully async (remove sync functions)
async function getTemplate(configKeyName: string, defaultFilename: string) {
const templateOverride = (await GenericHelpers.getConfigValue(
`userManagement.emails.templates.${configKeyName}`,
@ -60,7 +61,6 @@ export class UserManagementMailer {
let template = await getTemplate('invite', 'invite.html');
template = replaceStrings(template, inviteEmailData);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = await this.mailer?.sendMail({
emailRecipients: inviteEmailData.email,
subject: 'You have been invited to n8n',

View file

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Request, Response } from 'express';
import { IDataObject } from 'n8n-workflow';
@ -32,7 +31,7 @@ export function authenticationMethods(this: N8nApp): void {
let user;
try {
user = await Db.collections.User!.findOne(
user = await Db.collections.User.findOne(
{
email: req.body.email,
},
@ -91,7 +90,7 @@ export function authenticationMethods(this: N8nApp): void {
}
try {
user = await Db.collections.User!.findOneOrFail({ relations: ['globalRole'] });
user = await Db.collections.User.findOneOrFail({ relations: ['globalRole'] });
} catch (error) {
throw new Error(
'No users found in database - did you wipe the users table? Create at least one user.',

View file

@ -66,6 +66,8 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint
req.url.startsWith(`/${restEndpoint}/forgot-password`) ||
req.url.startsWith(`/${restEndpoint}/resolve-password-token`) ||
req.url.startsWith(`/${restEndpoint}/change-password`) ||
req.url.startsWith(`/${restEndpoint}/oauth2-credential/callback`) ||
req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`) ||
isAuthExcluded(req.url, ignoredEndpoints)
) {
return next();

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable import/no-cycle */
import express from 'express';
@ -53,7 +52,7 @@ export function meNamespace(this: N8nApp): void {
await validateEntity(newUser);
const user = await Db.collections.User!.save(newUser);
const user = await Db.collections.User.save(newUser);
Logger.info('User updated successfully', { userId: user.id });
@ -99,7 +98,7 @@ export function meNamespace(this: N8nApp): void {
req.user.password = await hashPassword(validPassword);
const user = await Db.collections.User!.save(req.user);
const user = await Db.collections.User.save(req.user);
Logger.info('Password updated successfully', { userId: user.id });
await issueCookie(res, user);
@ -135,7 +134,7 @@ export function meNamespace(this: N8nApp): void {
);
}
await Db.collections.User!.save({
await Db.collections.User.save({
id: req.user.id,
personalizationAnswers,
});

View file

@ -55,7 +55,7 @@ export function ownerNamespace(this: N8nApp): void {
);
}
let owner = await Db.collections.User!.findOne(userId, {
let owner = await Db.collections.User.findOne(userId, {
relations: ['globalRole'],
});
@ -78,11 +78,11 @@ export function ownerNamespace(this: N8nApp): void {
await validateEntity(owner);
owner = await Db.collections.User!.save(owner);
owner = await Db.collections.User.save(owner);
Logger.info('Owner was set up successfully', { userId: req.user.id });
await Db.collections.Settings!.update(
await Db.collections.Settings.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
@ -108,7 +108,7 @@ export function ownerNamespace(this: N8nApp): void {
`/${this.restEndpoint}/owner/skip-setup`,
// eslint-disable-next-line @typescript-eslint/naming-convention
ResponseHelper.send(async (_req: AuthenticatedRequest, _res: express.Response) => {
await Db.collections.Settings!.update(
await Db.collections.Settings.update(
{ key: 'userManagement.skipInstanceOwnerSetup' },
{ value: JSON.stringify(true) },
);

View file

@ -53,7 +53,7 @@ export function passwordResetNamespace(this: N8nApp): void {
}
// User should just be able to reset password if one is already present
const user = await Db.collections.User!.findOne({ email, password: Not(IsNull()) });
const user = await Db.collections.User.findOne({ email, password: Not(IsNull()) });
if (!user || !user.password) {
Logger.debug(
@ -69,7 +69,7 @@ export function passwordResetNamespace(this: N8nApp): void {
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;
await Db.collections.User!.update(id, { resetPasswordToken, resetPasswordTokenExpiration });
await Db.collections.User.update(id, { resetPasswordToken, resetPasswordTokenExpiration });
const baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`);
@ -134,7 +134,7 @@ export function passwordResetNamespace(this: N8nApp): void {
// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await Db.collections.User!.findOne({
const user = await Db.collections.User.findOne({
id,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
@ -187,7 +187,7 @@ export function passwordResetNamespace(this: N8nApp): void {
// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await Db.collections.User!.findOne({
const user = await Db.collections.User.findOne({
id: userId,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
@ -204,7 +204,7 @@ export function passwordResetNamespace(this: N8nApp): void {
throw new ResponseHelper.ResponseError('', undefined, 404);
}
await Db.collections.User!.update(userId, {
await Db.collections.User.update(userId, {
password: await hashPassword(validPassword),
resetPasswordToken: null,
resetPasswordTokenExpiration: null,

View file

@ -1,6 +1,5 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Response } from 'express';
import { In } from 'typeorm';
import validator from 'validator';
@ -106,10 +105,10 @@ export function usersNamespace(this: N8nApp): void {
400,
);
}
createUsers[invite.email] = null;
createUsers[invite.email.toLowerCase()] = null;
});
const role = await Db.collections.Role!.findOne({ scope: 'global', name: 'member' });
const role = await Db.collections.Role.findOne({ scope: 'global', name: 'member' });
if (!role) {
Logger.error(
@ -123,7 +122,7 @@ export function usersNamespace(this: N8nApp): void {
}
// remove/exclude existing users from creation
const existingUsers = await Db.collections.User!.find({
const existingUsers = await Db.collections.User.find({
where: { email: In(Object.keys(createUsers)) },
});
existingUsers.forEach((user) => {
@ -191,6 +190,7 @@ export function usersNamespace(this: N8nApp): void {
};
if (result?.success) {
void InternalHooksManager.getInstance().onUserTransactionalEmail({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user_id: id!,
message_type: 'New user invite',
});
@ -250,7 +250,7 @@ export function usersNamespace(this: N8nApp): void {
}
}
const users = await Db.collections.User!.find({ where: { id: In([inviterId, inviteeId]) } });
const users = await Db.collections.User.find({ where: { id: In([inviterId, inviteeId]) } });
if (users.length !== 2) {
Logger.debug(
@ -318,7 +318,7 @@ export function usersNamespace(this: N8nApp): void {
const validPassword = validatePassword(password);
const users = await Db.collections.User!.find({
const users = await Db.collections.User.find({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
@ -352,7 +352,7 @@ export function usersNamespace(this: N8nApp): void {
invitee.lastName = lastName;
invitee.password = await hashPassword(validPassword);
const updatedUser = await Db.collections.User!.save(invitee);
const updatedUser = await Db.collections.User.save(invitee);
await issueCookie(res, updatedUser);
@ -367,7 +367,7 @@ export function usersNamespace(this: N8nApp): void {
this.app.get(
`/${this.restEndpoint}/users`,
ResponseHelper.send(async () => {
const users = await Db.collections.User!.find({ relations: ['globalRole'] });
const users = await Db.collections.User.find({ relations: ['globalRole'] });
return users.map((user): PublicUser => sanitizeUser(user, ['personalizationAnswers']));
}),
@ -400,7 +400,7 @@ export function usersNamespace(this: N8nApp): void {
);
}
const users = await Db.collections.User!.find({
const users = await Db.collections.User.find({
where: { id: In([transferId, idToDelete]) },
});
@ -434,11 +434,11 @@ export function usersNamespace(this: N8nApp): void {
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
Db.collections.SharedWorkflow!.find({
Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { user: userToDelete },
}),
Db.collections.SharedCredentials!.find({
Db.collections.SharedCredentials.find({
relations: ['credentials'],
where: { user: userToDelete },
}),
@ -496,7 +496,7 @@ export function usersNamespace(this: N8nApp): void {
);
}
const reinvitee = await Db.collections.User!.findOne({ id: idToReinvite });
const reinvitee = await Db.collections.User.findOne({ id: idToReinvite });
if (!reinvitee) {
Logger.debug(

View file

@ -71,7 +71,7 @@ export class WaitTrackerClass {
);
}
const executions = await Db.collections.Execution!.find(findQuery);
const executions = await Db.collections.Execution.find(findQuery);
if (executions.length === 0) {
return;
@ -107,7 +107,7 @@ export class WaitTrackerClass {
}
// Also check in database
const execution = await Db.collections.Execution!.findOne(executionId);
const execution = await Db.collections.Execution.findOne(executionId);
if (execution === undefined || !execution.waitTill) {
throw new Error(`The execution ID "${executionId}" could not be found.`);
@ -127,7 +127,7 @@ export class WaitTrackerClass {
fullExecutionData.stoppedAt = new Date();
fullExecutionData.waitTill = undefined;
await Db.collections.Execution!.update(
await Db.collections.Execution.update(
executionId,
ResponseHelper.flattenExecutionData(fullExecutionData),
);
@ -146,7 +146,7 @@ export class WaitTrackerClass {
(async () => {
// Get the data to execute
const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(executionId);
const fullExecutionDataFlatted = await Db.collections.Execution.findOne(executionId);
if (fullExecutionDataFlatted === undefined) {
throw new Error(`The execution with the id "${executionId}" does not exist.`);

View file

@ -300,7 +300,7 @@ class App {
}
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (Db.collections.Workflow === null) {
if (!Db.isInitialized) {
const error = new ResponseHelper.ResponseError('Database is not ready!', undefined, 503);
return ResponseHelper.sendErrorResponse(res, error);
}

View file

@ -33,8 +33,8 @@ export async function WorkflowCredentials(nodes: INode[]): Promise<IWorkflowCred
}
if (!returnCredentials[type][nodeCredentials.id]) {
// eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion
foundCredentials = await Db.collections.Credentials!.findOne({
// eslint-disable-next-line no-await-in-loop
foundCredentials = await Db.collections.Credentials.findOne({
id: nodeCredentials.id,
type,
});

View file

@ -66,6 +66,7 @@ import {
getWorkflowOwner,
} from './UserManagement/UserManagementHelper';
import { whereClause } from './WorkflowHelpers';
import { RESPONSE_ERROR_MESSAGES } from './constants';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -181,9 +182,7 @@ function pruneExecutionData(this: WorkflowHooks): void {
const utcDate = DateUtils.mixedDateToUtcDatetimeString(date);
// throttle just on success to allow for self healing on failure
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Db.collections
.Execution!.delete({ stoppedAt: LessThanOrEqual(utcDate) })
Db.collections.Execution.delete({ stoppedAt: LessThanOrEqual(utcDate) })
.then((data) =>
setTimeout(() => {
throttling = false;
@ -371,8 +370,7 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx
{ executionId: this.executionId, nodeName },
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const execution = await Db.collections.Execution!.findOne(this.executionId);
const execution = await Db.collections.Execution.findOne(this.executionId);
if (execution === undefined) {
// Something went badly wrong if this happens.
@ -418,8 +416,7 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx
const flattenedExecutionData = ResponseHelper.flattenExecutionData(fullExecutionData);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await Db.collections.Execution!.update(
await Db.collections.Execution.update(
this.executionId,
flattenedExecutionData as IExecutionFlattedDb,
);
@ -503,7 +500,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) {
// Data is always saved, so we remove from database
await Db.collections.Execution!.delete(this.executionId);
await Db.collections.Execution.delete(this.executionId);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionId(
this.executionId,
);
@ -539,7 +536,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
);
}
// Data is always saved, so we remove from database
await Db.collections.Execution!.delete(this.executionId);
await Db.collections.Execution.delete(this.executionId);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionId(
this.executionId,
);
@ -580,7 +577,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
const executionData = ResponseHelper.flattenExecutionData(fullExecutionData);
// Save the Execution in DB
await Db.collections.Execution!.update(
await Db.collections.Execution.update(
this.executionId,
executionData as IExecutionFlattedDb,
);
@ -588,7 +585,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution
// await Db.collections.Execution!.save(executionData as IExecutionFlattedDb);
await Db.collections.Execution!.update(this.retryOf, {
await Db.collections.Execution.update(this.retryOf, {
retrySuccessId: this.executionId,
});
}
@ -693,14 +690,14 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
const executionData = ResponseHelper.flattenExecutionData(fullExecutionData);
// Save the Execution in DB
await Db.collections.Execution!.update(
await Db.collections.Execution.update(
this.executionId,
executionData as IExecutionFlattedDb,
);
if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution
await Db.collections.Execution!.update(this.retryOf, {
await Db.collections.Execution.update(this.retryOf, {
retrySuccessId: this.executionId,
});
}
@ -792,7 +789,7 @@ export async function getWorkflowData(
let workflowData: IWorkflowBase | undefined;
if (workflowInfo.id !== undefined) {
if (Db.collections.Workflow === null) {
if (!Db.isInitialized) {
// The first time executeWorkflow gets called the Database has
// to get initialized first
await Db.init();
@ -804,7 +801,7 @@ export async function getWorkflowData(
relations = relations.filter((relation) => relation !== 'workflow.tags');
}
const shared = await Db.collections.SharedWorkflow!.findOne({
const shared = await Db.collections.SharedWorkflow.findOne({
relations,
where: whereClause({
user,
@ -959,7 +956,7 @@ export async function executeWorkflow(
const executionData = ResponseHelper.flattenExecutionData(fullExecutionData);
await Db.collections.Execution!.update(executionId, executionData as IExecutionFlattedDb);
await Db.collections.Execution.update(executionId, executionData as IExecutionFlattedDb);
throw {
...error,
stack: error.stack,
@ -1034,9 +1031,6 @@ export async function getBase(
const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest');
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to decrypt the credentials!');
}
return {
credentialsHelper: new CredentialsHelper(encryptionKey),

View file

@ -107,9 +107,9 @@ export async function executeErrorWorkflow(
const user = await getWorkflowOwner(workflowErrorData.workflow.id!);
if (user.globalRole.name === 'owner') {
workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) });
workflowData = await Db.collections.Workflow.findOne({ id: Number(workflowId) });
} else {
const sharedWorkflowData = await Db.collections.SharedWorkflow!.findOne({
const sharedWorkflowData = await Db.collections.SharedWorkflow.findOne({
where: {
workflow: { id: workflowId },
user,
@ -121,7 +121,7 @@ export async function executeErrorWorkflow(
}
}
} else {
workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) });
workflowData = await Db.collections.Workflow.findOne({ id: Number(workflowId) });
}
if (workflowData === undefined) {
@ -426,7 +426,7 @@ export async function saveStaticDataById(
workflowId: string | number,
newStaticData: IDataObject,
): Promise<void> {
await Db.collections.Workflow!.update(workflowId, {
await Db.collections.Workflow.update(workflowId, {
staticData: newStaticData,
});
}
@ -440,7 +440,7 @@ export async function saveStaticDataById(
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function getStaticDataById(workflowId: string | number) {
const workflowData = await Db.collections.Workflow!.findOne(workflowId, {
const workflowData = await Db.collections.Workflow.findOne(workflowId, {
select: ['staticData'],
});
@ -586,7 +586,7 @@ export function whereClause({
* Get the IDs of the workflows that have been shared with the user.
*/
export async function getSharedWorkflowIds(user: User): Promise<number[]> {
const sharedWorkflows = await Db.collections.SharedWorkflow!.find({
const sharedWorkflows = await Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: whereClause({
user,

View file

@ -513,7 +513,7 @@ export class WorkflowRunner {
reject(error);
}
const executionDb = (await Db.collections.Execution!.findOne(
const executionDb = (await Db.collections.Execution.findOne(
executionId,
)) as IExecutionFlattedDb;
const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb);
@ -548,7 +548,7 @@ export class WorkflowRunner {
(workflowDidSucceed && saveDataSuccessExecution === 'none') ||
(!workflowDidSucceed && saveDataErrorExecution === 'none')
) {
await Db.collections.Execution!.delete(executionId);
await Db.collections.Execution.delete(executionId);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionId(executionId);
}
// eslint-disable-next-line id-denylist

View file

@ -53,12 +53,12 @@ credentialsController.get(
try {
if (req.user.globalRole.name === 'owner') {
credentials = await Db.collections.Credentials!.find({
credentials = await Db.collections.Credentials.find({
select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'],
where: filter,
});
} else {
const shared = await Db.collections.SharedCredentials!.find({
const shared = await Db.collections.SharedCredentials.find({
where: whereClause({
user: req.user,
entityType: 'credentials',
@ -67,7 +67,7 @@ credentialsController.get(
if (!shared.length) return [];
credentials = await Db.collections.Credentials!.find({
credentials = await Db.collections.Credentials.find({
select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'],
where: {
id: In(shared.map(({ credentialId }) => credentialId)),
@ -115,13 +115,15 @@ credentialsController.post(
ResponseHelper.send(async (req: CredentialRequest.Test): Promise<INodeCredentialTestResult> => {
const { credentials, nodeToTestWith } = req.body;
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
return {
status: 'Error',
message: RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
};
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
500,
);
}
const helper = new CredentialsHelper(encryptionKey);
@ -149,9 +151,10 @@ credentialsController.post(
nodeAccess.date = new Date();
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
@ -175,7 +178,7 @@ credentialsController.post(
await externalHooks.run('credentials.create', [encryptedData]);
const role = await Db.collections.Role!.findOneOrFail({
const role = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'credential',
});
@ -213,7 +216,7 @@ credentialsController.delete(
ResponseHelper.send(async (req: CredentialRequest.Delete) => {
const { id: credentialId } = req.params;
const shared = await Db.collections.SharedCredentials!.findOne({
const shared = await Db.collections.SharedCredentials.findOne({
relations: ['credentials'],
where: whereClause({
user: req.user,
@ -236,7 +239,7 @@ credentialsController.delete(
await externalHooks.run('credentials.delete', [credentialId]);
await Db.collections.Credentials!.remove(shared.credentials);
await Db.collections.Credentials.remove(shared.credentials);
return true;
}),
@ -255,7 +258,7 @@ credentialsController.patch(
await validateEntity(updateData);
const shared = await Db.collections.SharedCredentials!.findOne({
const shared = await Db.collections.SharedCredentials.findOne({
relations: ['credentials'],
where: whereClause({
user: req.user,
@ -285,9 +288,10 @@ credentialsController.patch(
}
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
@ -329,11 +333,11 @@ credentialsController.patch(
await externalHooks.run('credentials.update', [newCredentialData]);
// Update the credentials in DB
await Db.collections.Credentials!.update(credentialId, newCredentialData);
await Db.collections.Credentials.update(credentialId, newCredentialData);
// We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the updated entry.
const responseData = await Db.collections.Credentials!.findOne(credentialId);
const responseData = await Db.collections.Credentials.findOne(credentialId);
if (responseData === undefined) {
throw new ResponseHelper.ResponseError(
@ -363,7 +367,7 @@ credentialsController.get(
ResponseHelper.send(async (req: CredentialRequest.Get) => {
const { id: credentialId } = req.params;
const shared = await Db.collections.SharedCredentials!.findOne({
const shared = await Db.collections.SharedCredentials.findOne({
relations: ['credentials'],
where: whereClause({
user: req.user,
@ -393,9 +397,10 @@ credentialsController.get(
const { data, id, ...rest } = credential;
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,

View file

@ -1,8 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/naming-convention */
import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES } from 'n8n-core';
export const RESPONSE_ERROR_MESSAGES = {
NO_CREDENTIAL: 'Credential not found',
NO_ENCRYPTION_KEY: 'Encryption key missing to decrypt credentials',
NO_ENCRYPTION_KEY: CORE_RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
};
export const AUTH_COOKIE_NAME = 'n8n-auth';

View file

@ -12,6 +12,7 @@ import {
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
BeforeInsert,
} from 'typeorm';
import { IsEmail, IsString, Length } from 'class-validator';
import * as config from '../../../config';
@ -20,7 +21,7 @@ import { Role } from './Role';
import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials';
import { NoXss } from '../utils/customValidators';
import { answersFormatter } from '../utils/transformers';
import { answersFormatter, lowerCaser } from '../utils/transformers';
export const MIN_PASSWORD_LENGTH = 8;
@ -62,7 +63,11 @@ export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 254, nullable: true })
@Column({
length: 254,
nullable: true,
transformer: lowerCaser,
})
@Index({ unique: true })
@IsEmail()
email: string;
@ -119,8 +124,10 @@ export class User {
})
updatedAt: Date;
@BeforeInsert()
@BeforeUpdate()
setUpdateDate(): void {
preUpsertHook(): void {
this.email = this.email?.toLowerCase();
this.updatedAt = new Date();
}

View file

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
export class LowerCaseUserEmail1648740597343 implements MigrationInterface {
name = 'LowerCaseUserEmail1648740597343';
public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(`
UPDATE ${tablePrefix}user
SET email = LOWER(email);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -12,6 +12,7 @@ import { AddWaitColumnId1626183952959 } from './1626183952959-AddWaitColumn';
import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWorkflowCredentials';
import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -28,4 +29,5 @@ export const mysqlMigrations = [
UpdateWorkflowCredentials1630451444017,
AddExecutionEntityIndexes1644424784709,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
];

View file

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
export class LowerCaseUserEmail1648740597343 implements MigrationInterface {
name = 'LowerCaseUserEmail1648740597343';
public async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.get('database.tablePrefix');
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`
UPDATE ${tablePrefix}user
SET email = LOWER(email);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -10,6 +10,7 @@ import { UpdateWorkflowCredentials1630419189837 } from './1630419189837-UpdateWo
import { AddExecutionEntityIndexes1644422880309 } from './1644422880309-AddExecutionEntityIndexes';
import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -24,4 +25,5 @@ export const postgresMigrations = [
AddExecutionEntityIndexes1644422880309,
IncreaseTypeVarcharLimit1646834195327,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
];

View file

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class LowerCaseUserEmail1648740597343 implements MigrationInterface {
name = 'LowerCaseUserEmail1648740597343';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(`
UPDATE "${tablePrefix}user"
SET email = LOWER(email);
`);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -11,6 +11,7 @@ import { AddWaitColumn1621707690587 } from './1621707690587-AddWaitColumn';
import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWorkflowCredentials';
import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddAPIKeyColumn1647888658687 } from './1647888658687-AddAPIKeyColumn';
const sqliteMigrations = [
@ -25,6 +26,7 @@ const sqliteMigrations = [
UpdateWorkflowCredentials1630330987096,
AddExecutionEntityIndexes1644421939510,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddAPIKeyColumn1647888658687,
];

View file

@ -2,8 +2,13 @@
import { IPersonalizationSurveyAnswers } from '../../Interfaces';
export const idStringifier = {
from: (value: number): string | number => (value ? value.toString() : value),
to: (value: string): number | string => (value ? Number(value) : value),
from: (value: number): string | number => (typeof value === 'number' ? value.toString() : value),
to: (value: string): number | string => (typeof value === 'string' ? Number(value) : value),
};
export const lowerCaser = {
from: (value: string): string => value,
to: (value: string): string => (typeof value === 'string' ? value.toLowerCase() : value),
};
/**

View file

@ -255,12 +255,16 @@ export declare namespace OAuthRequest {
{},
{},
{ oauth_verifier: string; oauth_token: string; cid: string }
>;
> & {
user?: User;
};
}
namespace OAuth2Credential {
type Auth = OAuth1Credential.Auth;
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }> & {
user?: User;
};
}
}

View file

@ -1,4 +1,3 @@
import { hashSync, genSaltSync } from 'bcryptjs';
import express from 'express';
import validator from 'validator';
import { v4 as uuid } from 'uuid';
@ -60,10 +59,18 @@ afterAll(async () => {
test('POST /login should log user in', async () => {
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent.post('/login').send({
await Promise.all(
[
{
email: TEST_USER.email,
password: TEST_USER.password,
});
},
{
email: TEST_USER.email.toUpperCase(),
password: TEST_USER.password,
},
].map(async (payload) => {
const response = await authlessAgent.post('/login').send(payload);
expect(response.statusCode).toBe(200);
@ -84,7 +91,6 @@ test('POST /login should log user in', async () => {
expect(lastName).toBe(TEST_USER.lastName);
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
@ -92,6 +98,8 @@ test('POST /login should log user in', async () => {
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
}),
);
});
test('GET /login should receive logged in user', async () => {

View file

@ -7,6 +7,7 @@ import type { CredentialPayload, SaveCredentialFunction } from './shared/types';
import type { Role } from '../../src/databases/entities/Role';
import type { User } from '../../src/databases/entities/User';
import * as testDb from './shared/testDb';
import { RESPONSE_ERROR_MESSAGES } from '../../src/constants';
import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity';
jest.mock('../../src/telemetry');
@ -91,7 +92,7 @@ test('POST /credentials should fail with invalid inputs', async () => {
test('POST /credentials should fail with missing encryption key', async () => {
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockResolvedValue(undefined);
mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY));
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
@ -354,7 +355,8 @@ test('PATCH /credentials/:id should fail if cred not found', async () => {
test('PATCH /credentials/:id should fail with missing encryption key', async () => {
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockResolvedValue(undefined);
mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY));
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
@ -504,7 +506,8 @@ test('GET /credentials/:id should fail with missing encryption key', async () =>
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockResolvedValue(undefined);
mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY));
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)

View file

@ -91,7 +91,7 @@ describe('Owner shell', () => {
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
@ -103,7 +103,7 @@ describe('Owner shell', () => {
const storedOwnerShell = await Db.collections.User!.findOneOrFail(id);
expect(storedOwnerShell.email).toBe(validPayload.email);
expect(storedOwnerShell.email).toBe(validPayload.email.toLowerCase());
expect(storedOwnerShell.firstName).toBe(validPayload.firstName);
expect(storedOwnerShell.lastName).toBe(validPayload.lastName);
}
@ -245,7 +245,7 @@ describe('Member', () => {
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
@ -257,7 +257,7 @@ describe('Member', () => {
const storedMember = await Db.collections.User!.findOneOrFail(id);
expect(storedMember.email).toBe(validPayload.email);
expect(storedMember.email).toBe(validPayload.email.toLowerCase());
expect(storedMember.firstName).toBe(validPayload.firstName);
expect(storedMember.lastName).toBe(validPayload.lastName);
}
@ -400,7 +400,7 @@ describe('Owner', () => {
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(validPayload.email);
expect(email).toBe(validPayload.email.toLowerCase());
expect(firstName).toBe(validPayload.firstName);
expect(lastName).toBe(validPayload.lastName);
expect(personalizationAnswers).toBeNull();
@ -412,19 +412,13 @@ describe('Owner', () => {
const storedOwner = await Db.collections.User!.findOneOrFail(id);
expect(storedOwner.email).toBe(validPayload.email);
expect(storedOwner.email).toBe(validPayload.email.toLowerCase());
expect(storedOwner.firstName).toBe(validPayload.firstName);
expect(storedOwner.lastName).toBe(validPayload.lastName);
}
});
});
const TEST_USER = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
};
const SURVEY = [
'codingSkill',
'companyIndustry',
@ -444,7 +438,7 @@ const VALID_PATCH_ME_PAYLOADS = [
password: randomValidPassword(),
},
{
email: randomEmail(),
email: randomEmail().toUpperCase(),
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),

View file

@ -30,6 +30,12 @@ beforeAll(async () => {
});
beforeEach(async () => {
jest.mock('../../config');
config.set('userManagement.isInstanceOwnerSetUp', false);
});
afterEach(async () => {
await testDb.truncate(['User'], testDbName);
});
@ -88,6 +94,29 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
expect(isInstanceOwnerSetUpSetting).toBe(true);
});
test('POST /owner should create owner with lowercased email', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const newOwnerData = {
email: randomEmail().toUpperCase(),
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const response = await authOwnerAgent.post('/owner').send(newOwnerData);
expect(response.statusCode).toBe(200);
const { id, email } = response.body.data;
expect(email).toBe(newOwnerData.email.toLowerCase());
const storedOwner = await Db.collections.User!.findOneOrFail(id);
expect(storedOwner.email).toBe(newOwnerData.email.toLowerCase());
});
test('POST /owner should fail with invalid inputs', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });

View file

@ -13,12 +13,14 @@ import {
} from './shared/random';
import * as testDb from './shared/testDb';
import type { Role } from '../../src/databases/entities/Role';
import { SMTP_TEST_TIMEOUT } from './shared/constants';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
@ -26,6 +28,7 @@ beforeAll(async () => {
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole();
utils.initTestTelemetry();
utils.initTestLogger();
@ -38,30 +41,40 @@ beforeEach(async () => {
config.set('userManagement.isInstanceOwnerSetUp', true);
config.set('userManagement.emails.mode', '');
jest.setTimeout(30000); // fake SMTP service might be slow
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('POST /forgot-password should send password reset email', async () => {
test(
'POST /forgot-password should send password reset email',
async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const member = await testDb.createUser({
email: 'test@test.com',
globalRole: globalMemberRole,
});
await utils.configureSmtp();
const response = await authlessAgent.post('/forgot-password').send({ email: owner.email });
await Promise.all(
[{ email: owner.email }, { email: member.email.toUpperCase() }].map(async (payload) => {
const response = await authlessAgent.post('/forgot-password').send(payload);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({});
const storedOwner = await Db.collections.User!.findOneOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).toBeDefined();
expect(storedOwner.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000));
});
const user = await Db.collections.User!.findOneOrFail({ email: payload.email });
expect(user.resetPasswordToken).toBeDefined();
expect(user.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000));
}),
);
},
SMTP_TEST_TIMEOUT,
);
test('POST /forgot-password should fail if emailing is not set up', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });

View file

@ -59,3 +59,8 @@ export const BOOTSTRAP_POSTGRES_CONNECTION_NAME: Readonly<string> = 'n8n_bs_post
* for each suite test run.
*/
export const BOOTSTRAP_MYSQL_CONNECTION_NAME: Readonly<string> = 'n8n_bs_mysql';
/**
* Timeout (in milliseconds) to account for fake SMTP service being slow to respond.
*/
export const SMTP_TEST_TIMEOUT = 30_000;

View file

@ -395,10 +395,6 @@ export const getMySqlOptions = ({ name }: { name: string }): ConnectionOptions =
async function encryptCredentialData(credential: CredentialsEntity) {
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY);
}
const coreCredential = new Credentials(
{ id: null, name: credential.name },
credential.type,

View file

@ -4,7 +4,7 @@ import { v4 as uuid } from 'uuid';
import { Db } from '../../src';
import config from '../../config';
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { SMTP_TEST_TIMEOUT, SUCCESS_RESPONSE_BODY } from './shared/constants';
import {
randomEmail,
randomValidPassword,
@ -47,8 +47,6 @@ beforeAll(async () => {
utils.initTestTelemetry();
utils.initTestLogger();
jest.setTimeout(30000); // fake SMTP service might be slow
});
beforeEach(async () => {
@ -481,13 +479,22 @@ test('POST /users should fail if user management is disabled', async () => {
expect(response.statusCode).toBe(500);
});
test('POST /users should email invites and create user shells', async () => {
test(
'POST /users should email invites and create user shells but ignore existing',
async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const memberShell = await testDb.createUserShell(globalMemberRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
await utils.configureSmtp();
const testEmails = [randomEmail(), randomEmail(), randomEmail()];
const testEmails = [
randomEmail(),
randomEmail().toUpperCase(),
memberShell.email,
member.email,
];
const payload = testEmails.map((e) => ({ email: e }));
@ -495,12 +502,17 @@ test('POST /users should email invites and create user shells', async () => {
expect(response.statusCode).toBe(200);
await Promise.all(
response.body.data.map(async ({ user, error }: { user: User; error: Error }) => {
const { id, email: receivedEmail } = user;
for (const {
user: { id, email: receivedEmail },
error,
} of response.body.data) {
expect(validator.isUUID(id)).toBe(true);
expect(testEmails.some((e) => e === receivedEmail)).toBe(true);
expect(id).not.toBe(member.id);
const lowerCasedEmail = receivedEmail.toLowerCase();
expect(receivedEmail).toBe(lowerCasedEmail);
expect(payload.some(({ email }) => email.toLowerCase() === lowerCasedEmail)).toBe(true);
if (error) {
expect(error).toBe('Email could not be sent');
}
@ -514,11 +526,14 @@ test('POST /users should email invites and create user shells', async () => {
expect(personalizationAnswers).toBeNull();
expect(password).toBeNull();
expect(resetPasswordToken).toBeNull();
}),
}
},
SMTP_TEST_TIMEOUT,
);
});
test('POST /users should fail with invalid inputs', async () => {
test(
'POST /users should fail with invalid inputs',
async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
@ -541,9 +556,13 @@ test('POST /users should fail with invalid inputs', async () => {
expect(users.length).toBe(1); // DB unaffected
}),
);
});
},
SMTP_TEST_TIMEOUT,
);
test('POST /users should ignore an empty payload', async () => {
test(
'POST /users should ignore an empty payload',
async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
@ -559,7 +578,9 @@ test('POST /users should ignore an empty payload', async () => {
const users = await Db.collections.User!.find();
expect(users.length).toBe(1);
});
},
SMTP_TEST_TIMEOUT,
);
// TODO: /users/:id/reinvite route tests missing

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "0.112.0",
"version": "0.115.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -52,7 +52,7 @@
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.94.0",
"n8n-workflow": "~0.97.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention */
export const BINARY_ENCODING = 'base64';
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY';
@ -9,3 +10,7 @@ export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKOWN__';
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
export const TUNNEL_SUBDOMAIN_ENV = 'N8N_TUNNEL_SUBDOMAIN';
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z';
export const RESPONSE_ERROR_MESSAGES = {
NO_ENCRYPTION_KEY: 'Encryption key is missing or was not set',
};

View file

@ -874,13 +874,7 @@ export async function requestOAuth2(
oAuth2Options?: IOAuth2Options,
isN8nRequest = false,
) {
const credentials = (await this.getCredentials(
credentialsType,
)) as ICredentialDataDecryptedObject;
if (credentials === undefined) {
throw new Error('No credentials were returned!');
}
const credentials = await this.getCredentials(credentialsType);
if (credentials.oauthTokenData === undefined) {
throw new Error('OAuth credentials not connected!');
@ -997,9 +991,7 @@ export async function requestOAuth1(
| IHttpRequestOptions,
isN8nRequest = false,
) {
const credentials = (await this.getCredentials(
credentialsType,
)) as ICredentialDataDecryptedObject;
const credentials = await this.getCredentials(credentialsType);
if (credentials === undefined) {
throw new Error('No credentials were returned!');
@ -1269,7 +1261,7 @@ export async function getCredentials(
runIndex?: number,
connectionInputData?: INodeExecutionData[],
itemIndex?: number,
): Promise<ICredentialDataDecryptedObject | undefined> {
): Promise<ICredentialDataDecryptedObject> {
// Get the NodeType as it has the information if the credentials are required
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) {
@ -1309,8 +1301,8 @@ export async function getCredentials(
node.parameters,
)
) {
// Credentials should not be displayed so return undefined even if they would be defined
return undefined;
// Credentials should not be displayed even if they would be defined
throw new NodeOperationError(node, 'Credentials not found');
}
}
@ -1327,15 +1319,15 @@ export async function getCredentials(
throw new NodeOperationError(node, `Node does not have any credentials set for "${type}"!`);
}
} else {
// Credentials are not required so resolve with undefined
return undefined;
// Credentials are not required
throw new NodeOperationError(node, 'Node does not require credentials');
}
}
if (fullAccess && (!node.credentials || !node.credentials[type])) {
// Make sure that fullAccess nodes still behave like before that if they
// request access to credentials that are currently not set it returns undefined
return undefined;
throw new NodeOperationError(node, 'Credentials not found');
}
let expressionResolveValues: ICredentialsExpressionResolveValues | undefined;
@ -1605,7 +1597,7 @@ export function getExecutePollFunctions(
__emit: (data: INodeExecutionData[][]): void => {
throw new Error('Overwrite NodeExecuteFunctions.getExecutePullFunctions.__emit function!');
},
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject | undefined> {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject> {
return getCredentials(workflow, node, type, additionalData, mode);
},
getMode: (): WorkflowExecuteMode => {
@ -1759,7 +1751,7 @@ export function getExecuteTriggerFunctions(
emitError: (error: Error): void => {
throw new Error('Overwrite NodeExecuteFunctions.getExecuteTriggerFunctions.emit function!');
},
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject | undefined> {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject> {
return getCredentials(workflow, node, type, additionalData, mode);
},
getNode: () => {
@ -1949,7 +1941,7 @@ export function getExecuteFunctions(
async getCredentials(
type: string,
itemIndex?: number,
): Promise<ICredentialDataDecryptedObject | undefined> {
): Promise<ICredentialDataDecryptedObject> {
return getCredentials(
workflow,
node,
@ -2193,7 +2185,7 @@ export function getExecuteSingleFunctions(
getContext(type: string): IContextObject {
return NodeHelpers.getContext(runExecutionData, type, node);
},
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject | undefined> {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject> {
return getCredentials(
workflow,
node,
@ -2389,7 +2381,7 @@ export function getLoadOptionsFunctions(
): ILoadOptionsFunctions {
return ((workflow: Workflow, node: INode, path: string) => {
const that = {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject | undefined> {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject> {
return getCredentials(workflow, node, type, additionalData, 'internal');
},
getCurrentNodeParameter: (
@ -2533,7 +2525,7 @@ export function getExecuteHookFunctions(
): IHookFunctions {
return ((workflow: Workflow, node: INode) => {
const that = {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject | undefined> {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject> {
return getCredentials(workflow, node, type, additionalData, mode);
},
getMode: (): WorkflowExecuteMode => {
@ -2692,7 +2684,7 @@ export function getExecuteWebhookFunctions(
}
return additionalData.httpRequest.body;
},
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject | undefined> {
async getCredentials(type: string): Promise<ICredentialDataDecryptedObject> {
return getCredentials(workflow, node, type, additionalData, mode);
},
getHeaderData(): object {

View file

@ -10,6 +10,7 @@ import {
ENCRYPTION_KEY_ENV_OVERWRITE,
EXTENSIONS_SUBDIRECTORY,
IUserSettings,
RESPONSE_ERROR_MESSAGES,
USER_FOLDER_ENV_OVERWRITE,
USER_SETTINGS_FILE_NAME,
USER_SETTINGS_SUBFOLDER,
@ -73,19 +74,15 @@ export async function prepareUserSettings(): Promise<IUserSettings> {
* @returns
*/
export async function getEncryptionKey(): Promise<string | undefined> {
export async function getEncryptionKey(): Promise<string> {
if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) {
return process.env[ENCRYPTION_KEY_ENV_OVERWRITE];
return process.env[ENCRYPTION_KEY_ENV_OVERWRITE] as string;
}
const userSettings = await getUserSettings();
if (userSettings === undefined) {
return undefined;
}
if (userSettings.encryptionKey === undefined) {
return undefined;
if (userSettings === undefined || userSettings.encryptionKey === undefined) {
throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY);
}
return userSettings.encryptionKey;

View file

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "0.16.0",
"version": "0.18.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
@ -79,6 +79,7 @@
"vue-loader": "^15.9.7",
"vue-property-decorator": "^9.1.2",
"vue-template-compiler": "^2.6.11",
"vue-typed-mixins": "^0.2.0",
"vue2-boring-avatars": "0.3.4",
"xss": "^1.0.10"
}

View file

@ -69,7 +69,6 @@ export default {
default: false,
},
icon: {
type: String,
},
round: {
type: Boolean,

View file

@ -2,6 +2,7 @@
<component
:is="$options.components.N8nText"
:size="props.size"
:color="props.color"
:compact="true"
>
<component
@ -25,7 +26,6 @@ export default {
},
props: {
icon: {
type: String,
required: true,
},
size: {
@ -36,6 +36,8 @@ export default {
type: Boolean,
default: false,
},
color: {
},
},
};
</script>

View file

@ -40,7 +40,6 @@ export default {
default: false,
},
icon: {
type: String,
required: true,
},
theme: {

View file

@ -11,7 +11,13 @@ const Template = (args, { argTypes }) => ({
N8nInfoTip,
},
template:
'<n8n-info-tip>Need help doing something? <a href="/docs" target="_blank">Open docs</a></n8n-info-tip>',
'<n8n-info-tip v-bind="$props">Need help doing something? <a href="/docs" target="_blank">Open docs</a></n8n-info-tip>',
});
export const InputLabel = Template.bind({});
export const Note = Template.bind({});
export const Tooltip = Template.bind({});
Tooltip.args = {
type: 'tooltip',
tooltipPlacement: 'right',
};

View file

@ -1,23 +1,48 @@
<template functional>
<div :class="$style.infotip">
<component :is="$options.components.N8nIcon" icon="info-circle" /> <span><slot></slot></span>
<template>
<div :class="[$style[theme], $style[type]]">
<n8n-tooltip :placement="tooltipPlacement" :popper-class="$style.tooltipPopper" :disabled="type !== 'tooltip'">
<span>
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle': 'exclamation-triangle'" />
<span v-if="type === 'note'"><slot></slot></span>
</span>
<span v-if="type === 'tooltip'" slot="content"><slot></slot></span>
</n8n-tooltip>
</div>
</template>
<script lang="ts">
import N8nIcon from '../N8nIcon';
import N8nTooltip from '../N8nTooltip';
export default {
name: 'n8n-info-tip',
components: {
N8nIcon,
N8nTooltip,
},
props: {
theme: {
type: String,
default: 'info',
validator: (value: string): boolean =>
['info', 'info-light', 'warning', 'danger'].includes(value),
},
type: {
type: String,
default: 'note',
validator: (value: string): boolean =>
['note', 'tooltip'].includes(value),
},
tooltipPlacement: {
type: String,
default: 'top',
},
},
};
</script>
<style lang="scss" module>
.infotip {
color: var(--color-text-light);
.base {
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
line-height: var(--font-size-s);
@ -27,7 +52,35 @@ export default {
svg {
font-size: var(--font-size-s);
}
}
.note {
composes: base;
svg {
margin-right: var(--spacing-4xs);
}
}
.tooltip {
composes: base;
display: inline-block;
}
.info-light {
color: var(--color-foreground-dark);
}
.info {
color: var(--color-text-light);
}
.warning {
color: var(--color-warning);
}
.danger {
color: var(--color-danger);
}
</style>

View file

@ -1,6 +1,10 @@
<template>
<div>
<div v-if="!loading" ref="editor" :class="$style.markdown" v-html="htmlContent" />
<div
v-if="!loading"
ref="editor"
:class="$style[theme]" v-html="htmlContent"
/>
<div v-else :class="$style.markdown">
<div v-for="(block, index) in loadingBlocks"
:key="index">
@ -59,6 +63,9 @@ export default {
content: {
type: String,
},
withMultiBreaks: {
type: Boolean,
},
images: {
type: Array,
},
@ -75,6 +82,10 @@ export default {
return 3;
},
},
theme: {
type: String,
default: 'markdown',
},
options: {
type: Object,
default() {
@ -106,7 +117,11 @@ export default {
}
const fileIdRegex = new RegExp('fileId:([0-9]+)');
const html = this.md.render(escapeMarkdown(this.content));
let contentToRender = this.content;
if (this.withMultiBreaks) {
contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n');
}
const html = this.md.render(escapeMarkdown(contentToRender));
const safeHtml = xss(html, {
onTagAttr: (tag, name, value, isWhiteAttr) => {
if (tag === 'img' && name === 'src') {
@ -214,6 +229,67 @@ export default {
}
}
.sticky {
color: var(--color-text-dark);
h1, h2, h3, h4 {
margin-bottom: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
}
h1 {
font-size: 36px;
}
h2 {
font-size: 24px;
}
h3, h4, h5, h6 {
font-size: var(--font-size-m);
}
p {
margin-bottom: var(--spacing-2xs);
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-loose);
}
ul, ol {
margin-bottom: var(--spacing-2xs);
padding-left: var(--spacing-m);
li {
margin-top: 0.25em;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-regular);
}
}
code {
background-color: var(--color-background-base);
padding: 0 var(--spacing-4xs);
color: var(--color-secondary);
}
pre > code,li > code, p > code {
color: var(--color-secondary);
}
a {
&:hover {
text-decoration: underline;
}
}
img {
object-fit: contain;
}
}
.spacer {
margin: var(--spacing-2xl);
}

View file

@ -0,0 +1,66 @@
<template>
<label role="radio" tabindex="-1" :class="$style.container" aria-checked="true">
<input type="radio" tabindex="-1" autocomplete="off" :class="$style.input" :value="value">
<div :class="{[$style.button]: true, [$style.active]: active}" @click="$emit('click')">{{ label }}</div>
</label>
</template>
<script lang="ts">
export default {
name: 'n8n-radio-button',
props: {
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
},
};
</script>
<style lang="scss" module>
.container {
display: inline-block;
outline: 0;
position: relative;
&:hover {
.button:not(.active) {
color: var(--color-primary);
}
}
}
.input {
opacity: 0;
outline: 0;
z-index: -1;
position: absolute;
}
.button {
border-radius: 0;
padding: 0 var(--spacing-s);
display: flex;
align-items: center;
height: 26px;
font-size: var(--font-size-2xs);
border-radius: var(--border-radius-base);
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
cursor: pointer;
transition: background-color 0.2s ease;
}
.active {
background-color: var(--color-foreground-xlight);
color: var(--color-text-dark);
}
</style>

View file

@ -0,0 +1,51 @@
import N8nRadioButtons from './RadioButtons.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/RadioButtons',
component: N8nRadioButtons,
argTypes: {
},
parameters: {
backgrounds: { default: '--color-background-xlight' },
},
};
const methods = {
onInput: action('input'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nRadioButtons,
},
template:
`<n8n-radio-buttons v-model="val" v-bind="$props" @input="onInput">
</n8n-radio-buttons>`,
methods,
data() {
return {
val: '',
};
},
});
export const Example = Template.bind({});
Example.args = {
options: [
{
label: 'Test',
value: 'test',
},
{
label: 'World',
value: 'world',
},
{
label: 'Hello',
value: 'hello',
},
],
};

View file

@ -0,0 +1,49 @@
<template>
<div role="radiogroup" :class="$style.radioGroup">
<RadioButton
v-for="option in options"
:key="option.value"
v-bind="option"
:active="value === option.value"
@click="(e) => onClick(option.value, e)"
/>
</div>
</template>
<script lang="ts">
import RadioButton from './RadioButton';
export default {
name: 'n8n-radio-buttons',
props: {
value: {
type: String,
},
options: {
},
},
components: {
RadioButton,
},
methods: {
onClick(value) {
this.$emit('input', value);
},
},
};
</script>
<style lang="scss" module>
.radioGroup {
display: inline-flex;
line-height: 1;
vertical-align: middle;
font-size: 0;
background-color: var(--color-foreground-base);
padding: var(--spacing-5xs);
border-radius: var(--border-radius-base);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nRadioButtons from './RadioButtons.vue';
export default N8nRadioButtons;

View file

@ -1,4 +1,8 @@
<template functional>
<div :class="{[$style.container]: true, [$style.withPrepend]: !!$slots.prepend}">
<div v-if="$slots.prepend" :class="$style.prepend">
<slot name="prepend" />
</div>
<component
:is="$options.components.ElSelect"
v-bind="props"
@ -19,6 +23,7 @@
<slot></slot>
</template>
</component>
</div>
</template>
<script lang="ts">
@ -121,4 +126,30 @@ export default {
overflow-x: hidden;
}
}
.container {
display: inline-flex;
width: 100%;
}
.withPrepend {
input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.prepend {
font-size: var(--font-size-2xs);
border: var(--border-base);
border-right: none;
display: flex;
align-items: center;
padding: 0 var(--spacing-3xs);
background-color: var(--color-background-light);
border-bottom-left-radius: var(--input-border-radius, var(--border-radius-base));
border-top-left-radius: var(--input-border-radius, var(--border-radius-base));
color: var(--color-text-base);
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,238 @@
<template>
<div :class="$style.resize">
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="right" :class="[$style.resizer, $style.right]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="left" :class="[$style.resizer, $style.left]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top" :class="[$style.resizer, $style.top]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom" :class="[$style.resizer, $style.bottom]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top-left" :class="[$style.resizer, $style.topLeft]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top-right" :class="[$style.resizer, $style.topRight]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom-left" :class="[$style.resizer, $style.bottomLeft]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom-right" :class="[$style.resizer, $style.bottomRight]" />
<slot></slot>
</div>
</template>
<script lang="ts">
const cursorMap = {
right: 'ew-resize',
top: 'ns-resize',
bottom: 'ns-resize',
left: 'ew-resize',
'top-left': 'nw-resize',
'top-right' : 'ne-resize',
'bottom-left': 'sw-resize',
'bottom-right': 'se-resize',
};
function closestNumber(value: number, divisor: number): number {
let q = parseInt(value / divisor);
let n1 = divisor * q;
let n2 = (value * divisor) > 0 ?
(divisor * (q + 1)) : (divisor * (q - 1));
if (Math.abs(value - n1) < Math.abs(value - n2))
return n1;
return n2;
}
function getSize(delta, min, virtual, gridSize): number {
const target = closestNumber(virtual, gridSize);
if (target >= min && virtual > 0) {
return target;
}
return min;
};
export default {
name: 'n8n-resize',
props: {
isResizingEnabled: {
type: Boolean,
default: true,
},
height: {
type: Number,
},
width: {
type: Number,
},
minHeight: {
type: Number,
},
minWidth: {
type: Number,
},
scale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
},
},
data() {
return {
dir: '',
dHeight: 0,
dWidth: 0,
vHeight: 0,
vWidth: 0,
x: 0,
y: 0,
};
},
methods: {
resizerMove(e) {
e.preventDefault();
e.stopPropagation();
const targetResizer = e.target;
this.dir = targetResizer.dataset.dir;
document.body.style.cursor = cursorMap[this.dir];
this.x = e.pageX;
this.y = e.pageY;
this.dWidth = 0;
this.dHeight = 0;
this.vHeight = this.height;
this.vWidth = this.width;
window.addEventListener('mousemove', this.mouseMove);
window.addEventListener('mouseup', this.mouseUp);
this.$emit('resizestart');
},
mouseMove(e) {
e.preventDefault();
e.stopPropagation();
let dWidth = 0;
let dHeight = 0;
let top = false;
let left = false;
if (this.dir.includes('right')) {
dWidth = e.pageX - this.x;
}
if (this.dir.includes('left')) {
dWidth = this.x - e.pageX;
left = true;
}
if (this.dir.includes('top')) {
dHeight = this.y - e.pageY;
top = true;
}
if (this.dir.includes('bottom')) {
dHeight = e.pageY - this.y;
}
const deltaWidth = (dWidth - this.dWidth) / this.scale;
const deltaHeight = (dHeight - this.dHeight) / this.scale;
this.vHeight = this.vHeight + deltaHeight;
this.vWidth = this.vWidth + deltaWidth;
const height = getSize(deltaHeight, this.minHeight, this.vHeight, this.gridSize);
const width = getSize(deltaWidth, this.minWidth, this.vWidth, this.gridSize);
const dX = left && width !== this.width ? -1 * (width - this.width) : 0;
const dY = top && height !== this.height ? -1 * (height - this.height): 0;
this.$emit('resize', { height, width, dX, dY });
this.dHeight = dHeight;
this.dWidth = dWidth;
},
mouseUp(e) {
e.preventDefault();
e.stopPropagation();
this.$emit('resizeend');
window.removeEventListener('mousemove', this.mouseMove);
window.removeEventListener('mouseup', this.mouseUp);
document.body.style.cursor = 'unset';
this.dir = '';
},
},
};
</script>
<style lang="scss" module>
.resize {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
}
.resizer {
position: absolute;
z-index: 2;
}
.right {
width: 12px;
height: 100%;
top: -2px;
right: -2px;
cursor: ew-resize;
}
.top {
width: 100%;
height: 12px;
top: -2px;
left: -2px;
cursor: ns-resize;
}
.bottom {
width: 100%;
height: 12px;
bottom: -2px;
left: -2px;
cursor: ns-resize;
}
.left {
width: 12px;
height: 100%;
top: -2px;
left: -2px;
cursor: ew-resize;
}
.topLeft {
width: 12px;
height: 12px;
top: -3px;
left: -3px;
cursor: nw-resize;
z-index: 3;
}
.topRight {
width: 12px;
height: 12px;
top: -3px;
right: -3px;
cursor: ne-resize;
z-index: 3;
}
.bottomLeft {
width: 12px;
height: 12px;
bottom: -3px;
left: -3px;
cursor: sw-resize;
z-index: 3;
}
.bottomRight {
width: 12px;
height: 12px;
bottom: -3px;
right: -3px;
cursor: se-resize;
z-index: 3;
}
</style>

View file

@ -0,0 +1,67 @@
import { action } from '@storybook/addon-actions';
import N8nSticky from './Sticky.vue';
export default {
title: 'Atoms/Sticky',
component: N8nSticky,
argTypes: {
content: {
control: {
control: 'text',
},
},
height: {
control: {
control: 'number',
},
},
minHeight: {
control: {
control: 'number',
},
},
minWidth: {
control: {
control: 'number',
},
},
readOnly: {
control: {
control: 'Boolean',
},
},
width: {
control: {
control: 'number',
},
},
},
};
const methods = {
onInput: action('input'),
onResize: action('resize'),
onResizeEnd: action('resizeend'),
onResizeStart: action('resizestart'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nSticky,
},
template:
'<n8n-sticky v-bind="$props" @resize="onResize" @resizeend="onResizeEnd" @resizeStart="onResizeStart" @input="onInput"></n8n-sticky>',
methods,
});
export const Sticky = Template.bind({});
Sticky.args = {
height: 160,
width: 150,
content: `## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)`,
defaultText: `## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)`,
minHeight: 80,
minWidth: 150,
readOnly: false,
};

View file

@ -0,0 +1,253 @@
<template>
<div
:class="{[$style.sticky]: true, [$style.clickable]: !isResizing}"
:style="styles"
@keydown.prevent
>
<resize
:isResizingEnabled="!readOnly"
:height="height"
:width="width"
:minHeight="minHeight"
:minWidth="minWidth"
:scale="scale"
:gridSize="gridSize"
@resizeend="onResizeEnd"
@resize="onResize"
@resizestart="onResizeStart"
>
<template>
<div
v-show="!editMode"
:class="$style.wrapper"
@dblclick.stop="onDoubleClick"
>
<n8n-markdown
theme="sticky"
:content="content"
:withMultiBreaks="true"
/>
</div>
<div
v-show="editMode"
@click.stop
@mousedown.stop
@mouseup.stop
@keydown.esc="onInputBlur"
@keydown.stop
@wheel.stop
class="sticky-textarea"
:class="{'full-height': !shouldShowFooter}"
>
<n8n-input
:value="content"
type="textarea"
:rows="5"
@blur="onInputBlur"
@input="onInput"
ref="input"
/>
</div>
<div v-if="editMode && shouldShowFooter" :class="$style.footer">
<n8n-text
size="xsmall"
aligh="right"
>
<span v-html="t('sticky.markdownHint')"></span>
</n8n-text>
</div>
</template>
</resize>
</div>
</template>
<script lang="ts">
import N8nInput from '../N8nInput';
import N8nMarkdown from '../N8nMarkdown';
import Resize from './Resize';
import N8nText from '../N8nText';
import Locale from '../../mixins/locale';
import mixins from 'vue-typed-mixins';
export default mixins(Locale).extend({
name: 'n8n-sticky',
props: {
content: {
type: String,
},
height: {
type: Number,
default: 180,
},
width: {
type: Number,
default: 240,
},
minHeight: {
type: Number,
default: 80,
},
minWidth: {
type: Number,
default: 150,
},
scale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
default: 20,
},
id: {
type: String,
default: '0',
},
defaultText: {
type: String,
},
editMode: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
},
components: {
N8nInput,
N8nMarkdown,
Resize,
N8nText,
},
data() {
return {
isResizing: false,
};
},
computed: {
resHeight(): number {
if (this.height < this.minHeight) {
return this.minHeight;
}
return this.height;
},
resWidth(): number {
if (this.width < this.minWidth) {
return this.minWidth;
}
return this.width;
},
styles() {
return {
height: this.resHeight + 'px',
width: this.resWidth + 'px',
};
},
shouldShowFooter() {
return this.resHeight > 100 && this.resWidth > 155;
},
},
methods: {
onDoubleClick() {
if (!this.readOnly) {
this.$emit('edit', true);
}
},
onInputBlur(value) {
if (!this.isResizing) {
this.$emit('edit', false);
}
},
onInput(value: string) {
this.$emit('input', value);
},
onResize(values) {
this.$emit('resize', values);
},
onResizeEnd(resizeEnd) {
this.isResizing = false;
this.$emit('resizeend', resizeEnd);
},
onResizeStart() {
this.isResizing = true;
this.$emit('resizestart');
},
},
watch: {
editMode(newMode, prevMode) {
setTimeout(() => {
if (newMode && !prevMode && this.$refs.input && this.$refs.input.$refs && this.$refs.input.$refs.textarea) {
const textarea = this.$refs.input.$refs.textarea;
if (this.defaultText === this.content) {
textarea.select();
}
textarea.focus();
}
}, 100);
},
},
});
</script>
<style lang="scss" module>
.sticky {
position: absolute;
background-color: var(--color-sticky-default-background);
border: 1px solid var(--color-sticky-default-border);
border-radius: var(--border-radius-base);
}
.clickable {
cursor: pointer;
}
.wrapper {
width: 100%;
height: 100%;
position: absolute;
padding: var(--spacing-2xs) var(--spacing-xs) 0;
overflow: hidden;
&::after {
content: '';
width: 100%;
height: 24px;
left: 0;
bottom: 0;
position: absolute;
background: linear-gradient(180deg, var(--color-sticky-default-background), #fff5d600 0.01%, var(--color-sticky-default-background));
border-radius: var(--border-radius-base);
}
}
.footer {
padding: var(--spacing-5xs) var(--spacing-2xs) 0 var(--spacing-2xs);
display: flex;
justify-content: flex-end;
}
</style>
<style lang="scss">
.sticky-textarea {
height: calc(100% - var(--spacing-l));
padding: var(--spacing-2xs) var(--spacing-2xs) 0 var(--spacing-2xs);
cursor: default;
.el-textarea {
height: 100%;
.el-textarea__inner {
height: 100%;
resize: unset;
}
}
}
.full-height {
height: calc(100% - var(--spacing-2xs));
}
</style>

View file

@ -0,0 +1,3 @@
import Sticky from './Sticky.vue';
export default Sticky;

View file

@ -0,0 +1,54 @@
import N8nTabs from './Tabs.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/Tabs',
component: N8nTabs,
argTypes: {
},
parameters: {
backgrounds: { default: '--color-background-xlight' },
},
};
const methods = {
onInput: action('input'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nTabs,
},
template:
`<n8n-tabs v-model="val" v-bind="$props" @input="onInput">
</n8n-tabs>`,
methods,
data() {
return {
val: '',
};
},
});
export const Example = Template.bind({});
Example.args = {
options: [
{
label: 'Test',
value: 'test',
},
{
label: 'Github',
value: 'github',
href: 'https://github.com/',
},
{
label: 'Settings',
value: 'settings',
icon: 'cog',
align: 'right',
},
],
};

View file

@ -0,0 +1,192 @@
<template>
<div :class="$style.container">
<div :class="$style.back" v-if="scrollPosition > 0" @click="scrollLeft">
<n8n-icon icon="chevron-left" size="small" />
</div>
<div :class="$style.next" v-if="canScrollRight" @click="scrollRight">
<n8n-icon icon="chevron-right" size="small" />
</div>
<div ref="tabs" :class="$style.tabs">
<div v-for="option in options" :key="option.value" :class="{ [$style.alignRight]: option.align === 'right' }">
<a
v-if="option.href"
target="_blank"
:href="option.href"
:class="[$style.link, $style.tab]"
@click="handleTabClick"
>
<div>
{{ option.label }}
<span :class="$style.external"><n8n-icon icon="external-link-alt" size="small" /></span>
</div>
</a>
<div
v-else
:class="{ [$style.tab]: true, [$style.activeTab]: value === option.value }"
@click="() => handleTabClick(option.value)"
>
<n8n-icon v-if="option.icon" :icon="option.icon" size="medium" />
<span v-if="option.label">{{ option.label }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nIcon from '../N8nIcon';
export default Vue.extend({
name: 'N8nTabs',
components: {
N8nIcon,
},
mounted() {
const container = this.$refs.tabs;
if (container) {
container.addEventListener('scroll', (e) => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.scrollPosition = e.srcElement.scrollLeft;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
});
this.resizeObserver = new ResizeObserver(() => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
});
this.resizeObserver.observe(container);
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
}
},
destroyed() {
this.resizeObserver.disconnect();
},
data() {
return {
scrollPosition: 0,
canScrollRight: false,
resizeObserver: null,
};
},
props: {
value: {
},
options: {
},
},
methods: {
handleTabClick(tab: string) {
this.$emit('input', tab);
},
scrollLeft() {
this.scroll(-50);
},
scrollRight() {
this.scroll(50);
},
scroll(left: number) {
const container = this.$refs.tabs;
if (container) {
container.scrollBy({ left, top: 0, behavior: 'smooth' });
}
},
},
});
</script>
<style lang="scss" module>
.container {
position: relative;
height: 24px;
min-height: 24px;
width: 100%;
}
.tabs {
color: var(--color-text-base);
font-weight: var(--font-weight-bold);
display: flex;
width: 100%;
position: absolute;
overflow-x: scroll;
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.tab {
display: block;
padding: 0 var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
padding-bottom: var(--spacing-2xs);
font-size: var(--font-size-s);
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--color-primary);
}
}
.activeTab {
color: var(--color-primary);
border-bottom: var(--color-primary) 2px solid;
}
.alignRight {
margin-left: auto;
}
.link {
cursor: pointer;
color: var(--color-text-base);
&:hover {
color: var(--color-primary);
.external {
display: inline-block;
}
}
}
.external {
display: none;
}
.button {
position: absolute;
background-color: var(--color-background-light);
z-index: 1;
height: 24px;
width: 10px;
display: flex;
align-items: center;
font-weight: var(--font-weight-bold);
}
.back {
composes: tab;
composes: button;
left: 0;
}
.next {
composes: tab;
composes: button;
right: 0;
}
</style>

View file

@ -0,0 +1,3 @@
import N8nTabs from './Tabs.vue';
export default N8nTabs;

View file

@ -12,6 +12,7 @@ import Switch from 'element-ui/lib/switch';
import Select from 'element-ui/lib/select';
import Option from 'element-ui/lib/option';
import OptionGroup from 'element-ui/lib/option-group';
import Pagination from 'element-ui/lib/pagination';
import ButtonGroup from 'element-ui/lib/button-group';
import Table from 'element-ui/lib/table';
import TableColumn from 'element-ui/lib/table-column';
@ -29,6 +30,7 @@ import Loading from 'element-ui/lib/loading';
import MessageBox from 'element-ui/lib/message-box';
import Message from 'element-ui/lib/message';
import Notification from 'element-ui/lib/notification';
import Popover from 'element-ui/lib/popover';
import CollapseTransition from 'element-ui/lib/transitions/collapse-transition';
import N8nActionBox from './N8nActionBox';
@ -52,10 +54,13 @@ import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem';
import N8nLink from './N8nLink';
import N8nOption from './N8nOption';
import N8nRadioButtons from './N8nRadioButtons';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nSticky from './N8nSticky';
import N8nSquareButton from './N8nSquareButton';
import N8nTags from './N8nTags';
import N8nTabs from './N8nTabs';
import N8nTag from './N8nTag';
import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip';
@ -86,9 +91,12 @@ export {
N8nMenu,
N8nMenuItem,
N8nOption,
N8nRadioButtons,
N8nSelect,
N8nSpinner,
N8nSticky,
N8nSquareButton,
N8nTabs,
N8nTags,
N8nTag,
N8nText,
@ -110,6 +118,7 @@ export {
Select,
Option,
OptionGroup,
Pagination,
ButtonGroup,
Table,
TableColumn,
@ -128,6 +137,7 @@ export {
Message,
Notification,
CollapseTransition,
Popover,
locale,
};

View file

@ -1,5 +1,5 @@
export function addTargetBlank(html: string) {
return html.includes('href=')
return html && html.includes('href=')
? html.replace(/href=/g, 'target="_blank" href=')
: html;
}

View file

@ -16,4 +16,5 @@ export default {
config.minimum > 1 ? 's' : ''
}`),
"formInput.validator.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
"sticky.markdownHint": `You can style with <a href="https://docs.n8n.io/workflows/sticky-notes/" target="_blank">Markdown</a>`,
};

View file

@ -154,3 +154,29 @@ import ColorCircles from './ColorCircles.vue';
}}
</Story>
</Canvas>
<Canvas>
<Story name="json">
{{
template: `<color-circles :colors="['--color-json-default', '--color-json-null', '--color-json-boolean', '--color-json-number', '--color-json-string', '--color-json-key', '--color-json-brackets', '--color-json-brackets-hover', '--color-json-line', '--color-json-highlight']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>
## Sticky
<Canvas>
<Story name="sticky">
{{
template: `<color-circles :colors="['--color-sticky-default-background', '--color-sticky-default-border']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>

View file

@ -3,6 +3,7 @@ export const escapeMarkdown = (html: string | undefined): string => {
return '';
}
const escaped = html.replace(/</g, "&lt;").replace(/>/g, "&gt;");
// unescape greater than quotes at start of line
const withQuotes = escaped.replace(/^((\s)*(&gt;)+)+\s*/gm, (matches) => {
return matches.replace(/&gt;/g, '>');

View file

@ -70,13 +70,6 @@
var(--color-secondary-l)
);
--color-secondary-tint-1-l: 92%;
--color-secondary-tint-1: hsl(
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-tint-1-l)
);
--color-success-h: 150.4;
--color-success-s: 60%;
--color-success-l: 40.4%;
@ -285,8 +278,8 @@
);
--color-background-light-h: 220;
--color-background-light-s: 27.3%;
--color-background-light-l: 97.8%;
--color-background-light-s: 60%;
--color-background-light-l: 99%;
--color-background-light: hsl(
var(--color-background-light-h),
var(--color-background-light-s),
@ -329,6 +322,35 @@
var(--color-canvas-background-l)
);
--color-json-default: #5045A1;
--color-json-null: var(--color-danger);
--color-json-boolean: #1d8ce0;
--color-json-number: #1d8ce0;
--color-json-string: #5045A1;
--color-json-key: var(--color-text-dark);
--color-json-brackets: var(--color-text-dark);
--color-json-brackets-hover: #1890ff;
--color-json-line: #bfcbd9;
--color-json-highlight: #E2E5EE;
--color-sticky-default-background-h: 46;
--color-sticky-default-background-s: 100%;
--color-sticky-default-background-l: 92%;
--color-sticky-default-background: hsl(
var(--color-sticky-default-background-h),
var(--color-sticky-default-background-s),
var(--color-sticky-default-background-l)
);
--color-sticky-default-border-h: 43;
--color-sticky-default-border-s: 75%;
--color-sticky-default-border-l: 80%;
--color-sticky-default-border: hsl(
var(--color-sticky-default-border-h),
var(--color-sticky-default-border-s),
var(--color-sticky-default-border-l)
);
--border-radius-xlarge: 12px;
--border-radius-large: 8px;
--border-radius-base: 4px;

View file

@ -777,7 +777,7 @@ $table-fixed-box-shadow: 0 0 10px rgba(0, 0, 0, 0.12);
/* Pagination
-------------------------- */
/// fontSize||Font|1
$pagination-font-size: 13px;
$pagination-font-size: var(--font-size-2xs);
/// color||Color|0
$pagination-background-color: $color-white;
/// color||Color|0
@ -799,7 +799,7 @@ $pagination-hover-color: var(--color-primary);
/* Popup
-------------------------- */
/// color||Color|0
$popup-modal-background-color: hsla(247,14%, 70%, 0.75);
$popup-modal-background-color: hsla(247,14%, 30%, 0.75);
/// opacity||Other|1
$popup-modal-opacity: 0.65;

View file

@ -2,7 +2,7 @@
@use "./tokens.dark.scss" as dark;
@use "./reset.scss";
@use "./base.scss";
// @use "./pagination.scss";
@use "./pagination.scss";
@use "./dialog.scss";
// @use "./autocomplete.scss";
@use "./dropdown.scss";

View file

@ -193,11 +193,11 @@
.btn-prev,
.btn-next,
.el-pager li {
margin: 0 5px;
background-color: var.$color-info-lighter;
color: var(--color-text-dark);
margin: 0 1px;
color: var(--color-text-base);
min-width: 30px;
border-radius: 2px;
border-radius: var(--border-radius-base);
border: 1px solid transparent;
&.disabled {
color: var(--color-text-lighter);
@ -215,12 +215,14 @@
.el-pager li:not(.disabled) {
&:hover {
color: var.$pagination-hover-color;
color: var(--color-primary);
background-color: var(--color-background-xlight);
border: 1px solid var(--color-foreground-base);
}
&.active {
background-color: var(--color-primary);
color: var.$color-white;
border: 1px solid var(--color-primary);
color: var(--color-primary);
}
}
@ -252,7 +254,6 @@
padding: 0 4px;
background: var.$pagination-background-color;
vertical-align: top;
display: inline-block;
font-size: var.$pagination-font-size;
min-width: var.$pagination-button-width;
height: var.$pagination-button-height;
@ -261,6 +262,9 @@
box-sizing: border-box;
text-align: center;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
&.btn-quicknext,
&.btn-quickprev {

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.138.0",
"version": "0.141.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -25,14 +25,15 @@
},
"dependencies": {
"@fontsource/open-sans": "^4.5.0",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"luxon": "^2.3.0",
"n8n-design-system": "~0.16.0",
"monaco-editor": "^0.29.1",
"n8n-design-system": "~0.18.0",
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2",
"vue2-boring-avatars": "0.3.4",
"vue-i18n": "^8.26.7",
"vue2-boring-avatars": "0.3.4",
"xss": "^1.0.10"
},
"devDependencies": {
@ -77,7 +78,7 @@
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"n8n-workflow": "~0.94.0",
"n8n-workflow": "~0.97.0",
"monaco-editor-webpack-plugin": "^5.0.0",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",

View file

@ -28,10 +28,13 @@ import { showMessage } from './components/mixins/showMessage';
import { IUser } from './Interface';
import { mapGetters } from 'vuex';
import { userHelpers } from './components/mixins/userHelpers';
import { addHeaders, loadLanguage } from './plugins/i18n';
import { restApi } from '@/components/mixins/restApi';
export default mixins(
showMessage,
userHelpers,
restApi,
).extend({
name: 'App',
components: {
@ -42,6 +45,9 @@ export default mixins(
computed: {
...mapGetters('settings', ['isHiringBannerEnabled', 'isTemplatesEnabled', 'isTemplatesEndpointReachable', 'isUserManagementEnabled', 'showSetupPage']),
...mapGetters('users', ['currentUser']),
defaultLocale (): string {
return this.$store.getters.defaultLocale;
},
},
data() {
return {
@ -79,7 +85,7 @@ export default mixins(
}
},
logHiringBanner() {
if (!this.isHiringBannerEnabled && this.$route.name !== VIEWS.DEMO) {
if (this.isHiringBannerEnabled && this.$route.name !== VIEWS.DEMO) {
console.log(HIRING_BANNER); // eslint-disable-line no-console
}
},
@ -143,8 +149,8 @@ export default mixins(
},
},
async mounted() {
this.logHiringBanner();
await this.initialize();
this.logHiringBanner();
this.authenticate();
this.redirectIfNecessary();
@ -152,6 +158,11 @@ export default mixins(
this.trackPage();
this.$externalHooks().run('app.mount');
if (this.defaultLocale !== 'en') {
const headers = await this.restApi().getNodeTranslationHeaders();
if (headers) addHeaders(headers, this.defaultLocale);
}
},
watch: {
$route(route) {
@ -160,6 +171,9 @@ export default mixins(
this.trackPage();
},
defaultLocale(newLocale) {
loadLanguage(newLocale);
},
},
});
</script>

View file

@ -746,6 +746,10 @@ export interface ITemplatesNode extends IVersionNode {
categories?: ITemplatesCategory[];
}
export interface INodeMetadata {
parametersLastUpdatedAt?: number;
}
export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
@ -783,6 +787,7 @@ export interface IRootState {
workflow: IWorkflowDb;
sidebarMenuItems: IMenuItem[];
instanceId: string;
nodeMetadata: {[nodeName: string]: INodeMetadata};
}
export interface ICredentialTypeMap {
@ -958,3 +963,11 @@ export type IFormBoxConfig = {
redirectLink?: string;
redirectText?: string;
};
export interface ITab {
value: string | number;
label?: string;
href?: string;
icon?: string;
align?: 'right';
}

View file

@ -1,40 +1,24 @@
<template>
<el-dialog
:visible="!!node"
:visible="(!!node || renaming) && !isActiveStickyNode"
:before-close="close"
:custom-class="`classic data-display-wrapper`"
:show-close="false"
custom-class="data-display-wrapper"
width="85%"
append-to-body
@opened="showDocumentHelp = true"
>
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
<n8n-tooltip placement="bottom-start" :value="showTriggerWaitingWarning" :disabled="!showTriggerWaitingWarning" :manual="true">
<div slot="content" :class="$style.triggerWarning">{{ $locale.baseText('ndv.backToCanvas.waitingForTriggerWarning') }}</div>
<div :class="$style.backToCanvas" @click="close">
<n8n-icon icon="arrow-left" color="text-xlight" size="medium" />
<n8n-text color="text-xlight" size="medium" :bold="true">{{ $locale.baseText('ndv.backToCanvas') }}</n8n-text>
</div>
</n8n-tooltip>
<div class="data-display" v-if="node" >
<NodeSettings :eventBus="settingsEventBus" @valueChanged="valueChanged" @execute="onNodeExecute" />
<RunData @openSettings="openSettings" />
</div>
<transition name="fade">
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper">
<svg id="help-logo" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>{{ $locale.baseText('dataDisplay.nodeDocumentation') }}</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</g>
</g>
</svg>
<div class="text">
{{ $locale.baseText('dataDisplay.needHelp') }} <n8n-link size="small" :to="documentationUrl" :bold="true" @click="onDocumentationUrlClick">{{ $locale.baseText('dataDisplay.openDocumentationFor', { interpolate: { nodeTypeDisplayName: nodeType.displayName } }) }}</n8n-link>
</div>
</div>
</transition>
</el-dialog>
</template>
@ -55,6 +39,9 @@ import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue';
import mixins from 'vue-typed-mixins';
import Vue from 'vue';
import { mapGetters } from 'vuex';
import { STICKY_NODE_TYPE } from '@/constants';
export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
name: 'DataDisplay',
@ -62,23 +49,24 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
NodeSettings,
RunData,
},
props: {
renaming: {
type: Boolean,
},
},
data () {
return {
basePath: this.$store.getters.getBaseUrl,
showDocumentHelp: false,
settingsEventBus: new Vue(),
triggerWaitingWarningEnabled: false,
};
},
computed: {
documentationUrl (): string {
if (!this.nodeType) {
return '';
}
if (this.nodeType.documentationUrl && this.nodeType.documentationUrl.startsWith('http')) {
return this.nodeType.documentationUrl;
}
return 'https://docs.n8n.io/nodes/' + (this.nodeType.documentationUrl || this.nodeType.name) + '?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=' + this.nodeType.name;
...mapGetters(['executionWaitingForWebhook']),
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
showTriggerWaitingWarning(): boolean {
return this.triggerWaitingWarningEnabled && !!this.nodeType && !this.nodeType.group.includes('trigger') && this.workflowRunning && this.executionWaitingForWebhook;
},
node (): INodeUi {
return this.$store.getters.activeNode;
@ -89,19 +77,34 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
}
return null;
},
isActiveStickyNode(): boolean {
return !!this.$store.getters.activeNode && this.$store.getters.activeNode.type === STICKY_NODE_TYPE;
},
},
watch: {
node (node, oldNode) {
if(node && !oldNode) {
if(node && !oldNode && !this.isActiveStickyNode) {
this.triggerWaitingWarningEnabled = false;
this.$externalHooks().run('dataDisplay.nodeTypeChanged', { nodeSubtitle: this.getNodeSubtitle(node, this.nodeType, this.getWorkflow()) });
this.$telemetry.track('User opened node modal', { node_type: this.nodeType ? this.nodeType.name : '', workflow_id: this.$store.getters.workflowId });
}
if (window.top) {
if (window.top && !this.isActiveStickyNode) {
window.top.postMessage(JSON.stringify({command: (node? 'openNDV': 'closeNDV')}), '*');
}
},
},
methods: {
onNodeExecute() {
setTimeout(() => {
if (!this.node || !this.workflowRunning) {
return;
}
this.triggerWaitingWarningEnabled = true;
}, 1000);
},
openSettings() {
this.settingsEventBus.$emit('openSettings');
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
@ -110,12 +113,9 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
},
close () {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.showDocumentHelp = false;
this.triggerWaitingWarningEnabled = false;
this.$store.commit('setActiveNode', null);
},
onDocumentationUrlClick () {
this.$externalHooks().run('dataDisplay.onDocumentationUrlClick', { nodeType: this.nodeType, documentationUrl: this.documentationUrl });
},
},
});
@ -124,6 +124,7 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
<style lang="scss">
.data-display-wrapper {
height: 85%;
margin-top: 48px !important;
.el-dialog__header {
padding: 0 !important;
@ -145,41 +146,6 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
height: 100%;
}
.doc-help-wrapper {
position: absolute;
right: 0;
transition-delay: 2s;
background-color: #fff;
margin-top: 1%;
box-sizing: border-box;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #FFFFFF;
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
min-width: 319px;
height: 40px;
float: right;
padding: 5px;
display: flex;
flex-direction: row;
padding-top: 10px;
padding-right: 12px;
#help-logo {
flex: 1;
}
.text {
margin-left: 5px;
flex: 9;
font-family: "Open Sans";
font-size: 12px;
font-weight: 600;
line-height: 17px;
white-space: nowrap;
}
}
.fade-enter-active, .fade-enter-to, .fade-leave-active {
transition: all .75s ease;
opacity: 1;
@ -189,3 +155,30 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
opacity: 0;
}
</style>
<style lang="scss" module>
.triggerWarning {
max-width: 180px;
}
.backToCanvas {
position: absolute;
top: -40px;
&:hover {
cursor: pointer;
}
> * {
margin-right: var(--spacing-3xs);
}
}
@media (min-width: $--breakpoint-lg) {
.backToCanvas {
position: fixed;
top: 10px;
left: 20px;
}
}
</style>

View file

@ -90,8 +90,11 @@ export default mixins(showMessage).extend({
return this.userToDelete && !this.userToDelete.firstName;
},
title(): string {
const user = this.userToDelete && (this.userToDelete.fullName || this.userToDelete.email);
return this.$locale.baseText('settings.users.deleteUser', { interpolate: { user }});
const user = this.userToDelete && (this.userToDelete.fullName || this.userToDelete.email) || '';
return this.$locale.baseText(
'settings.users.deleteUser',
{ interpolate: { user }},
);
},
enabled(): boolean {
if (this.isPending) {
@ -138,7 +141,10 @@ export default mixins(showMessage).extend({
if (this.transferId) {
const getUserById = this.$store.getters['users/getUserById'];
const transferUser: IUser = getUserById(this.transferId);
message = this.$locale.baseText('settings.users.transferredToUser', { interpolate: { user: transferUser.fullName }});
message = this.$locale.baseText(
'settings.users.transferredToUser',
{ interpolate: { user: transferUser.fullName || '' }},
);
}
this.$showMessage({

View file

@ -1,128 +0,0 @@
<template>
<span class="static-text-wrapper">
<span v-show="!editActive" :title="$locale.baseText('displayWithChange.clickToChange')">
<span class="static-text" @mousedown="startEdit">{{currentValue}}</span>
</span>
<span v-show="editActive">
<input class="edit-field" ref="inputField" type="text" v-model="newValue" @keydown.enter.stop.prevent="setValue" @keydown.escape.stop.prevent="cancelEdit" @keydown.stop="noOp" @blur="cancelEdit" />
<font-awesome-icon icon="times" @mousedown="cancelEdit" class="icons clickable" :title="$locale.baseText('displayWithChange.cancelEdit')" />
<font-awesome-icon icon="check" @mousedown="setValue" class="icons clickable" :title="$locale.baseText('displayWithChange.setValue')" />
</span>
</span>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { INodeUi } from '@/Interface';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers).extend({
name: 'DisplayWithChange',
props: {
keyName: String,
},
computed: {
node (): INodeUi {
return this.$store.getters.activeNode;
},
currentValue (): string {
const getDescendantProp = (obj: object, path: string): string => {
// @ts-ignore
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
};
if (this.keyName === 'name' && this.node.type.startsWith('n8n-nodes-base.')) {
const shortNodeType = this.$locale.shortNodeType(this.node.type);
return this.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: getDescendantProp(this.node, this.keyName),
});
}
return getDescendantProp(this.node, this.keyName);
},
},
watch: {
currentValue (val) {
// Deactivate when the data to edit changes
// (like when a different node gets selected)
this.editActive = false;
},
},
data: () => {
return {
editActive: false,
newValue: '',
};
},
methods: {
noOp () {},
startEdit () {
if (this.isReadOnly === true) {
return;
}
this.editActive = true;
this.newValue = this.currentValue;
setTimeout(() => {
(this.$refs.inputField as HTMLInputElement).focus();
});
},
cancelEdit () {
this.editActive = false;
},
setValue () {
const sendData = {
value: this.newValue,
name: this.keyName,
};
this.$emit('valueChanged', sendData);
this.editActive = false;
},
},
});
</script>
<style lang="scss">
.static-text-wrapper {
line-height: 1.4em;
font-weight: 600;
.static-text {
position: relative;
top: 1px;
&:hover {
border-bottom: 1px dashed #555;
cursor: text;
}
}
input {
font-weight: 600;
&.edit-field {
background: none;
border: none;
font-size: 1em;
color: #555;
border-bottom: 1px dashed #555;
width: calc(100% - 130px);
}
&.edit-field:focus {
outline-offset: unset;
outline: none;
}
}
.icons {
margin-left: 0.6em;
}
}
</style>

View file

@ -157,7 +157,7 @@ export default mixins(
padding: 1em 0 0.5em 1.8em;
border-top-left-radius: 8px;
background-color: $--custom-window-sidebar-top;
background-color: var(--color-background-base);
color: #555;
border-bottom: 1px solid $--color-primary;
margin-bottom: 1em;

View file

@ -106,7 +106,10 @@ export default mixins(showMessage).extend({
},
buttonLabel(): string {
if (this.emailsCount > 1) {
return this.$locale.baseText('settings.users.inviteXUser', { interpolate: { count: this.emailsCount }});
return this.$locale.baseText(
'settings.users.inviteXUser',
{ interpolate: { count: this.emailsCount.toString() }},
);
}
return this.$locale.baseText('settings.users.inviteUser');

View file

@ -26,13 +26,13 @@
</span>
{{ $locale.baseText('executionDetails.of') }}
<span class="primary-color clickable" :title="$locale.baseText('executionDetails.openWorkflow')">
<WorkflowNameShort :name="workflowName">
<ShortenName :name="workflowName">
<template v-slot="{ shortenedName }">
<span @click="openWorkflow(workflowExecution.workflowId)">
"{{ shortenedName }}"
</span>
</template>
</WorkflowNameShort>
</ShortenName>
</span>
{{ $locale.baseText('executionDetails.workflow') }}
</span>
@ -47,13 +47,13 @@ import { IExecutionResponse } from "../../../Interface";
import { titleChange } from "@/components/mixins/titleChange";
import WorkflowNameShort from "@/components/WorkflowNameShort.vue";
import ShortenName from "@/components/ShortenName.vue";
import ReadOnly from "@/components/MainHeader/ExecutionDetails/ReadOnly.vue";
export default mixins(titleChange).extend({
name: "ExecutionDetails",
components: {
WorkflowNameShort,
ShortenName,
ReadOnly,
},
computed: {

View file

@ -2,7 +2,7 @@
<div class="container" v-if="workflowName">
<BreakpointsObserver :valueXS="15" :valueSM="25" :valueMD="50" class="name-container">
<template v-slot="{ value }">
<WorkflowNameShort
<ShortenName
:name="workflowName"
:limit="value"
:custom="true"
@ -19,7 +19,7 @@
class="name"
/>
</template>
</WorkflowNameShort>
</ShortenName>
</template>
</BreakpointsObserver>
@ -81,7 +81,7 @@ import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { MAX_WORKFLOW_NAME_LENGTH } from "@/constants";
import WorkflowNameShort from "@/components/WorkflowNameShort.vue";
import ShortenName from "@/components/ShortenName.vue";
import TagsContainer from "@/components/TagsContainer.vue";
import PushConnectionTracker from "@/components/PushConnectionTracker.vue";
import WorkflowActivator from "@/components/WorkflowActivator.vue";
@ -105,7 +105,7 @@ export default mixins(workflowHelpers).extend({
components: {
TagsContainer,
PushConnectionTracker,
WorkflowNameShort,
ShortenName,
WorkflowActivator,
SaveButton,
TagsDropdown,

View file

@ -125,13 +125,16 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
if (this.nodeType !== null && this.nodeType.hasOwnProperty('eventTriggerDescription')) {
const nodeName = this.$locale.shortNodeType(this.nodeType.name);
const { eventTriggerDescription } = this.nodeType;
return this.$locale.nodeText().eventTriggerDescription(nodeName, eventTriggerDescription);
return this.$locale.nodeText().eventTriggerDescription(
nodeName,
eventTriggerDescription || '',
);
} else {
return this.$locale.baseText(
'node.waitingForYouToCreateAnEventIn',
{
interpolate: {
nodeType: this.nodeType && getTriggerNodeServiceName(this.nodeType.displayName),
nodeType: this.nodeType ? getTriggerNodeServiceName(this.nodeType.displayName) : '',
},
},
);

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