diff --git a/.eslintrc.js b/.eslintrc.js index 550426054b..2a9b045e34 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -257,6 +257,11 @@ module.exports = { */ '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + /** + * https://github.com/typescript-eslint/typescript-eslint/blob/v4.33.0/packages/eslint-plugin/docs/rules/no-namespace.md + */ + '@typescript-eslint/no-namespace': 'off', + /** * https://eslint.org/docs/1.0.0/rules/no-throw-literal */ diff --git a/package-lock.json b/package-lock.json index a7423ea693..cca5fe6745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3030,37 +3030,49 @@ } }, "@jest/globals": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-26.6.2.tgz", - "integrity": "sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", "requires": { - "@jest/environment": "^26.6.2", - "@jest/types": "^26.6.2", - "expect": "^26.6.2" + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" }, "dependencies": { "@jest/environment": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz", - "integrity": "sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", "requires": { - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2" + "jest-mock": "^27.5.1" } }, "@jest/fake-timers": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz", - "integrity": "sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", "requires": { - "@jest/types": "^26.6.2", - "@sinonjs/fake-timers": "^6.0.1", + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", "@types/node": "*", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + } + }, + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" } }, "@types/stack-utils": { @@ -3068,6 +3080,14 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3085,6 +3105,11 @@ "supports-color": "^7.1.0" } }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3104,16 +3129,14 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" }, "expect": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", - "integrity": "sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", "requires": { - "@jest/types": "^26.6.2", - "ansi-styles": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0" + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" } }, "has-flag": { @@ -3122,41 +3145,54 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "jest-matcher-utils": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz", - "integrity": "sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", "requires": { "chalk": "^4.0.0", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-message-util": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz", - "integrity": "sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", "requires": { - "@babel/code-frame": "^7.0.0", - "@jest/types": "^26.6.2", + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", "slash": "^3.0.0", - "stack-utils": "^2.0.2" + "stack-utils": "^2.0.3" } }, "jest-mock": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz", - "integrity": "sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "@types/node": "*" } }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, "stack-utils": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", @@ -10340,9 +10376,9 @@ } }, "@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", "requires": { "@sinonjs/commons": "^1.7.0" } @@ -12370,6 +12406,19 @@ "resolved": "https://registry.npmjs.org/@types/convict/-/convict-4.2.1.tgz", "integrity": "sha512-2cd51m3i0yeY1i3dKxcqJKeS5Q4jZnjP37OseoNeIX1OM0AhmGPuuYmwJ9OqtsU35YrREQxdb2VeX5sM3cwGMQ==" }, + "@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "requires": { + "@types/express": "*" + } + }, + "@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==" + }, "@types/copyfiles": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/copyfiles/-/copyfiles-2.4.1.tgz", @@ -12573,12 +12622,68 @@ } }, "@types/jest": { - "version": "26.0.24", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", - "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "version": "27.4.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz", + "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==", "requires": { - "jest-diff": "^26.0.0", - "pretty-format": "^26.0.0" + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } } }, "@types/jmespath": { @@ -12825,6 +12930,33 @@ "@types/node": "*" } }, + "@types/passport": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", + "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", + "requires": { + "@types/express": "*" + } + }, + "@types/passport-jwt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", + "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", + "requires": { + "@types/express": "*", + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", + "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/prettier": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz", @@ -13020,6 +13152,23 @@ "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==" }, + "@types/superagent": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.13.tgz", + "integrity": "sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww==", + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz", + "integrity": "sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q==", + "requires": { + "@types/superagent": "*" + } + }, "@types/tapable": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz", @@ -13687,6 +13836,15 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -13722,6 +13880,21 @@ } } }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true + }, "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -13784,6 +13957,58 @@ "worker-rpc": "^0.1.0" } }, + "fork-ts-checker-webpack-plugin-v5": { + "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", + "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", + "optional": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "optional": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, "glob-parent": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", @@ -13818,6 +14043,12 @@ "slash": "^2.0.0" } }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "optional": true + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -13846,6 +14077,16 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "optional": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -13881,6 +14122,17 @@ } } }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "optional": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -13891,6 +14143,15 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==" }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + }, "to-regex-range": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", @@ -13984,6 +14245,12 @@ "requires": { "tslib": "^1.8.1" } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "optional": true } } }, @@ -17984,9 +18251,14 @@ } }, "cjs-module-lexer": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz", - "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" + }, + "clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ=" }, "class-utils": { "version": "0.3.6", @@ -19480,11 +19752,32 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -20595,8 +20888,7 @@ "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", - "dev": true + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=" }, "deep-equal": { "version": "1.1.1", @@ -20967,7 +21259,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", "integrity": "sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=", - "dev": true, "requires": { "asap": "^2.0.0", "wrappy": "1" @@ -21007,9 +21298,9 @@ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, "diff-sequences": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", - "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==" + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==" }, "diffie-hellman": { "version": "5.0.3", @@ -21413,9 +21704,9 @@ } }, "emittery": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz", - "integrity": "sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==" }, "emoji-regex": { "version": "8.0.0", @@ -22816,6 +23107,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -23462,124 +23758,6 @@ } } }, - "fork-ts-checker-webpack-plugin-v5": { - "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", - "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", - "optional": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "optional": true - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "optional": true - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "optional": true, - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "optional": true, - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "optional": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "optional": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "optional": true - } - } - }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -25874,6 +26052,11 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -27460,150 +27643,183 @@ "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==" }, "jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", - "integrity": "sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "requires": { - "@jest/core": "^26.6.3", + "@jest/core": "^27.5.1", "import-local": "^3.0.2", - "jest-cli": "^26.6.3" + "jest-cli": "^27.5.1" }, "dependencies": { "@jest/console": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz", - "integrity": "sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", - "jest-message-util": "^26.6.2", - "jest-util": "^26.6.2", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", "slash": "^3.0.0" } }, "@jest/core": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-26.6.3.tgz", - "integrity": "sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", "requires": { - "@jest/console": "^26.6.2", - "@jest/reporters": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", + "emittery": "^0.8.1", "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-changed-files": "^26.6.2", - "jest-config": "^26.6.3", - "jest-haste-map": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-resolve-dependencies": "^26.6.3", - "jest-runner": "^26.6.3", - "jest-runtime": "^26.6.3", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "jest-watcher": "^26.6.2", - "micromatch": "^4.0.2", - "p-each-series": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", "rimraf": "^3.0.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" } }, "@jest/environment": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz", - "integrity": "sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", "requires": { - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2" + "jest-mock": "^27.5.1" } }, "@jest/fake-timers": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz", - "integrity": "sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", "requires": { - "@jest/types": "^26.6.2", - "@sinonjs/fake-timers": "^6.0.1", + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", "@types/node": "*", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" } }, "@jest/reporters": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-26.6.2.tgz", - "integrity": "sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", "requires": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", "chalk": "^4.0.0", "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", "glob": "^7.1.2", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^4.0.3", + "istanbul-lib-instrument": "^5.1.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "jest-haste-map": "^26.6.2", - "jest-resolve": "^26.6.2", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", - "node-notifier": "^8.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", "terminal-link": "^2.0.0", - "v8-to-istanbul": "^7.0.0" + "v8-to-istanbul": "^8.1.0" } }, "@jest/source-map": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-26.6.2.tgz", - "integrity": "sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", "requires": { "callsites": "^3.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "source-map": "^0.6.0" } }, "@jest/test-result": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-26.6.2.tgz", - "integrity": "sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", "requires": { - "@jest/console": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" } }, "@jest/test-sequencer": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz", - "integrity": "sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", "requires": { - "@jest/test-result": "^26.6.2", - "graceful-fs": "^4.2.4", - "jest-haste-map": "^26.6.2", - "jest-runner": "^26.6.3", - "jest-runtime": "^26.6.3" + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + } + }, + "@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + } + }, + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" } }, "@types/stack-utils": { @@ -27611,6 +27827,14 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, "acorn": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", @@ -27641,24 +27865,24 @@ } }, "babel-jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", - "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", "requires": { - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/babel__core": "^7.1.7", - "babel-plugin-istanbul": "^6.0.0", - "babel-preset-jest": "^26.6.2", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "slash": "^3.0.0" } }, "babel-plugin-jest-hoist": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz", - "integrity": "sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", "requires": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -27667,11 +27891,11 @@ } }, "babel-preset-jest": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz", - "integrity": "sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", "requires": { - "babel-plugin-jest-hoist": "^26.6.2", + "babel-plugin-jest-hoist": "^27.5.1", "babel-preset-current-node-syntax": "^1.0.0" } }, @@ -27689,14 +27913,19 @@ "supports-color": "^7.1.0" } }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, "cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" + "wrap-ansi": "^7.0.0" } }, "color-convert": { @@ -27778,32 +28007,30 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" }, "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "expect": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", - "integrity": "sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", "requires": { - "@jest/types": "^26.6.2", - "ansi-styles": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0" + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" } }, "form-data": { @@ -27816,6 +28043,11 @@ "mime-types": "^2.1.12" } }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -27829,6 +28061,11 @@ "whatwg-encoding": "^1.0.5" } }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, "import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -27843,17 +28080,6 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" }, - "istanbul-lib-instrument": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", - "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", - "requires": { - "@babel/core": "^7.7.5", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.0.0", - "semver": "^6.3.0" - } - }, "istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -27884,323 +28110,388 @@ } }, "jest-changed-files": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", - "integrity": "sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", "requires": { - "@jest/types": "^26.6.2", - "execa": "^4.0.0", - "throat": "^5.0.0" + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" } }, "jest-cli": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz", - "integrity": "sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", "requires": { - "@jest/core": "^26.6.3", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", "exit": "^0.1.2", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "import-local": "^3.0.2", - "is-ci": "^2.0.0", - "jest-config": "^26.6.3", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", "prompts": "^2.0.1", - "yargs": "^15.4.1" + "yargs": "^16.2.0" } }, "jest-config": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.6.3.tgz", - "integrity": "sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", "requires": { - "@babel/core": "^7.1.0", - "@jest/test-sequencer": "^26.6.3", - "@jest/types": "^26.6.2", - "babel-jest": "^26.6.3", + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", "chalk": "^4.0.0", + "ci-info": "^3.2.0", "deepmerge": "^4.2.2", "glob": "^7.1.1", - "graceful-fs": "^4.2.4", - "jest-environment-jsdom": "^26.6.2", - "jest-environment-node": "^26.6.2", - "jest-get-type": "^26.3.0", - "jest-jasmine2": "^26.6.3", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2" + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" } }, "jest-docblock": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", - "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", "requires": { "detect-newline": "^3.0.0" } }, "jest-each": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-26.6.2.tgz", - "integrity": "sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-util": "^26.6.2", - "pretty-format": "^26.6.2" + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-environment-jsdom": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz", - "integrity": "sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", "requires": { - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2", - "jsdom": "^16.4.0" + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" } }, "jest-environment-node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.6.2.tgz", - "integrity": "sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", "requires": { - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + } + }, + "jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "requires": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" } }, "jest-jasmine2": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz", - "integrity": "sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", "requires": { - "@babel/traverse": "^7.1.0", - "@jest/environment": "^26.6.2", - "@jest/source-map": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", "co": "^4.6.0", - "expect": "^26.6.2", + "expect": "^27.5.1", "is-generator-fn": "^2.0.0", - "jest-each": "^26.6.2", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-runtime": "^26.6.3", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "pretty-format": "^26.6.2", - "throat": "^5.0.0" + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" } }, "jest-leak-detector": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz", - "integrity": "sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", "requires": { - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-matcher-utils": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz", - "integrity": "sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", "requires": { "chalk": "^4.0.0", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" } }, "jest-message-util": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz", - "integrity": "sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", "requires": { - "@babel/code-frame": "^7.0.0", - "@jest/types": "^26.6.2", + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", "slash": "^3.0.0", - "stack-utils": "^2.0.2" + "stack-utils": "^2.0.3" } }, "jest-mock": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz", - "integrity": "sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "@types/node": "*" } }, + "jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==" + }, "jest-resolve": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.6.2.tgz", - "integrity": "sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", "requires": { - "@jest/types": "^26.6.2", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", "jest-pnp-resolver": "^1.2.2", - "jest-util": "^26.6.2", - "read-pkg-up": "^7.0.1", - "resolve": "^1.18.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", "slash": "^3.0.0" } }, "jest-resolve-dependencies": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz", - "integrity": "sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", "requires": { - "@jest/types": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-snapshot": "^26.6.2" + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" } }, "jest-runner": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-26.6.3.tgz", - "integrity": "sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", "requires": { - "@jest/console": "^26.6.2", - "@jest/environment": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", - "emittery": "^0.7.1", - "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-config": "^26.6.3", - "jest-docblock": "^26.0.0", - "jest-haste-map": "^26.6.2", - "jest-leak-detector": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-resolve": "^26.6.2", - "jest-runtime": "^26.6.3", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", "source-map-support": "^0.5.6", - "throat": "^5.0.0" + "throat": "^6.0.1" } }, "jest-runtime": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.6.3.tgz", - "integrity": "sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", "requires": { - "@jest/console": "^26.6.2", - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/globals": "^26.6.2", - "@jest/source-map": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/yargs": "^15.0.0", + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "chalk": "^4.0.0", - "cjs-module-lexer": "^0.6.0", + "cjs-module-lexer": "^1.0.0", "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", + "execa": "^5.0.0", "glob": "^7.1.3", - "graceful-fs": "^4.2.4", - "jest-config": "^26.6.3", - "jest-haste-map": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", "slash": "^3.0.0", - "strip-bom": "^4.0.0", - "yargs": "^15.4.1" + "strip-bom": "^4.0.0" + } + }, + "jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.9" } }, "jest-snapshot": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.6.2.tgz", - "integrity": "sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", "requires": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", "@babel/types": "^7.0.0", - "@jest/types": "^26.6.2", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.0.0", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", - "expect": "^26.6.2", - "graceful-fs": "^4.2.4", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "jest-haste-map": "^26.6.2", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-resolve": "^26.6.2", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", "natural-compare": "^1.4.0", - "pretty-format": "^26.6.2", + "pretty-format": "^27.5.1", "semver": "^7.3.2" - }, - "dependencies": { - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "requires": { - "lru-cache": "^6.0.0" - } - } + } + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" } }, "jest-validate": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-26.6.2.tgz", - "integrity": "sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", "requires": { - "@jest/types": "^26.6.2", - "camelcase": "^6.0.0", + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", "chalk": "^4.0.0", - "jest-get-type": "^26.3.0", + "jest-get-type": "^27.5.1", "leven": "^3.1.0", - "pretty-format": "^26.6.2" + "pretty-format": "^27.5.1" } }, "jest-watcher": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.6.2.tgz", - "integrity": "sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", "requires": { - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", - "jest-util": "^26.6.2", + "jest-util": "^27.5.1", "string-length": "^4.0.1" } }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -28245,31 +28536,6 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, - "node-notifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.2.tgz", - "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", - "optional": true, - "requires": { - "growly": "^1.3.0", - "is-wsl": "^2.2.0", - "semver": "^7.3.2", - "shellwords": "^0.1.1", - "uuid": "^8.3.0", - "which": "^2.0.2" - }, - "dependencies": { - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "optional": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, "npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -28286,10 +28552,16 @@ "mimic-fn": "^2.1.0" } }, - "p-each-series": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", - "integrity": "sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==" + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } }, "path-key": { "version": "3.1.1", @@ -28312,6 +28584,14 @@ "xmlchars": "^2.2.0" } }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -28347,6 +28627,11 @@ "strip-ansi": "^6.0.0" } }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -28356,9 +28641,9 @@ } }, "throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==" }, "tough-cookie": { "version": "4.0.0", @@ -28409,54 +28694,34 @@ "isexe": "^2.0.0" } }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, "ws": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==" }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, "yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "requires": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" } }, "yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" - } - } + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" } } }, @@ -28521,6 +28786,530 @@ } } }, + "jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "requires": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + } + }, + "@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "requires": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + } + }, + "@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "requires": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + } + }, + "@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "requires": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + } + }, + "@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + } + }, + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "requires": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "requires": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "requires": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + } + }, + "jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + } + }, + "jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*" + } + }, + "jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==" + }, + "jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "requires": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + } + }, + "jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "requires": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + } + }, + "jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + } + }, + "jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "requires": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + } + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, + "jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "requires": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "requires": { + "path-key": "^3.0.0" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "stack-utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", + "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", + "requires": { + "escape-string-regexp": "^2.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "throat": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==" + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "jest-config": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-24.9.0.tgz", @@ -28730,14 +29519,14 @@ } }, "jest-diff": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", - "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", "requires": { "chalk": "^4.0.0", - "diff-sequences": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" }, "dependencies": { "ansi-styles": { @@ -29210,9 +29999,9 @@ } }, "jest-get-type": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", - "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==" + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==" }, "jest-haste-map": { "version": "26.6.2", @@ -32669,6 +33458,11 @@ } } }, + "material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -35619,6 +36413,37 @@ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=" }, + "passport": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.5.2.tgz", + "integrity": "sha512-w9n/Ot5I7orGD4y+7V3EFJCQEznE5RxHamUxcqLT2QoJY0f2JdN8GyHonYFvN0Vz+L6lUJfVhrk2aZz2LbuREw==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-cookie": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/passport-cookie/-/passport-cookie-1.0.9.tgz", + "integrity": "sha512-8a6foX2bbGoJzup0RAiNcC2tTqzYS46RQEK3Z4u8p86wesPUjgDaji3C7+5j4TGyCq4ZoOV+3YLw1Hy6cV6kyw==", + "requires": { + "passport-strategy": "^1.0.0" + } + }, + "passport-jwt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz", + "integrity": "sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg==", + "requires": { + "jsonwebtoken": "^8.2.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, "password-prompt": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.2.tgz", @@ -35709,6 +36534,11 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "pbkdf2": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", @@ -36731,36 +37561,19 @@ } }, "pretty-format": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "requires": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", "react-is": "^17.0.1" }, "dependencies": { "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, "react-is": { "version": "17.0.2", @@ -38496,6 +39309,11 @@ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" }, + "resolve.exports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", + "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==" + }, "responselike": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", @@ -40297,6 +41115,66 @@ } } }, + "superagent": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.1.tgz", + "integrity": "sha512-CQ2weSS6M+doIwwYFoMatklhRbx6sVNdB99OEJ5czcP3cng76Ljqus694knFWgOj3RkrtxZqIgpe6vhe0J7QWQ==", + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.3", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", + "methods": "^1.1.2", + "mime": "^2.5.0", + "qs": "^6.10.1", + "readable-stream": "^3.6.0", + "semver": "^7.3.5" + }, + "dependencies": { + "formidable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", + "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", + "requires": { + "dezalgo": "1.0.3", + "hexoid": "1.0.0", + "once": "1.4.0", + "qs": "6.9.3" + }, + "dependencies": { + "qs": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==" + } + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "supertest": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.2.2.tgz", + "integrity": "sha512-wCw9WhAtKJsBvh07RaS+/By91NNE0Wh0DN19/hWPlBOU8tAfOtbZoVSV4xXeoKoxgPx0rx2y+y+8660XtE7jzg==", + "requires": { + "methods": "^1.1.2", + "superagent": "^7.1.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -40903,6 +41781,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tinycolor2": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz", + "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==" + }, "title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -41148,26 +42031,92 @@ "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==" }, "ts-jest": { - "version": "26.5.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.6.tgz", - "integrity": "sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==", + "version": "27.1.3", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.3.tgz", + "integrity": "sha512-6Nlura7s6uM9BVUAoqLH7JHyMXjz8gluryjpPXxr3IxZdAXnU6FhjvVLHFtfd1vsE1p8zD1OJfskkc0jhTSnkA==", "requires": { "bs-logger": "0.x", - "buffer-from": "1.x", "fast-json-stable-stringify": "2.x", - "jest-util": "^26.1.0", + "jest-util": "^27.0.0", "json5": "2.x", - "lodash": "4.x", + "lodash.memoize": "4.x", "make-error": "1.x", - "mkdirp": "1.x", "semver": "7.x", "yargs-parser": "20.x" }, "dependencies": { - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", + "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } }, "semver": { "version": "7.3.5", @@ -41177,6 +42126,14 @@ "lru-cache": "^6.0.0" } }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, "yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", @@ -42216,9 +43173,9 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==" }, "v8-to-istanbul": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz", - "integrity": "sha512-TxNb7YEUwkLXCQYeudi6lgQ/SZrzNO4kMdlqVxaZPUIUjCv6iSSypUQX70kNBSERpQ8fk48+d61FXk+tgqcWow==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", "requires": { "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^1.6.0", @@ -42478,6 +43435,17 @@ "webpack-bundle-analyzer": "^3.6.0" } }, + "vue-color": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/vue-color/-/vue-color-2.8.1.tgz", + "integrity": "sha512-BoLCEHisXi2QgwlhZBg9UepvzZZmi4176vbr+31Shen5WWZwSLVgdScEPcB+yrAtuHAz42309C0A4+WiL9lNBw==", + "requires": { + "clamp": "^1.0.1", + "lodash.throttle": "^4.0.0", + "material-colors": "^1.0.0", + "tinycolor2": "^1.1.2" + } + }, "vue-docgen-api": { "version": "4.44.18", "resolved": "https://registry.npmjs.org/vue-docgen-api/-/vue-docgen-api-4.44.18.tgz", @@ -42659,6 +43627,16 @@ "resolved": "https://registry.npmjs.org/vue-typed-mixins/-/vue-typed-mixins-0.2.0.tgz", "integrity": "sha512-0OxuinandPWv3nm5k/reYkuKtX3jjPZ40Sy9roJz0ih8PUzmI7zSRiXFEJ62LsyRegw9Tqy+qMkajk7ipKP8Vg==" }, + "vue2-boring-avatars": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/vue2-boring-avatars/-/vue2-boring-avatars-0.3.4.tgz", + "integrity": "sha512-N3FYX9Z6rZdTeP3BOBz2LMxlWo9WRmPF6SOsYzz+tEuUH0QjX8UD7c1X95J8pZ7cFvbh9QflVujYQRqRiiwoAg==", + "requires": { + "core-js": "^3.6.5", + "vue": "^2.6.11", + "vue-color": "^2.8.1" + } + }, "vue2-touch-events": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/vue2-touch-events/-/vue2-touch-events-3.2.2.tgz", diff --git a/packages/cli/commands/Interfaces.d.ts b/packages/cli/commands/Interfaces.d.ts index c47f1680e5..41721bcc86 100644 --- a/packages/cli/commands/Interfaces.d.ts +++ b/packages/cli/commands/Interfaces.d.ts @@ -53,3 +53,14 @@ declare module 'json-diff' { } export function diff(obj1: unknown, obj2: unknown, diffOptions: IDiffOptions): string; } + +type SmtpConfig = { + host: string; + port: number; + secure: boolean; + auth: { + user: string; + pass: string; + }; + sender: string; +}; diff --git a/packages/cli/commands/execute.ts b/packages/cli/commands/execute.ts index 07677743e4..2d647cb233 100644 --- a/packages/cli/commands/execute.ts +++ b/packages/cli/commands/execute.ts @@ -28,6 +28,7 @@ import { import { getLogger } from '../src/Logger'; import config = require('../config'); +import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper'; export class Execute extends Command { static description = '\nExecutes a given workflow'; @@ -169,11 +170,13 @@ export class Execute extends Command { } try { + const user = await getInstanceOwner(); const runData: IWorkflowExecutionDataProcess = { executionMode: 'cli', startNodes: [startNode.name], // eslint-disable-next-line @typescript-eslint/no-non-null-assertion workflowData: workflowData!, + userId: user.id, }; const workflowRunner = new WorkflowRunner(); diff --git a/packages/cli/commands/executeBatch.ts b/packages/cli/commands/executeBatch.ts index 3add81ac92..a1d5992b38 100644 --- a/packages/cli/commands/executeBatch.ts +++ b/packages/cli/commands/executeBatch.ts @@ -37,6 +37,8 @@ import { WorkflowRunner, } from '../src'; import config = require('../config'); +import { User } from '../src/databases/entities/User'; +import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper'; export class ExecuteBatch extends Command { static description = '\nExecutes multiple workflows once'; @@ -57,6 +59,8 @@ export class ExecuteBatch extends Command { static executionTimeout = 3 * 60 * 1000; + static instanceOwner: User; + static examples = [ `$ n8n executeBatch`, `$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.txt`, @@ -279,6 +283,8 @@ export class ExecuteBatch extends Command { // Wait till the database is ready await startDbInitPromise; + ExecuteBatch.instanceOwner = await getInstanceOwner(); + let allWorkflows; const query = Db.collections.Workflow!.createQueryBuilder('workflows'); @@ -666,6 +672,7 @@ export class ExecuteBatch extends Command { executionMode: 'cli', startNodes: [startNode!.name], workflowData, + userId: ExecuteBatch.instanceOwner.id, }; const workflowRunner = new WorkflowRunner(); diff --git a/packages/cli/commands/import/credentials.ts b/packages/cli/commands/import/credentials.ts index 01ce0e5a45..a4d799c604 100644 --- a/packages/cli/commands/import/credentials.ts +++ b/packages/cli/commands/import/credentials.ts @@ -1,3 +1,9 @@ +/* eslint-disable no-restricted-syntax */ +/* 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 */ import { Command, flags } from '@oclif/command'; @@ -9,15 +15,25 @@ import { LoggerProxy } from 'n8n-workflow'; import * as fs from 'fs'; import * as glob from 'fast-glob'; import * as path from 'path'; +import { EntityManager, getConnection } from 'typeorm'; import { getLogger } from '../../src/Logger'; import { Db } from '../../src'; +import { User } from '../../src/databases/entities/User'; +import { SharedCredentials } from '../../src/databases/entities/SharedCredentials'; +import { Role } from '../../src/databases/entities/Role'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; + +const FIX_INSTRUCTION = + 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; export class ImportCredentialsCommand extends Command { static description = 'Import credentials'; static examples = [ - `$ n8n import:credentials --input=file.json`, - `$ n8n import:credentials --separate --input=backups/latest/`, + '$ n8n import:credentials --input=file.json', + '$ n8n import:credentials --separate --input=backups/latest/', + '$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; static flags = { @@ -29,87 +45,161 @@ export class ImportCredentialsCommand extends Command { separate: flags.boolean({ description: 'Imports *.json files from directory provided by --input', }), + userId: flags.string({ + description: 'The ID of the user to assign the imported credentials to', + }), }; - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async run() { + ownerCredentialRole: Role; + + transactionManager: EntityManager; + + async run(): Promise { const logger = getLogger(); LoggerProxy.init(logger); - // eslint-disable-next-line @typescript-eslint/no-shadow const { flags } = this.parse(ImportCredentialsCommand); if (!flags.input) { - console.info(`An input file or directory with --input must be provided`); + console.info('An input file or directory with --input must be provided'); return; } if (flags.separate) { if (fs.existsSync(flags.input)) { if (!fs.lstatSync(flags.input).isDirectory()) { - console.info(`The paramenter --input must be a directory`); + console.info('The argument to --input must be a directory'); return; } } } + let totalImported = 0; + try { await Db.init(); + await this.initOwnerCredentialRole(); + const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); + // Make sure the settings exist await UserSettings.prepareUserSettings(); - let i; const encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { - throw new Error('No encryption key got found to encrypt the credentials!'); + throw new Error('No encryption key found to encrypt the credentials!'); } if (flags.separate) { const files = await glob( `${flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep}*.json`, ); - for (i = 0; i < files.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const credential = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + totalImported = files.length; + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + for (const file of files) { + const credential = JSON.parse(fs.readFileSync(file, { encoding: 'utf8' })); + + if (typeof credential.data === 'object') { + // plain data / decrypted input. Should be encrypted first. + Credentials.prototype.setData.call(credential, credential.data, encryptionKey); + } + + await this.storeCredential(credential, user); + } + }); + + this.reportSuccess(totalImported); + process.exit(); + } + + const credentials = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); + + totalImported = credentials.length; + + if (!Array.isArray(credentials)) { + throw new Error( + 'File does not seem to contain credentials. Make sure the credentials are contained in an array.', + ); + } + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + for (const credential of credentials) { if (typeof credential.data === 'object') { // plain data / decrypted input. Should be encrypted first. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access Credentials.prototype.setData.call(credential, credential.data, encryptionKey); } - - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Credentials!.save(credential); + await this.storeCredential(credential, user); } - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const fileContents = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); + }); - if (!Array.isArray(fileContents)) { - throw new Error(`File does not seem to contain credentials.`); - } - - for (i = 0; i < fileContents.length; i++) { - if (typeof fileContents[i].data === 'object') { - // plain data / decrypted input. Should be encrypted first. - Credentials.prototype.setData.call( - fileContents[i], - fileContents[i].data, - encryptionKey, - ); - } - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Credentials!.save(fileContents[i]); - } - } - console.info(`Successfully imported ${i} ${i === 1 ? 'credential.' : 'credentials.'}`); - process.exit(0); + this.reportSuccess(totalImported); + process.exit(); } catch (error) { - console.error('An error occurred while exporting credentials. See log messages for details.'); - logger.error(error.message); + console.error('An error occurred while importing credentials. See log messages for details.'); + if (error instanceof Error) logger.error(error.message); this.exit(1); } } + + private reportSuccess(total: number) { + console.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); + } + + private async initOwnerCredentialRole() { + const ownerCredentialRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'credential' }, + }); + + if (!ownerCredentialRole) { + throw new Error(`Failed to find owner credential role. ${FIX_INSTRUCTION}`); + } + + this.ownerCredentialRole = ownerCredentialRole; + } + + private async storeCredential(credential: object, user: User) { + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, credential); + + const savedCredential = await this.transactionManager.save(newCredential); + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + credentials: savedCredential, + user, + role: this.ownerCredentialRole, + }); + + await this.transactionManager.save(newSharedCredential); + } + + private async getOwner() { + const ownerGlobalRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'global' }, + }); + + const owner = await Db.collections.User!.findOne({ globalRole: ownerGlobalRole }); + + if (!owner) { + throw new Error(`Failed to find owner. ${FIX_INSTRUCTION}`); + } + + return owner; + } + + private async getAssignee(userId: string) { + const user = await Db.collections.User!.findOne(userId); + + if (!user) { + throw new Error(`Failed to find user with ID ${userId}`); + } + + return user; + } } diff --git a/packages/cli/commands/import/workflow.ts b/packages/cli/commands/import/workflow.ts index 6ca37d0000..a58f073167 100644 --- a/packages/cli/commands/import/workflow.ts +++ b/packages/cli/commands/import/workflow.ts @@ -1,3 +1,11 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-loop-func */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Command, flags } from '@oclif/command'; @@ -7,15 +15,25 @@ import { INode, INodeCredentialsDetails, LoggerProxy } from 'n8n-workflow'; import * as fs from 'fs'; import * as glob from 'fast-glob'; import { UserSettings } from 'n8n-core'; +import { EntityManager, getConnection } from 'typeorm'; import { getLogger } from '../../src/Logger'; import { Db, ICredentialsDb } from '../../src'; +import { SharedWorkflow } from '../../src/databases/entities/SharedWorkflow'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import { Role } from '../../src/databases/entities/Role'; +import { User } from '../../src/databases/entities/User'; + +const FIX_INSTRUCTION = + 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; export class ImportWorkflowsCommand extends Command { static description = 'Import workflows'; static examples = [ - `$ n8n import:workflow --input=file.json`, - `$ n8n import:workflow --separate --input=backups/latest/`, + '$ n8n import:workflow --input=file.json', + '$ n8n import:workflow --separate --input=backups/latest/', + '$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; static flags = { @@ -27,12 +45,174 @@ export class ImportWorkflowsCommand extends Command { separate: flags.boolean({ description: 'Imports *.json files from directory provided by --input', }), + userId: flags.string({ + description: 'The ID of the user to assign the imported workflows to', + }), }; + ownerWorkflowRole: Role; + + transactionManager: EntityManager; + + async run(): Promise { + const logger = getLogger(); + LoggerProxy.init(logger); + + const { flags } = this.parse(ImportWorkflowsCommand); + + if (!flags.input) { + console.info('An input file or directory with --input must be provided'); + return; + } + + if (flags.separate) { + if (fs.existsSync(flags.input)) { + if (!fs.lstatSync(flags.input).isDirectory()) { + console.info('The argument to --input must be a directory'); + return; + } + } + } + + try { + await Db.init(); + + await this.initOwnerWorkflowRole(); + const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); + + // Make sure the settings exist + await UserSettings.prepareUserSettings(); + const credentials = (await Db.collections.Credentials?.find()) ?? []; + + let totalImported = 0; + + if (flags.separate) { + let { input: inputPath } = flags; + + if (process.platform === 'win32') { + inputPath = inputPath.replace(/\\/g, '/'); + } + + inputPath = inputPath.replace(/\/$/g, ''); + + const files = await glob(`${inputPath}/*.json`); + + totalImported = files.length; + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + + for (const file of files) { + const workflow = JSON.parse(fs.readFileSync(file, { encoding: 'utf8' })); + + if (credentials.length > 0) { + workflow.nodes.forEach((node: INode) => { + this.transformCredentials(node, credentials); + }); + } + + await this.storeWorkflow(workflow, user); + } + }); + + this.reportSuccess(totalImported); + process.exit(); + } + + const workflows = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); + + totalImported = workflows.length; + + if (!Array.isArray(workflows)) { + throw new Error( + 'File does not seem to contain workflows. Make sure the workflows are contained in an array.', + ); + } + + await getConnection().transaction(async (transactionManager) => { + this.transactionManager = transactionManager; + + for (const workflow of workflows) { + if (credentials.length > 0) { + workflow.nodes.forEach((node: INode) => { + this.transformCredentials(node, credentials); + }); + } + + await this.storeWorkflow(workflow, user); + } + }); + + this.reportSuccess(totalImported); + process.exit(); + } catch (error) { + console.error('An error occurred while importing workflows. See log messages for details.'); + if (error instanceof Error) logger.error(error.message); + this.exit(1); + } + } + + private reportSuccess(total: number) { + console.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); + } + + private async initOwnerWorkflowRole() { + const ownerWorkflowRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'workflow' }, + }); + + if (!ownerWorkflowRole) { + throw new Error(`Failed to find owner workflow role. ${FIX_INSTRUCTION}`); + } + + this.ownerWorkflowRole = ownerWorkflowRole; + } + + private async storeWorkflow(workflow: object, user: User) { + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, workflow); + + const savedWorkflow = await this.transactionManager.save(newWorkflow); + + const newSharedWorkflow = new SharedWorkflow(); + + Object.assign(newSharedWorkflow, { + workflow: savedWorkflow, + user, + role: this.ownerWorkflowRole, + }); + + await this.transactionManager.save(newSharedWorkflow); + } + + private async getOwner() { + const ownerGlobalRole = await Db.collections.Role!.findOne({ + where: { name: 'owner', scope: 'global' }, + }); + + const owner = await Db.collections.User!.findOne({ globalRole: ownerGlobalRole }); + + if (!owner) { + throw new Error(`Failed to find owner. ${FIX_INSTRUCTION}`); + } + + return owner; + } + + private async getAssignee(userId: string) { + const user = await Db.collections.User!.findOne(userId); + + if (!user) { + throw new Error(`Failed to find user with ID ${userId}`); + } + + return user; + } + private transformCredentials(node: INode, credentialsEntities: ICredentialsDb[]) { if (node.credentials) { const allNodeCredentials = Object.entries(node.credentials); - // eslint-disable-next-line no-restricted-syntax for (const [type, name] of allNodeCredentials) { if (typeof name === 'string') { const nodeCredentials: INodeCredentialsDetails = { @@ -54,80 +234,4 @@ export class ImportWorkflowsCommand extends Command { } } } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async run() { - const logger = getLogger(); - LoggerProxy.init(logger); - - // eslint-disable-next-line @typescript-eslint/no-shadow - const { flags } = this.parse(ImportWorkflowsCommand); - - if (!flags.input) { - console.info(`An input file or directory with --input must be provided`); - return; - } - - if (flags.separate) { - if (fs.existsSync(flags.input)) { - if (!fs.lstatSync(flags.input).isDirectory()) { - console.info(`The paramenter --input must be a directory`); - return; - } - } - } - - try { - await Db.init(); - - // Make sure the settings exist - await UserSettings.prepareUserSettings(); - const credentialsEntities = (await Db.collections.Credentials?.find()) ?? []; - let i; - if (flags.separate) { - let inputPath = flags.input; - if (process.platform === 'win32') { - inputPath = inputPath.replace(/\\/g, '/'); - } - inputPath = inputPath.replace(/\/$/g, ''); - const files = await glob(`${inputPath}/*.json`); - for (i = 0; i < files.length; i++) { - const workflow = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); - if (credentialsEntities.length > 0) { - // eslint-disable-next-line - workflow.nodes.forEach((node: INode) => { - this.transformCredentials(node, credentialsEntities); - }); - } - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Workflow!.save(workflow); - } - } else { - const fileContents = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); - - if (!Array.isArray(fileContents)) { - throw new Error(`File does not seem to contain workflows.`); - } - - for (i = 0; i < fileContents.length; i++) { - if (credentialsEntities.length > 0) { - // eslint-disable-next-line - fileContents[i].nodes.forEach((node: INode) => { - this.transformCredentials(node, credentialsEntities); - }); - } - // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion - await Db.collections.Workflow!.save(fileContents[i]); - } - } - - console.info(`Successfully imported ${i} ${i === 1 ? 'workflow.' : 'workflows.'}`); - process.exit(0); - } catch (error) { - console.error('An error occurred while exporting workflows. See log messages for details.'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - logger.error(error.message); - this.exit(1); - } - } } diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index c85c9c0c3f..66dcd81eb1 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/await-thenable */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ @@ -12,6 +13,7 @@ import { Command, flags } from '@oclif/command'; import * as Redis from 'ioredis'; import { IDataObject, LoggerProxy } from 'n8n-workflow'; +import { createHash } from 'crypto'; import * as config from '../config'; import { ActiveExecutions, @@ -31,6 +33,7 @@ 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'); @@ -166,6 +169,26 @@ export class Start extends Command { // Make sure the settings exist const userSettings = await UserSettings.prepareUserSettings(); + if (!config.get('userManagement.jwtSecret')) { + // 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. + let baseKey = ''; + for (let i = 0; i < encryptionKey.length; i += 2) { + baseKey += encryptionKey[i]; + } + config.set( + 'userManagement.jwtSecret', + createHash('sha256').update(baseKey).digest('hex'), + ); + } + // Load all node and credential types const loadNodesAndCredentials = LoadNodesAndCredentials(); await loadNodesAndCredentials.init(); @@ -187,6 +210,18 @@ 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); + } + + // Load settings from database and set them to config. + const databaseSettings = await Db.collections.Settings!.find({ loadOnStartup: true }); + databaseSettings.forEach((setting) => { + config.set(setting.key, JSON.parse(setting.value)); + }); + if (config.get('executions.mode') === 'queue') { const redisHost = config.get('queue.bull.redis.host'); const redisPassword = config.get('queue.bull.redis.password'); @@ -319,6 +354,12 @@ export class Start extends Command { const editorUrl = GenericHelpers.getBaseUrl(); this.log(`\nEditor is now accessible via:\n${editorUrl}`); + const saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean; + + if (saveManualExecutions) { + this.log('\nManual executions will be visible only for the owner'); + } + // Allow to open n8n editor by pressing "o" if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) { process.stdin.setRawMode(true); diff --git a/packages/cli/commands/user-management/reset.ts b/packages/cli/commands/user-management/reset.ts new file mode 100644 index 0000000000..404e5a8df1 --- /dev/null +++ b/packages/cli/commands/user-management/reset.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import Command from '@oclif/command'; +import { Not } from 'typeorm'; +import { LoggerProxy } from 'n8n-workflow'; +import { Db } from '../../src'; +import { User } from '../../src/databases/entities/User'; +import { getLogger } from '../../src/Logger'; + +export class Reset extends Command { + static description = '\nResets the database to the default user state'; + + private defaultUserProps = { + firstName: null, + lastName: null, + email: null, + password: null, + resetPasswordToken: null, + }; + + async run(): Promise { + const logger = getLogger(); + LoggerProxy.init(logger); + await Db.init(); + + try { + const owner = await this.getInstanceOwner(); + + const ownerWorkflowRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); + + const ownerCredentialRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + await Db.collections.SharedWorkflow!.update( + { user: { id: Not(owner.id) }, role: ownerWorkflowRole }, + { user: owner }, + ); + + 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.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: 'false' }, + ); + await Db.collections.Settings!.update( + { key: 'userManagement.skipInstanceOwnerSetup' }, + { value: 'false' }, + ); + } catch (error) { + console.error('Error resetting database. See log messages for details.'); + if (error instanceof Error) logger.error(error.message); + this.exit(1); + } + + console.info('Successfully reset the database to default user state.'); + this.exit(); + } + + private async getInstanceOwner(): Promise { + const globalRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); + + 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 })); + + return Db.collections.User!.findOneOrFail({ globalRole }); + } +} diff --git a/packages/cli/commands/worker.ts b/packages/cli/commands/worker.ts index 812bf42802..1f3f3b9fe0 100644 --- a/packages/cli/commands/worker.ts +++ b/packages/cli/commands/worker.ts @@ -41,6 +41,10 @@ import { getLogger } from '../src/Logger'; import * as config from '../config'; import * as Queue from '../src/Queue'; +import { + checkPermissionsForExecution, + getWorkflowOwner, +} from '../src/UserManagement/UserManagementHelper'; export class Worker extends Command { static description = '\nStarts a n8n worker'; @@ -123,6 +127,8 @@ export class Worker extends Command { `Start job: ${job.id} (Workflow ID: ${currentExecutionDb.workflowData.id} | Execution: ${jobData.executionId})`, ); + const workflowOwner = await getWorkflowOwner(currentExecutionDb.workflowData.id!.toString()); + let { staticData } = currentExecutionDb.workflowData; if (jobData.loadStaticData) { const findOptions = { @@ -166,7 +172,10 @@ export class Worker extends Command { settings: currentExecutionDb.workflowData.settings, }); + await checkPermissionsForExecution(workflow, workflowOwner.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + workflowOwner.id, undefined, executionTimeoutTimestamp, ); diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 9ca8a913e3..1e5b25f933 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -351,6 +351,7 @@ const config = convict({ }, }, }, + generic: { // The timezone to use. Is important for nodes like "Cron" which start the // workflow automatically at a specified time. This setting can also be @@ -410,6 +411,12 @@ const config = convict({ env: 'N8N_SSL_CERT', doc: 'SSL Cert for HTTPS Protocol', }, + editorBaseUrl: { + format: String, + default: '', + env: 'N8N_EDITOR_BASE_URL', + doc: 'Public URL where the editor is accessible. Also used for emails sent from n8n.', + }, security: { excludeEndpoints: { @@ -573,6 +580,90 @@ const config = convict({ }, }, + workflowTagsDisabled: { + format: Boolean, + default: false, + env: 'N8N_WORKFLOW_TAGS_DISABLED', + doc: 'Disable worfklow tags.', + }, + + userManagement: { + disabled: { + doc: 'Disable user management and hide it completely.', + format: Boolean, + default: false, + env: 'N8N_USER_MANAGEMENT_DISABLED', + }, + jwtSecret: { + doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts + format: String, + default: '', + env: 'N8N_USER_MANAGEMENT_JWT_SECRET', + }, + emails: { + mode: { + doc: 'How to send emails', + format: ['', 'smtp'], + default: 'smtp', + env: 'N8N_EMAIL_MODE', + }, + smtp: { + host: { + doc: 'SMTP server host', + format: String, // e.g. 'smtp.gmail.com' + default: '', + env: 'N8N_SMTP_HOST', + }, + port: { + doc: 'SMTP server port', + format: Number, + default: 465, + env: 'N8N_SMTP_PORT', + }, + secure: { + doc: 'Whether or not to use SSL for SMTP', + format: Boolean, + default: true, + env: 'N8N_SMTP_SSL', + }, + auth: { + user: { + doc: 'SMTP login username', + format: String, // e.g.'you@gmail.com' + default: '', + env: 'N8N_SMTP_USER', + }, + pass: { + doc: 'SMTP login password', + format: String, + default: '', + env: 'N8N_SMTP_PASS', + }, + }, + sender: { + doc: 'How to display sender name', + format: String, + default: '', + env: 'N8N_SMTP_SENDER', + }, + }, + templates: { + invite: { + doc: 'Overrides default HTML template for inviting new people (use full path)', + format: String, + default: '', + env: 'N8N_UM_EMAIL_TEMPLATES_INVITE', + }, + passwordReset: { + doc: 'Overrides default HTML template for resetting password (use full path)', + format: String, + default: '', + env: 'N8N_UM_EMAIL_TEMPLATES_PWRESET', + }, + }, + }, + }, + externalHookFiles: { doc: 'Files containing external hooks. Multiple files can be separated by colon (":")', format: String, @@ -636,8 +727,8 @@ const config = convict({ logs: { level: { - doc: 'Log output level. Options are error, warn, info, verbose and debug.', - format: String, + doc: 'Log output level', + format: ['error', 'warn', 'info', 'verbose', 'debug'], default: 'info', env: 'N8N_LOG_LEVEL', }, @@ -713,10 +804,10 @@ const config = convict({ doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: String, + format: ['default', 'filesystem'], default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', - doc: 'Storage mode for binary data, default | filesystem', + doc: 'Storage mode for binary data', }, localStoragePath: { format: String, diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js new file mode 100644 index 0000000000..267cc93266 --- /dev/null +++ b/packages/cli/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + verbose: true, + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + testURL: 'http://localhost/', + testRegex: '(/__tests__/.*|(\\.|/)(test))\\.ts$', + testPathIgnorePatterns: ['/dist/', '/node_modules/'], + moduleFileExtensions: ['ts', 'js', 'json'], + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, + globalTeardown: '/test/teardown.ts', + setupFiles: ['/test/setup.ts'], +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 42eb3f4839..b853574ec4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,7 @@ "bin": "n8n" }, "scripts": { - "build": "tsc", + "build": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email", "dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"", "format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write", "lint": "cd ../.. && node_modules/eslint/bin/eslint.js packages/cli", @@ -29,7 +29,10 @@ "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "test": "jest", + "test": "npm run test:sqlite", + "test:sqlite": "export DB_TYPE=sqlite && jest", + "test:postgres": "export DB_TYPE=postgresdb && jest", + "test:mysql": "export DB_TYPE=mysqldb && jest", "watch": "tsc --watch", "typeorm": "ts-node ../../node_modules/typeorm/cli.js" }, @@ -61,23 +64,29 @@ "@types/compression": "1.0.1", "@types/connect-history-api-fallback": "^1.3.1", "@types/convict": "^4.2.1", + "@types/cookie-parser": "^1.4.2", "@types/dotenv": "^8.2.0", "@types/express": "^4.17.6", - "@types/jest": "^26.0.13", + "@types/jest": "^27.4.0", "@types/localtunnel": "^1.9.0", "@types/lodash.get": "^4.4.6", "@types/lodash.merge": "^4.6.6", "@types/node": "14.17.27", "@types/open": "^6.1.0", "@types/parseurl": "^1.3.1", + "@types/passport-jwt": "^3.0.6", "@types/request-promise-native": "~1.0.15", + "@types/superagent": "4.1.13", + "@types/supertest": "^2.0.11", + "@types/uuid": "^8.3.0", "@types/validator": "^13.7.0", "axios": "^0.21.1", "concurrently": "^5.1.0", - "jest": "^26.4.2", + "jest": "^27.4.7", "nodemon": "^2.0.2", "run-script-os": "^1.0.7", - "ts-jest": "^26.3.0", + "supertest": "^6.2.2", + "ts-jest": "^27.1.3", "ts-node": "^8.9.1", "tslint": "^6.1.2", "typescript": "~4.3.5" @@ -94,11 +103,14 @@ "body-parser-xml": "^2.0.3", "bull": "^3.19.0", "callsites": "^3.1.0", + "change-case": "^4.1.1", "class-validator": "^0.13.1", "client-oauth2": "^4.2.5", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", "convict": "^6.0.1", + "cookie-parser": "^1.4.6", + "crypto-js": "^4.1.1", "csrf": "^3.1.0", "dotenv": "^8.0.0", "express": "^4.16.4", @@ -117,33 +129,22 @@ "n8n-editor-ui": "~0.134.0", "n8n-nodes-base": "~0.165.0", "n8n-workflow": "~0.90.0", + "nodemailer": "^6.7.1", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "p-cancelable": "^2.0.0", + "passport": "^0.5.0", + "passport-cookie": "^1.0.9", + "passport-jwt": "^4.0.0", "pg": "^8.3.0", "prom-client": "^13.1.0", "request-promise-native": "^1.0.7", - "sqlite3": "^5.0.1", + "sqlite3": "^5.0.2", "sse-channel": "^3.1.1", "tslib": "1.14.1", "typeorm": "0.2.30", + "uuid": "^8.3.0", + "validator": "13.7.0", "winston": "^3.3.3" - }, - "jest": { - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, - "testURL": "http://localhost/", - "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - "testPathIgnorePatterns": [ - "/dist/", - "/node_modules/" - ], - "moduleFileExtensions": [ - "ts", - "tsx", - "js", - "json" - ] } } diff --git a/packages/cli/packages/cli/database.sqlite b/packages/cli/packages/cli/database.sqlite deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 7f2fdec9da..bc24be4019 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable prefer-spread */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-param-reassign */ @@ -48,6 +49,9 @@ import { ExternalHooks, } from '.'; import config = require('../config'); +import { User } from './databases/entities/User'; +import { whereClause } from './WorkflowHelpers'; +import { WorkflowEntity } from './databases/entities/WorkflowEntity'; const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`; @@ -66,7 +70,8 @@ export class ActiveWorkflowRunner { // 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({ - active: true, + where: { active: true }, + relations: ['shared', 'shared.user', 'shared.user.globalRole'], })) as IWorkflowDb[]; if (!config.get('endpoints.skipWebhoooksDeregistrationOnShutdown')) { @@ -102,7 +107,7 @@ export class ActiveWorkflowRunner { }); console.log(` => Started`); } catch (error) { - console.log(` => ERROR: Workflow could not be activated:`); + console.log(` => ERROR: Workflow could not be activated`); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions console.log(` ${error.message}`); Logger.error(`Unable to initialize workflow "${workflowData.name}" (startup)`, { @@ -251,7 +256,9 @@ 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) { throw new ResponseHelper.ResponseError( `Could not find workflow with id "${webhook.workflowId}"`, @@ -272,7 +279,9 @@ export class ActiveWorkflowRunner { settings: workflowData.settings, }); - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + workflowData.shared[0].user.id, + ); const webhookData = NodeHelpers.getNodeWebhooks( workflow, @@ -336,14 +345,30 @@ export class ActiveWorkflowRunner { * @returns {string[]} * @memberof ActiveWorkflowRunner */ - async getActiveWorkflows(): Promise { - const activeWorkflows = (await Db.collections.Workflow?.find({ - where: { active: true }, - select: ['id'], - })) as IWorkflowDb[]; - return activeWorkflows.filter( - (workflow) => this.activationErrors[workflow.id.toString()] === undefined, - ); + async getActiveWorkflows(user?: User): Promise { + let activeWorkflows: WorkflowEntity[] = []; + + if (!user || user.globalRole.name === 'owner') { + activeWorkflows = await Db.collections.Workflow!.find({ + select: ['id'], + where: { active: true }, + }); + } else { + const shared = await Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: whereClause({ + user, + entityType: 'workflow', + }), + }); + + activeWorkflows = shared.reduce((acc, cur) => { + if (cur.workflow.active) acc.push(cur.workflow); + return acc; + }, []); + } + + return activeWorkflows.filter((workflow) => this.activationErrors[workflow.id] === undefined); } /** @@ -354,8 +379,8 @@ export class ActiveWorkflowRunner { * @memberof ActiveWorkflowRunner */ async isActive(id: string): Promise { - const workflow = (await Db.collections.Workflow?.findOne({ id: Number(id) })) as IWorkflowDb; - return workflow?.active; + const workflow = await Db.collections.Workflow!.findOne(id); + return !!workflow?.active; } /** @@ -462,19 +487,14 @@ export class ActiveWorkflowRunner { ); } - let errorMessage = ''; - // if it's a workflow from the the insert // TODO check if there is standard error code for duplicate key violation that works // with all databases if (error.name === 'QueryFailedError') { - errorMessage = `The webhook path [${webhook.webhookPath}] and method [${webhook.method}] already exist.`; + error.message = `The URL path that the "${webhook.node}" node uses is already taken. Please change it to something else.`; } else if (error.detail) { // it's a error runnig the webhook methods (checkExists, create) - errorMessage = error.detail; - } else { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - errorMessage = error.message; + error.message = error.detail; } throw error; @@ -492,7 +512,9 @@ export class ActiveWorkflowRunner { * @memberof ActiveWorkflowRunner */ async removeWorkflowWebhooks(workflowId: string): Promise { - 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) { throw new Error(`Could not find workflow with id "${workflowId}"`); } @@ -511,7 +533,9 @@ export class ActiveWorkflowRunner { const mode = 'internal'; - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + workflowData.shared[0].user.id, + ); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); @@ -578,6 +602,7 @@ export class ActiveWorkflowRunner { // Start the workflow const runData: IWorkflowExecutionDataProcess = { + userId: additionalData.userId, executionMode: mode, executionData, workflowData, @@ -681,7 +706,9 @@ export class ActiveWorkflowRunner { let workflowInstance: Workflow; try { if (workflowData === undefined) { - workflowData = (await Db.collections.Workflow!.findOne(workflowId)) as IWorkflowDb; + workflowData = (await Db.collections.Workflow!.findOne(workflowId, { + relations: ['shared', 'shared.user', 'shared.user.globalRole'], + })) as IWorkflowDb; } if (!workflowData) { @@ -710,7 +737,9 @@ export class ActiveWorkflowRunner { } const mode = 'trigger'; - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + (workflowData as WorkflowEntity).shared[0].user.id, + ); const getTriggerFunctions = this.getExecuteTriggerFunctions( workflowData, additionalData, diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index e7abf5b0ae..6806dd0294 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -35,6 +35,7 @@ import { Workflow, WorkflowExecuteMode, ITaskDataConnections, + LoggerProxy as Logger, } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle @@ -44,8 +45,11 @@ import { Db, ICredentialsDb, NodeTypes, + WhereClause, WorkflowExecuteAdditionalData, } from '.'; +// eslint-disable-next-line import/no-cycle +import { User } from './databases/entities/User'; const mockNodeTypes: INodeTypes = { nodeTypes: {} as INodeTypeData, @@ -209,32 +213,40 @@ export class CredentialsHelper extends ICredentialsHelper { /** * Returns the credentials instance * - * @param {INodeCredentialsDetails} nodeCredentials id and name to return instance of - * @param {string} type Type of the credentials to return instance of + * @param {INodeCredentialsDetails} nodeCredential id and name to return instance of + * @param {string} type Type of the credential to return instance of * @returns {Credentials} * @memberof CredentialsHelper */ async getCredentials( - nodeCredentials: INodeCredentialsDetails, + nodeCredential: INodeCredentialsDetails, type: string, + userId?: string, ): Promise { - if (!nodeCredentials.id) { - throw new Error(`Credentials "${nodeCredentials.name}" for type "${type}" don't have an ID.`); + if (!nodeCredential.id) { + throw new Error(`Credential "${nodeCredential.name}" of type "${type}" has no ID.`); } - const credentials = await Db.collections.Credentials?.findOne({ id: nodeCredentials.id, type }); + const credential = userId + ? 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 }); - if (!credentials) { + if (!credential) { throw new Error( - `Credentials with ID "${nodeCredentials.id}" don't exist for type "${type}".`, + `Credential with ID "${nodeCredential.id}" does not exist for type "${type}".`, ); } return new Credentials( - { id: credentials.id.toString(), name: credentials.name }, - credentials.type, - credentials.nodesAccess, - credentials.data, + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, ); } @@ -504,6 +516,7 @@ export class CredentialsHelper extends ICredentialsHelper { } async testCredentials( + user: User, credentialType: string, credentialsDecrypted: ICredentialsDecrypted, nodeToTestWith?: string, @@ -602,7 +615,7 @@ export class CredentialsHelper extends ICredentialsHelper { }, }; - const additionalData = await WorkflowExecuteAdditionalData.getBase(node.parameters); + const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id, node.parameters); const routingNode = new RoutingNode( workflow, @@ -656,7 +669,7 @@ export class CredentialsHelper extends ICredentialsHelper { }; } } - + Logger.debug('Credential test failed', error); return { status: 'Error', message: error.message.toString(), @@ -669,3 +682,47 @@ export class CredentialsHelper extends ICredentialsHelper { }; } } + +/** + * Build a `where` clause for a `find()` or `findOne()` operation + * in the `shared_workflow` or `shared_credentials` tables. + */ +export function whereClause({ + user, + entityType, + entityId = '', +}: { + user: User; + entityType: 'workflow' | 'credentials'; + entityId?: string; +}): WhereClause { + const where: WhereClause = entityId ? { [entityType]: { id: entityId } } : {}; + + if (user.globalRole.name !== 'owner') { + where.user = { id: user.id }; + } + + return where; +} + +/** + * Get a credential if it has been shared with a user. + */ +export async function getCredentialForUser( + credentialId: string, + user: User, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sharedCredential = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!sharedCredential) return null; + + return sharedCredential.credentials as ICredentialsDb; +} diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index cf5f368494..db788c521b 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -1,9 +1,19 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable no-case-declarations */ /* eslint-disable @typescript-eslint/naming-convention */ import { UserSettings } from 'n8n-core'; -import { ConnectionOptions, createConnection, getRepository, LoggerOptions } from 'typeorm'; +import { + Connection, + ConnectionOptions, + createConnection, + EntityManager, + EntityTarget, + getRepository, + LoggerOptions, + Repository, +} from 'typeorm'; import { TlsOptions } from 'tls'; import * as path from 'path'; // eslint-disable-next-line import/no-cycle @@ -24,9 +34,26 @@ export const collections: IDatabaseCollections = { Workflow: null, Webhook: null, Tag: null, + Role: null, + User: null, + SharedCredentials: null, + SharedWorkflow: null, + Settings: null, }; -export async function init(): Promise { +let connection: Connection; + +export async function transaction(fn: (entityManager: EntityManager) => Promise): Promise { + return connection.transaction(fn); +} + +export function linkRepository(entityClass: EntityTarget): Repository { + return getRepository(entityClass, connection.name); +} + +export async function init( + testConnectionOptions?: ConnectionOptions, +): Promise { const dbType = (await GenericHelpers.getConfigValue('database.type')) as DatabaseType; const n8nFolder = UserSettings.getUserN8nFolderPath(); @@ -34,74 +61,80 @@ export async function init(): Promise { const entityPrefix = config.get('database.tablePrefix'); - switch (dbType) { - case 'postgresdb': - const sslCa = (await GenericHelpers.getConfigValue('database.postgresdb.ssl.ca')) as string; - const sslCert = (await GenericHelpers.getConfigValue( - 'database.postgresdb.ssl.cert', - )) as string; - const sslKey = (await GenericHelpers.getConfigValue('database.postgresdb.ssl.key')) as string; - const sslRejectUnauthorized = (await GenericHelpers.getConfigValue( - 'database.postgresdb.ssl.rejectUnauthorized', - )) as boolean; + if (testConnectionOptions) { + connectionOptions = testConnectionOptions; + } else { + switch (dbType) { + case 'postgresdb': + const sslCa = (await GenericHelpers.getConfigValue('database.postgresdb.ssl.ca')) as string; + const sslCert = (await GenericHelpers.getConfigValue( + 'database.postgresdb.ssl.cert', + )) as string; + const sslKey = (await GenericHelpers.getConfigValue( + 'database.postgresdb.ssl.key', + )) as string; + const sslRejectUnauthorized = (await GenericHelpers.getConfigValue( + 'database.postgresdb.ssl.rejectUnauthorized', + )) as boolean; - let ssl: TlsOptions | undefined; - if (sslCa !== '' || sslCert !== '' || sslKey !== '' || !sslRejectUnauthorized) { - ssl = { - ca: sslCa || undefined, - cert: sslCert || undefined, - key: sslKey || undefined, - rejectUnauthorized: sslRejectUnauthorized, + let ssl: TlsOptions | undefined; + if (sslCa !== '' || sslCert !== '' || sslKey !== '' || !sslRejectUnauthorized) { + ssl = { + ca: sslCa || undefined, + cert: sslCert || undefined, + key: sslKey || undefined, + rejectUnauthorized: sslRejectUnauthorized, + }; + } + + connectionOptions = { + type: 'postgres', + entityPrefix, + database: (await GenericHelpers.getConfigValue('database.postgresdb.database')) as string, + host: (await GenericHelpers.getConfigValue('database.postgresdb.host')) as string, + password: (await GenericHelpers.getConfigValue('database.postgresdb.password')) as string, + port: (await GenericHelpers.getConfigValue('database.postgresdb.port')) as number, + username: (await GenericHelpers.getConfigValue('database.postgresdb.user')) as string, + schema: config.get('database.postgresdb.schema'), + migrations: postgresMigrations, + migrationsRun: true, + migrationsTableName: `${entityPrefix}migrations`, + ssl, }; - } - connectionOptions = { - type: 'postgres', - entityPrefix, - database: (await GenericHelpers.getConfigValue('database.postgresdb.database')) as string, - host: (await GenericHelpers.getConfigValue('database.postgresdb.host')) as string, - password: (await GenericHelpers.getConfigValue('database.postgresdb.password')) as string, - port: (await GenericHelpers.getConfigValue('database.postgresdb.port')) as number, - username: (await GenericHelpers.getConfigValue('database.postgresdb.user')) as string, - schema: config.get('database.postgresdb.schema'), - migrations: postgresMigrations, - migrationsRun: true, - migrationsTableName: `${entityPrefix}migrations`, - ssl, - }; + break; - break; + case 'mariadb': + case 'mysqldb': + connectionOptions = { + type: dbType === 'mysqldb' ? 'mysql' : 'mariadb', + database: (await GenericHelpers.getConfigValue('database.mysqldb.database')) as string, + entityPrefix, + host: (await GenericHelpers.getConfigValue('database.mysqldb.host')) as string, + password: (await GenericHelpers.getConfigValue('database.mysqldb.password')) as string, + port: (await GenericHelpers.getConfigValue('database.mysqldb.port')) as number, + username: (await GenericHelpers.getConfigValue('database.mysqldb.user')) as string, + migrations: mysqlMigrations, + migrationsRun: true, + migrationsTableName: `${entityPrefix}migrations`, + timezone: 'Z', // set UTC as default + }; + break; - case 'mariadb': - case 'mysqldb': - connectionOptions = { - type: dbType === 'mysqldb' ? 'mysql' : 'mariadb', - database: (await GenericHelpers.getConfigValue('database.mysqldb.database')) as string, - entityPrefix, - host: (await GenericHelpers.getConfigValue('database.mysqldb.host')) as string, - password: (await GenericHelpers.getConfigValue('database.mysqldb.password')) as string, - port: (await GenericHelpers.getConfigValue('database.mysqldb.port')) as number, - username: (await GenericHelpers.getConfigValue('database.mysqldb.user')) as string, - migrations: mysqlMigrations, - migrationsRun: true, - migrationsTableName: `${entityPrefix}migrations`, - timezone: 'Z', // set UTC as default - }; - break; + case 'sqlite': + connectionOptions = { + type: 'sqlite', + database: path.join(n8nFolder, 'database.sqlite'), + entityPrefix, + migrations: sqliteMigrations, + migrationsRun: false, // migrations for sqlite will be ran manually for now; see below + migrationsTableName: `${entityPrefix}migrations`, + }; + break; - case 'sqlite': - connectionOptions = { - type: 'sqlite', - database: path.join(n8nFolder, 'database.sqlite'), - entityPrefix, - migrations: sqliteMigrations, - migrationsRun: false, // migrations for sqlite will be ran manually for now; see below - migrationsTableName: `${entityPrefix}migrations`, - }; - break; - - default: - throw new Error(`The database "${dbType}" is currently not supported!`); + default: + throw new Error(`The database "${dbType}" is currently not supported!`); + } } let loggingOption: LoggerOptions = (await GenericHelpers.getConfigValue( @@ -129,9 +162,9 @@ export async function init(): Promise { )) as string, }); - let connection = await createConnection(connectionOptions); + connection = await createConnection(connectionOptions); - if (dbType === 'sqlite') { + if (!testConnectionOptions && dbType === 'sqlite') { // This specific migration changes database metadata. // A field is now nullable. We need to reconnect so that // n8n knows it has changed. Happens only on sqlite. @@ -157,11 +190,17 @@ export async function init(): Promise { } } - collections.Credentials = getRepository(entities.CredentialsEntity); - collections.Execution = getRepository(entities.ExecutionEntity); - collections.Workflow = getRepository(entities.WorkflowEntity); - collections.Webhook = getRepository(entities.WebhookEntity); - collections.Tag = getRepository(entities.TagEntity); + collections.Credentials = linkRepository(entities.CredentialsEntity); + collections.Execution = linkRepository(entities.ExecutionEntity); + collections.Workflow = linkRepository(entities.WorkflowEntity); + collections.Webhook = linkRepository(entities.WebhookEntity); + collections.Tag = linkRepository(entities.TagEntity); + + collections.Role = linkRepository(entities.Role); + collections.User = linkRepository(entities.User); + collections.SharedCredentials = linkRepository(entities.SharedCredentials); + collections.SharedWorkflow = linkRepository(entities.SharedWorkflow); + collections.Settings = linkRepository(entities.Settings); return collections; } diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 73a2310929..9514c202d1 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-unsafe-return */ @@ -8,14 +9,18 @@ import * as express from 'express'; import { join as pathJoin } from 'path'; import { readFile as fsReadFile } from 'fs/promises'; import { IDataObject } from 'n8n-workflow'; +import { validate } from 'class-validator'; import * as config from '../config'; // eslint-disable-next-line import/no-cycle -import { Db, ICredentialsDb, IPackageVersions } from '.'; +import { Db, ICredentialsDb, IPackageVersions, ResponseHelper } from '.'; // eslint-disable-next-line import/order import { Like } from 'typeorm'; // eslint-disable-next-line import/no-cycle import { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { CredentialsEntity } from './databases/entities/CredentialsEntity'; +import { TagEntity } from './databases/entities/TagEntity'; +import { User } from './databases/entities/User'; let versionCache: IPackageVersions | undefined; @@ -188,3 +193,23 @@ export async function generateUniqueName( return { name: `${requestedName} ${maxSuffix + 1}` }; } + +export async function validateEntity( + entity: WorkflowEntity | CredentialsEntity | TagEntity | User, +): Promise { + const errors = await validate(entity); + + const errorMessages = errors + .reduce((acc, cur) => { + if (!cur.constraints) return acc; + acc.push(...Object.values(cur.constraints)); + return acc; + }, []) + .join(' | '); + + if (errorMessages) { + throw new ResponseHelper.ResponseError(errorMessages, undefined, 400); + } +} + +export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 69be4445ac..ac8290140c 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -29,6 +29,11 @@ import { Url } from 'url'; import { Request } from 'express'; import { WorkflowEntity } from './databases/entities/WorkflowEntity'; import { TagEntity } from './databases/entities/TagEntity'; +import { Role } from './databases/entities/Role'; +import { User } from './databases/entities/User'; +import { SharedCredentials } from './databases/entities/SharedCredentials'; +import { SharedWorkflow } from './databases/entities/SharedWorkflow'; +import { Settings } from './databases/entities/Settings'; export interface IActivationError { time: number; @@ -72,6 +77,11 @@ export interface IDatabaseCollections { Workflow: Repository | null; Webhook: Repository | null; Tag: Repository | null; + Role: Repository | null; + User: Repository | null; + SharedCredentials: Repository | null; + SharedWorkflow: Repository | null; + Settings: Repository | null; } export interface IWebhookDb { @@ -83,6 +93,16 @@ export interface IWebhookDb { pathLength?: number; } +// ---------------------------------- +// settings +// ---------------------------------- + +export interface ISettingsDb { + key: string; + value: string | boolean | IDataObject | number; + loadOnStartup: boolean; +} + // ---------------------------------- // tags // ---------------------------------- @@ -313,6 +333,16 @@ export interface IDiagnosticInfo { }; deploymentType: string; binaryDataMode: string; + n8n_multi_user_allowed: boolean; + smtp_set_up: boolean; +} + +export interface ITelemetryUserDeletionData { + user_id: string; + target_user_old_status: 'active' | 'invited'; + migration_strategy?: 'transfer_data' | 'delete_data'; + target_user_id?: string; + migration_user_id?: string; } export interface IInternalHooksClass { @@ -321,15 +351,29 @@ export interface IInternalHooksClass { diagnosticInfo: IDiagnosticInfo, firstWorkflowCreatedAt?: Date, ): Promise; - onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise; - onWorkflowCreated(workflow: IWorkflowBase): Promise; - onWorkflowDeleted(workflowId: string): Promise; - onWorkflowSaved(workflow: IWorkflowBase): Promise; + onPersonalizationSurveySubmitted(userId: string, answers: Record): Promise; + onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise; + onWorkflowDeleted(userId: string, workflowId: string): Promise; + onWorkflowSaved(userId: string, workflow: IWorkflowBase): Promise; onWorkflowPostExecute( executionId: string, workflow: IWorkflowBase, runData?: IRun, + userId?: string, ): Promise; + onUserDeletion(userId: string, userDeletionData: ITelemetryUserDeletionData): Promise; + onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise; + onUserReinvite(userReinviteData: { user_id: string; target_user_id: string }): Promise; + onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise; + onUserInviteEmailClick(userInviteClickData: { user_id: string }): Promise; + onUserPasswordResetEmailClick(userPasswordResetData: { user_id: string }): Promise; + onUserTransactionalEmail(userTransactionalEmailData: { + user_id: string; + message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + }): Promise; + onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise; + onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise; + onUserSignup(userSignupData: { user_id: string }): Promise; } export interface IN8nConfig { @@ -402,6 +446,7 @@ export interface IN8nUISettings { }; timezone: string; urlBaseWebhook: string; + urlBaseEditor: string; versionCli: string; n8nMetadata?: { [key: string]: string | number | undefined; @@ -409,8 +454,10 @@ export interface IN8nUISettings { versionNotifications: IVersionNotificationSettings; instanceId: string; telemetry: ITelemetrySettings; - personalizationSurvey: IPersonalizationSurvey; + personalizationSurveyEnabled: boolean; defaultLocale: string; + userManagement: IUserManagementSettings; + workflowTagsDisabled: boolean; logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose'; hiringBannerEnabled: boolean; templates: { @@ -428,9 +475,10 @@ export interface IPersonalizationSurveyAnswers { workArea: string[] | string | null; } -export interface IPersonalizationSurvey { - answers?: IPersonalizationSurveyAnswers; - shouldShow: boolean; +export interface IUserManagementSettings { + enabled: boolean; + showSetupOnFirstLoad?: boolean; + smtpSetup: boolean; } export interface IPackageVersions { @@ -556,6 +604,7 @@ export interface IWorkflowExecutionDataProcess { sessionId?: string; startNodes?: string[]; workflowData: IWorkflowBase; + userId: string; } export interface IWorkflowExecutionDataProcessWithExecution extends IWorkflowExecutionDataProcess { @@ -563,6 +612,7 @@ export interface IWorkflowExecutionDataProcessWithExecution extends IWorkflowExe credentialsTypeData: ICredentialsTypeData; executionId: string; nodeTypeData: ITransferNodeTypes; + userId: string; } export interface IWorkflowExecuteProcess { @@ -570,3 +620,5 @@ export interface IWorkflowExecuteProcess { workflow: Workflow; workflowExecute: WorkflowExecute; } + +export type WhereClause = Record; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 2f48c77448..55a39ed88d 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,10 +1,11 @@ /* eslint-disable import/no-cycle */ import { BinaryDataManager } from 'n8n-core'; import { IDataObject, INodeTypes, IRun, TelemetryHelpers } from 'n8n-workflow'; +import { snakeCase } from 'change-case'; import { IDiagnosticInfo, IInternalHooksClass, - IPersonalizationSurveyAnswers, + ITelemetryUserDeletionData, IWorkflowBase, IWorkflowDb, } from '.'; @@ -34,6 +35,8 @@ export class InternalHooksClass implements IInternalHooksClass { execution_variables: diagnosticInfo.executionVariables, n8n_deployment_type: diagnosticInfo.deploymentType, n8n_binary_data_mode: diagnosticInfo.binaryDataMode, + n8n_multi_user_allowed: diagnosticInfo.n8n_multi_user_allowed, + smtp_set_up: diagnosticInfo.smtp_set_up, }; return Promise.all([ @@ -45,41 +48,49 @@ export class InternalHooksClass implements IInternalHooksClass { ]); } - async onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise { - return this.telemetry.track('User responded to personalization questions', { - company_size: answers.companySize, - coding_skill: answers.codingSkill, - work_area: answers.workArea, - other_work_area: answers.otherWorkArea, - company_industry: answers.companyIndustry, - other_company_industry: answers.otherCompanyIndustry, + async onPersonalizationSurveySubmitted( + userId: string, + answers: Record, + ): Promise { + const camelCaseKeys = Object.keys(answers); + const personalizationSurveyData = { user_id: userId } as Record; + camelCaseKeys.forEach((camelCaseKey) => { + personalizationSurveyData[snakeCase(camelCaseKey)] = answers[camelCaseKey]; }); + + return this.telemetry.track( + 'User responded to personalization questions', + personalizationSurveyData, + ); } - async onWorkflowCreated(workflow: IWorkflowBase): Promise { + async onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); return this.telemetry.track('User created workflow', { + user_id: userId, workflow_id: workflow.id, node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), }); } - async onWorkflowDeleted(workflowId: string): Promise { + async onWorkflowDeleted(userId: string, workflowId: string): Promise { return this.telemetry.track('User deleted workflow', { + user_id: userId, workflow_id: workflowId, }); } - async onWorkflowSaved(workflow: IWorkflowDb): Promise { + async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); return this.telemetry.track('User saved workflow', { + user_id: userId, workflow_id: workflow.id, node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), version_cli: this.versionCli, - num_tags: workflow.tags.length, + num_tags: workflow.tags?.length ?? 0, }); } @@ -87,6 +98,7 @@ export class InternalHooksClass implements IInternalHooksClass { executionId: string, workflow: IWorkflowBase, runData?: IRun, + userId?: string, ): Promise { const promises = [Promise.resolve()]; const properties: IDataObject = { @@ -95,6 +107,10 @@ export class InternalHooksClass implements IInternalHooksClass { version_cli: this.versionCli, }; + if (userId) { + properties.user_id = userId; + } + if (runData !== undefined) { properties.execution_mode = runData.mode; properties.success = !!runData.finished; @@ -188,4 +204,72 @@ export class InternalHooksClass implements IInternalHooksClass { return Promise.race([timeoutPromise, this.telemetry.trackN8nStop()]); } + + async onUserDeletion( + userId: string, + userDeletionData: ITelemetryUserDeletionData, + ): Promise { + return this.telemetry.track('User deleted user', { ...userDeletionData, user_id: userId }); + } + + async onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise { + return this.telemetry.track('User invited new user', userInviteData); + } + + async onUserReinvite(userReinviteData: { + user_id: string; + target_user_id: string; + }): Promise { + return this.telemetry.track('User resent new user invite email', userReinviteData); + } + + async onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise { + return this.telemetry.track('User changed personal settings', userUpdateData); + } + + async onUserInviteEmailClick(userInviteClickData: { user_id: string }): Promise { + return this.telemetry.track('User clicked invite link from email', userInviteClickData); + } + + async onUserPasswordResetEmailClick(userPasswordResetData: { user_id: string }): Promise { + return this.telemetry.track( + 'User clicked password reset link from email', + userPasswordResetData, + ); + } + + async onUserTransactionalEmail(userTransactionalEmailData: { + user_id: string; + message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + }): Promise { + return this.telemetry.track( + 'Instance sent transactional email to user', + userTransactionalEmailData, + ); + } + + async onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise { + return this.telemetry.track( + 'User requested password reset while logged out', + userPasswordResetData, + ); + } + + async onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise { + return this.telemetry.track('Owner finished instance setup', instanceOwnerSetupData); + } + + async onUserSignup(userSignupData: { user_id: string }): Promise { + return this.telemetry.track('User signed up', userSignupData); + } + + async onEmailFailed(failedEmailData: { + user_id: string; + message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + }): Promise { + return this.telemetry.track( + 'Instance failed to send transactional email to user', + failedEmailData, + ); + } } diff --git a/packages/cli/src/PersonalizationSurvey.ts b/packages/cli/src/PersonalizationSurvey.ts deleted file mode 100644 index b384b4894c..0000000000 --- a/packages/cli/src/PersonalizationSurvey.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync, writeFile } from 'fs'; -import { promisify } from 'util'; -import { UserSettings } from 'n8n-core'; - -import * as config from '../config'; -// eslint-disable-next-line import/no-cycle -import { Db, IPersonalizationSurvey, IPersonalizationSurveyAnswers } from '.'; - -const fsWriteFile = promisify(writeFile); - -const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; - -function loadSurveyFromDisk(): IPersonalizationSurveyAnswers | undefined { - const userSettingsPath = UserSettings.getUserN8nFolderPath(); - try { - const surveyFile = readFileSync( - `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`, - 'utf-8', - ); - return JSON.parse(surveyFile) as IPersonalizationSurveyAnswers; - } catch (error) { - return undefined; - } -} - -export async function writeSurveyToDisk( - surveyAnswers: IPersonalizationSurveyAnswers, -): Promise { - const userSettingsPath = UserSettings.getUserN8nFolderPath(); - await fsWriteFile( - `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`, - JSON.stringify(surveyAnswers, null, '\t'), - ); -} - -export async function preparePersonalizationSurvey(): Promise { - const survey: IPersonalizationSurvey = { - shouldShow: false, - }; - - survey.answers = loadSurveyFromDisk(); - - if (survey.answers) { - return survey; - } - - const enabled = - (config.get('personalization.enabled') as boolean) && - (config.get('diagnostics.enabled') as boolean); - - if (!enabled) { - return survey; - } - - const workflowsExist = !!(await Db.collections.Workflow?.findOne()); - - if (workflowsExist) { - return survey; - } - - survey.shouldShow = true; - return survey; -} diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index e8430c695d..747ca0211d 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ @@ -101,6 +102,8 @@ export function sendErrorResponse(res: Response, error: ResponseError, shouldLog httpStatusCode = error.httpStatusCode; } + shouldLog = !process.argv[1].split('/').includes('jest'); + if (process.env.NODE_ENV !== 'production' && shouldLog) { console.error('ERROR RESPONSE'); console.error(error); @@ -133,6 +136,9 @@ export function sendErrorResponse(res: Response, error: ResponseError, shouldLog res.status(httpStatusCode).json(response); } +const isUniqueConstraintError = (error: Error) => + ['unique', 'duplicate'].some((s) => error.message.toLowerCase().includes(s)); + /** * A helper function which does not just allow to return Promises it also makes sure that * all the responses have the same format @@ -148,10 +154,12 @@ export function send(processFunction: (req: Request, res: Response) => Promise { const enableMetrics = config.get('endpoints.metrics.enable') as boolean; let register: Registry; @@ -320,9 +376,6 @@ class App { this.frontendSettings.instanceId = await UserSettings.getInstanceId(); - this.frontendSettings.personalizationSurvey = - await PersonalizationSurvey.preparePersonalizationSurvey(); - await this.externalHooks.run('frontend.settings', [this.frontendSettings]); const excludeEndpoints = config.get('security.excludeEndpoints') as string; @@ -365,7 +418,11 @@ class App { this.app.use( async (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (authIgnoreRegex.exec(req.url)) { + // Skip basic auth for a few listed endpoints or when instance owner has been setup + if ( + authIgnoreRegex.exec(req.url) || + config.get('userManagement.isInstanceOwnerSetUp') + ) { return next(); } const realm = 'n8n - Editor UI'; @@ -500,20 +557,32 @@ class App { }); } + // Parse cookies for easier access + this.app.use(cookieParser()); + // Get push connections - this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { - if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) { - // TODO: Later also has to add some kind of authentication token - if (req.query.sessionId === undefined) { - next(new Error('The query parameter "sessionId" is missing!')); + this.app.use( + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) { + if (req.query.sessionId === undefined) { + next(new Error('The query parameter "sessionId" is missing!')); + return; + } + + try { + const authCookie = req.cookies?.[AUTH_COOKIE_NAME] ?? ''; + await resolveJwt(authCookie); + } catch (error) { + res.status(401).send('Unauthorized'); + return; + } + + this.push.add(req.query.sessionId as string, req, res); return; } - - this.push.add(req.query.sessionId as string, req, res); - return; - } - next(); - }); + next(); + }, + ); // Compress the response data this.app.use(compression()); @@ -593,6 +662,7 @@ class App { this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { // Allow access also from frontend when developing res.header('Access-Control-Allow-Origin', 'http://localhost:8080'); + res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE'); res.header( 'Access-Control-Allow-Headers', @@ -612,6 +682,13 @@ class App { next(); }); + // ---------------------------------------- + // User Management + // ---------------------------------------- + await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]); + + this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); + // ---------------------------------------- // Healthcheck // ---------------------------------------- @@ -663,42 +740,69 @@ class App { // Creates a new workflow this.app.post( `/${this.restEndpoint}/workflows`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - delete req.body.id; // ignore if sent by mistake - const incomingData = req.body; + ResponseHelper.send(async (req: WorkflowRequest.Create) => { + delete req.body.id; // delete if sent - const newWorkflow = new WorkflowEntity(); + const newWorkflow = new WorkflowEntity(); - Object.assign(newWorkflow, incomingData); - newWorkflow.name = incomingData.name.trim(); + Object.assign(newWorkflow, req.body); - const incomingTagOrder = incomingData.tags.slice(); + await validateEntity(newWorkflow); - if (incomingData.tags.length) { - newWorkflow.tags = await Db.collections.Tag!.findByIds(incomingData.tags, { - select: ['id', 'name'], - }); - } + await this.externalHooks.run('workflow.create', [newWorkflow]); - // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); + const { tags: tagIds } = req.body; - await this.externalHooks.run('workflow.create', [newWorkflow]); + if (tagIds?.length && !config.get('workflowTagsDisabled')) { + newWorkflow.tags = await Db.collections.Tag!.findByIds(tagIds, { + select: ['id', 'name'], + }); + } - await WorkflowHelpers.validateWorkflow(newWorkflow); - const savedWorkflow = (await Db.collections - .Workflow!.save(newWorkflow) - .catch(WorkflowHelpers.throwDuplicateEntryError)) as WorkflowEntity; - savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, incomingTagOrder); + await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); - // @ts-ignore - savedWorkflow.id = savedWorkflow.id.toString(); - await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase); - return savedWorkflow; - }, - ), + let savedWorkflow: undefined | WorkflowEntity; + + await getConnection().transaction(async (transactionManager) => { + savedWorkflow = await transactionManager.save(newWorkflow); + + const role = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); + + const newSharedWorkflow = new SharedWorkflow(); + + Object.assign(newSharedWorkflow, { + role, + user: req.user, + workflow: savedWorkflow, + }); + + await transactionManager.save(newSharedWorkflow); + }); + + if (!savedWorkflow) { + LoggerProxy.error('Failed to create workflow', { userId: req.user.id }); + throw new ResponseHelper.ResponseError('Failed to save workflow'); + } + + if (tagIds && !config.get('workflowTagsDisabled')) { + savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, { + requestOrder: tagIds, + }); + } + + await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); + void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow); + + const { id, ...rest } = savedWorkflow; + + return { + id: id.toString(), + ...rest, + }; + }), ); // Reads and returns workflow data from an URL @@ -757,192 +861,294 @@ class App { // Returns workflows this.app.get( `/${this.restEndpoint}/workflows`, - ResponseHelper.send(async (req: express.Request, res: express.Response) => { - const findQuery: FindManyOptions = { + ResponseHelper.send(async (req: WorkflowRequest.GetAll) => { + let workflows: WorkflowEntity[] = []; + + const filter: Record = req.query.filter ? JSON.parse(req.query.filter) : {}; + + const query: FindManyOptions = { select: ['id', 'name', 'active', 'createdAt', 'updatedAt'], relations: ['tags'], }; - if (req.query.filter) { - findQuery.where = JSON.parse(req.query.filter as string); + if (config.get('workflowTagsDisabled')) { + delete query.relations; } - const workflows = await Db.collections.Workflow!.find(findQuery); + if (req.user.globalRole.name === 'owner') { + workflows = await Db.collections.Workflow!.find( + Object.assign(query, { + where: filter, + }), + ); + } else { + const shared = await Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + }), + }); - workflows.forEach((workflow) => { - // @ts-ignore - workflow.id = workflow.id.toString(); - // @ts-ignore - workflow.tags = workflow.tags.map(({ id, name }) => ({ id: id.toString(), name })); + if (!shared.length) return []; + + workflows = await Db.collections.Workflow!.find( + Object.assign(query, { + where: { + id: In(shared.map(({ workflow }) => workflow.id)), + ...filter, + }, + }), + ); + } + + return workflows.map((workflow) => { + const { id, ...rest } = workflow; + + return { + id: id.toString(), + ...rest, + }; }); - return workflows; }), ); this.app.get( `/${this.restEndpoint}/workflows/new`, - ResponseHelper.send( - async (req: NameRequest, res: express.Response): Promise<{ name: string }> => { - const requestedName = - req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName; + ResponseHelper.send(async (req: WorkflowRequest.NewName) => { + const requestedName = + req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName; - return await GenericHelpers.generateUniqueName(requestedName, 'workflow'); - }, - ), + return await GenericHelpers.generateUniqueName(requestedName, 'workflow'); + }), ); // Returns a specific workflow this.app.get( `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send( - async ( - req: express.Request, - res: express.Response, - ): Promise => { - const workflow = await Db.collections.Workflow!.findOne(req.params.id, { - relations: ['tags'], + ResponseHelper.send(async (req: WorkflowRequest.Get) => { + const { id: workflowId } = req.params; + + let relations = ['workflow', 'workflow.tags']; + + if (config.get('workflowTagsDisabled')) { + relations = relations.filter((relation) => relation !== 'workflow.tags'); + } + + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations, + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); + + if (!shared) { + LoggerProxy.info('User attempted to access a workflow without permissions', { + workflowId, + userId: req.user.id, }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found.`, + undefined, + 404, + ); + } - if (workflow === undefined) { - return undefined; - } + const { + workflow: { id, ...rest }, + } = shared; - // @ts-ignore - workflow.id = workflow.id.toString(); - // @ts-ignore - workflow.tags.forEach((tag) => (tag.id = tag.id.toString())); - return workflow; - }, - ), + return { + id: id.toString(), + ...rest, + }; + }), ); // Updates an existing workflow this.app.patch( `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const { tags, ...updateData } = req.body; + ResponseHelper.send(async (req: WorkflowRequest.Update) => { + const { id: workflowId } = req.params; - const { id } = req.params; - updateData.id = id; + const updateData = new WorkflowEntity(); + const { tags, ...rest } = req.body; + Object.assign(updateData, rest); - // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(updateData as WorkflowEntity); + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); - await this.externalHooks.run('workflow.update', [updateData]); + if (!shared) { + LoggerProxy.info('User attempted to update a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be updated.`, + undefined, + 404, + ); + } - const isActive = await this.activeWorkflowRunner.isActive(id); + // check credentials for old format + await WorkflowHelpers.replaceInvalidCredentials(updateData); - if (isActive) { - // When workflow gets saved always remove it as the triggers could have been - // changed and so the changes would not take effect - await this.activeWorkflowRunner.remove(id); + await this.externalHooks.run('workflow.update', [updateData]); + + if (shared.workflow.active) { + // When workflow gets saved always remove it as the triggers could have been + // changed and so the changes would not take effect + await this.activeWorkflowRunner.remove(workflowId); + } + + if (updateData.settings) { + if (updateData.settings.timezone === 'DEFAULT') { + // Do not save the default timezone + delete updateData.settings.timezone; } - - if (updateData.settings) { - if (updateData.settings.timezone === 'DEFAULT') { - // Do not save the default timezone - delete updateData.settings.timezone; - } - if (updateData.settings.saveDataErrorExecution === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveDataErrorExecution; - } - if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveDataSuccessExecution; - } - if (updateData.settings.saveManualExecutions === 'DEFAULT') { - // Do not save when default got set - delete updateData.settings.saveManualExecutions; - } - if ( - parseInt(updateData.settings.executionTimeout as string, 10) === this.executionTimeout - ) { - // Do not save when default got set - delete updateData.settings.executionTimeout; - } + if (updateData.settings.saveDataErrorExecution === 'DEFAULT') { + // Do not save when default got set + delete updateData.settings.saveDataErrorExecution; } - - // required due to atomic update - updateData.updatedAt = this.getCurrentDate(); - - await WorkflowHelpers.validateWorkflow(updateData); - await Db.collections - .Workflow!.update(id, updateData) - .catch(WorkflowHelpers.throwDuplicateEntryError); - - if (tags) { - const tablePrefix = config.get('database.tablePrefix'); - await TagHelpers.removeRelations(req.params.id, tablePrefix); - - if (tags.length) { - await TagHelpers.createRelations(req.params.id, tags, tablePrefix); - } + if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') { + // Do not save when default got set + delete updateData.settings.saveDataSuccessExecution; } + if (updateData.settings.saveManualExecutions === 'DEFAULT') { + // Do not save when default got set + delete updateData.settings.saveManualExecutions; + } + if ( + parseInt(updateData.settings.executionTimeout as string, 10) === this.executionTimeout + ) { + // Do not save when default got set + delete updateData.settings.executionTimeout; + } + } - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the hopefully updated entry. - const workflow = await Db.collections.Workflow!.findOne(id, { relations: ['tags'] }); + if (updateData.name) { + updateData.updatedAt = this.getCurrentDate(); // required due to atomic update + await validateEntity(updateData); + } - if (workflow === undefined) { - throw new ResponseHelper.ResponseError( - `Workflow with id "${id}" could not be found to be updated.`, - undefined, - 400, + await Db.collections.Workflow!.update(workflowId, updateData); + + if (tags && !config.get('workflowTagsDisabled')) { + const tablePrefix = config.get('database.tablePrefix'); + await TagHelpers.removeRelations(workflowId, tablePrefix); + + if (tags.length) { + await TagHelpers.createRelations(workflowId, tags, tablePrefix); + } + } + + const options: FindManyOptions = { + relations: ['tags'], + }; + + if (config.get('workflowTagsDisabled')) { + delete options.relations; + } + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the hopefully updated entry. + const updatedWorkflow = await Db.collections.Workflow!.findOne(workflowId, options); + + if (updatedWorkflow === undefined) { + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be updated.`, + undefined, + 400, + ); + } + + if (updatedWorkflow.tags.length && tags?.length) { + updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { + requestOrder: tags, + }); + } + + await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); + // @ts-ignore + void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow); + + if (updatedWorkflow.active) { + // When the workflow is supposed to be active add it again + try { + await this.externalHooks.run('workflow.activate', [updatedWorkflow]); + await this.activeWorkflowRunner.add( + workflowId, + shared.workflow.active ? 'update' : 'activate', ); + } catch (error) { + // If workflow could not be activated set it again to inactive + updateData.active = false; + // @ts-ignore + await Db.collections.Workflow!.update(workflowId, updateData); + + // Also set it in the returned data + updatedWorkflow.active = false; + + // Now return the original error for UI to display + throw error; } + } - if (tags?.length) { - workflow.tags = TagHelpers.sortByRequestOrder(workflow.tags, tags); - } + const { id, ...remainder } = updatedWorkflow; - await this.externalHooks.run('workflow.afterUpdate', [workflow]); - void InternalHooksManager.getInstance().onWorkflowSaved(workflow); - - if (workflow.active) { - // When the workflow is supposed to be active add it again - try { - await this.externalHooks.run('workflow.activate', [workflow]); - await this.activeWorkflowRunner.add(id, isActive ? 'update' : 'activate'); - } catch (error) { - // If workflow could not be activated set it again to inactive - updateData.active = false; - // @ts-ignore - await Db.collections.Workflow!.update(id, updateData); - - // Also set it in the returned data - workflow.active = false; - - // Now return the original error for UI to display - throw error; - } - } - - // @ts-ignore - workflow.id = workflow.id.toString(); - return workflow; - }, - ), + return { + id: id.toString(), + ...remainder, + }; + }), ); // Deletes a specific workflow this.app.delete( `/${this.restEndpoint}/workflows/:id`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const { id } = req.params; + ResponseHelper.send(async (req: WorkflowRequest.Delete) => { + const { id: workflowId } = req.params; - await this.externalHooks.run('workflow.delete', [id]); + await this.externalHooks.run('workflow.delete', [workflowId]); - const isActive = await this.activeWorkflowRunner.isActive(id); - if (isActive) { - // Before deleting a workflow deactivate it - await this.activeWorkflowRunner.remove(id); + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); + + if (!shared) { + LoggerProxy.info('User attempted to delete a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found to be deleted.`, + undefined, + 400, + ); } - await Db.collections.Workflow!.delete(id); - void InternalHooksManager.getInstance().onWorkflowDeleted(id); - await this.externalHooks.run('workflow.afterDelete', [id]); + if (shared.workflow.active) { + // deactivate before deleting + await this.activeWorkflowRunner.remove(workflowId); + } + + await Db.collections.Workflow!.delete(workflowId); + + void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId); + await this.externalHooks.run('workflow.afterDelete', [workflowId]); return true; }), @@ -951,7 +1157,10 @@ class App { this.app.post( `/${this.restEndpoint}/workflows/run`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { + async ( + req: WorkflowRequest.ManualRun, + res: express.Response, + ): Promise => { const { workflowData } = req.body; const { runData } = req.body; const { startNodes } = req.body; @@ -968,13 +1177,13 @@ class App { startNodes.length === 0 || destinationNode === undefined ) { - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); const nodeTypes = NodeTypes(); const workflowInstance = new Workflow({ - id: workflowData.id, + id: workflowData.id?.toString(), name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, + nodes: workflowData.nodes!, + connections: workflowData.connections!, active: false, nodeTypes, staticData: undefined, @@ -1007,6 +1216,7 @@ class App { sessionId, startNodes, workflowData, + userId: req.user.id, }; const workflowRunner = new WorkflowRunner(); const executionId = await workflowRunner.run(data); @@ -1026,15 +1236,15 @@ class App { req: express.Request, res: express.Response, ): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } if (req.query.withUsageCount === 'true') { const tablePrefix = config.get('database.tablePrefix'); return TagHelpers.getTagsWithCountDb(tablePrefix); } - const tags = await Db.collections.Tag!.find({ select: ['id', 'name'] }); - // @ts-ignore - tags.forEach((tag) => (tag.id = tag.id.toString())); - return tags; + return Db.collections.Tag!.find({ select: ['id', 'name'] }); }, ), ); @@ -1044,20 +1254,19 @@ class App { `/${this.restEndpoint}/tags`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } const newTag = new TagEntity(); newTag.name = req.body.name.trim(); await this.externalHooks.run('tag.beforeCreate', [newTag]); - await TagHelpers.validateTag(newTag); - const tag = await Db.collections - .Tag!.save(newTag) - .catch(TagHelpers.throwDuplicateEntryError); + await validateEntity(newTag); + const tag = await Db.collections.Tag!.save(newTag); await this.externalHooks.run('tag.afterCreate', [tag]); - // @ts-ignore - tag.id = tag.id.toString(); return tag; }, ), @@ -1068,24 +1277,25 @@ class App { `/${this.restEndpoint}/tags/:id`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } + const { name } = req.body; const { id } = req.params; const newTag = new TagEntity(); - newTag.id = Number(id); + // @ts-ignore + newTag.id = id; newTag.name = name.trim(); await this.externalHooks.run('tag.beforeUpdate', [newTag]); - await TagHelpers.validateTag(newTag); - const tag = await Db.collections - .Tag!.save(newTag) - .catch(TagHelpers.throwDuplicateEntryError); + await validateEntity(newTag); + const tag = await Db.collections.Tag!.save(newTag); await this.externalHooks.run('tag.afterUpdate', [tag]); - // @ts-ignore - tag.id = tag.id.toString(); return tag; }, ), @@ -1094,17 +1304,33 @@ class App { // Deletes a tag this.app.delete( `/${this.restEndpoint}/tags/:id`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const id = Number(req.params.id); + ResponseHelper.send( + async (req: TagsRequest.Delete, res: express.Response): Promise => { + if (config.get('workflowTagsDisabled')) { + throw new ResponseHelper.ResponseError('Workflow tags are disabled'); + } + if ( + config.get('userManagement.isInstanceOwnerSetUp') === true && + req.user.globalRole.name !== 'owner' + ) { + throw new ResponseHelper.ResponseError( + 'You are not allowed to perform this action', + undefined, + 403, + 'Only owners can remove tags', + ); + } + const id = Number(req.params.id); - await this.externalHooks.run('tag.beforeDelete', [id]); + await this.externalHooks.run('tag.beforeDelete', [id]); - await Db.collections.Tag!.delete({ id }); + await Db.collections.Tag!.delete({ id }); - await this.externalHooks.run('tag.afterDelete', [id]); + await this.externalHooks.run('tag.afterDelete', [id]); - return true; - }), + return true; + }, + ), ); // Returns parameter values which normally get loaded from an external API or @@ -1112,40 +1338,43 @@ class App { this.app.get( `/${this.restEndpoint}/node-parameter-options`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { + async (req: NodeParameterOptionsRequest): Promise => { const nodeTypeAndVersion = JSON.parse( - `${req.query.nodeTypeAndVersion}`, + req.query.nodeTypeAndVersion, ) as INodeTypeNameVersion; - const path = req.query.path as string; - let credentials: INodeCredentials | undefined; + + const { path, methodName } = req.query; + const currentNodeParameters = JSON.parse( - `${req.query.currentNodeParameters}`, + req.query.currentNodeParameters, ) as INodeParameters; - if (req.query.credentials !== undefined) { - credentials = JSON.parse(req.query.credentials as string); + + let credentials: INodeCredentials | undefined; + + if (req.query.credentials) { + credentials = JSON.parse(req.query.credentials); } - const nodeTypes = NodeTypes(); - - // @ts-ignore const loadDataInstance = new LoadNodeParameterOptions( nodeTypeAndVersion, - nodeTypes, + NodeTypes(), path, currentNodeParameters, credentials, ); - const additionalData = await WorkflowExecuteAdditionalData.getBase(currentNodeParameters); + const additionalData = await WorkflowExecuteAdditionalData.getBase( + req.user.id, + currentNodeParameters, + ); - if (req.query.methodName) { - return loadDataInstance.getOptionsViaMethodName( - req.query.methodName as string, - additionalData, - ); + if (methodName) { + return loadDataInstance.getOptionsViaMethodName(methodName, additionalData); } + // @ts-ignore if (req.query.loadOptions) { return loadDataInstance.getOptionsViaRequestProperty( + // @ts-ignore JSON.parse(req.query.loadOptions as string), additionalData, ); @@ -1340,314 +1569,45 @@ class App { // Returns the active workflow ids this.app.get( `/${this.restEndpoint}/active`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(); - return activeWorkflows.map((workflow) => workflow.id.toString()); - }, - ), + ResponseHelper.send(async (req: WorkflowRequest.GetAllActive) => { + const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(req.user); + + return activeWorkflows.map(({ id }) => id.toString()); + }), ); // Returns if the workflow with the given id had any activation errors this.app.get( `/${this.restEndpoint}/active/error/:id`, - ResponseHelper.send( - async ( - req: express.Request, - res: express.Response, - ): Promise => { - const { id } = req.params; - return this.activeWorkflowRunner.getActivationError(id); - }, - ), - ); + ResponseHelper.send(async (req: WorkflowRequest.GetAllActivationErrors) => { + const { id: workflowId } = req.params; - // ---------------------------------------- - // Credentials - // ---------------------------------------- + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations: ['workflow'], + where: whereClause({ + user: req.user, + entityType: 'workflow', + entityId: workflowId, + }), + }); - this.app.get( - `/${this.restEndpoint}/credentials/new`, - ResponseHelper.send( - async (req: NameRequest, res: express.Response): Promise<{ name: string }> => { - const requestedName = - req.query.name && req.query.name !== '' ? req.query.name : this.defaultCredentialsName; + if (!shared) { + LoggerProxy.info('User attempted to access workflow errors without permissions', { + workflowId, + userId: req.user.id, + }); - return await GenericHelpers.generateUniqueName(requestedName, 'credentials'); - }, - ), - ); + throw new ResponseHelper.ResponseError( + `Workflow with ID "${workflowId}" could not be found.`, + undefined, + 400, + ); + } - // Deletes a specific credential - this.app.delete( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const { id } = req.params; - - await this.externalHooks.run('credentials.delete', [id]); - - await Db.collections.Credentials!.delete({ id }); - - return true; + return this.activeWorkflowRunner.getActivationError(workflowId); }), ); - // Creates new credentials - this.app.post( - `/${this.restEndpoint}/credentials`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body; - - if (!incomingData.name || incomingData.name.length < 3) { - throw new ResponseHelper.ResponseError( - `Credentials name must be at least 3 characters long.`, - undefined, - 400, - ); - } - - // Add the added date for node access permissions - for (const nodeAccess of incomingData.nodesAccess) { - nodeAccess.date = this.getCurrentDate(); - } - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to encrypt the credentials!'); - } - - if (incomingData.name === '') { - throw new Error('Credentials have to have a name set!'); - } - - // Encrypt the data - const credentials = new Credentials( - { id: null, name: incomingData.name }, - incomingData.type, - incomingData.nodesAccess, - ); - credentials.setData(incomingData.data, encryptionKey); - const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; - - await this.externalHooks.run('credentials.create', [newCredentialsData]); - - // Save the credentials in DB - const result = await Db.collections.Credentials!.save(newCredentialsData); - result.data = incomingData.data; - - // Convert to response format in which the id is a string - (result as unknown as ICredentialsResponse).id = result.id.toString(); - return result as unknown as ICredentialsResponse; - }, - ), - ); - - // Test credentials - this.app.post( - `/${this.restEndpoint}/credentials-test`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body as INodeCredentialTestRequest; - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - return { - status: 'Error', - message: 'No encryption key got found to decrypt the credentials!', - }; - } - - const credentialsHelper = new CredentialsHelper(encryptionKey); - - const credentialType = incomingData.credentials.type; - return credentialsHelper.testCredentials( - credentialType, - incomingData.credentials, - incomingData.nodeToTestWith, - ); - }, - ), - ); - - // Updates existing credentials - this.app.patch( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body; - - const { id } = req.params; - - if (incomingData.name === '') { - throw new Error('Credentials have to have a name set!'); - } - - // Add the date for newly added node access permissions - for (const nodeAccess of incomingData.nodesAccess) { - if (!nodeAccess.date) { - nodeAccess.date = this.getCurrentDate(); - } - } - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to encrypt the credentials!'); - } - - // Load the currently saved credentials to be able to persist some of the data if - const result = await Db.collections.Credentials!.findOne(id); - if (result === undefined) { - throw new ResponseHelper.ResponseError( - `Credentials with the id "${id}" do not exist.`, - undefined, - 400, - ); - } - - const currentlySavedCredentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, - result.data, - ); - const decryptedData = currentlySavedCredentials.getData(encryptionKey); - - // Do not overwrite the oauth data else data like the access or refresh token would get lost - // everytime anybody changes anything on the credentials even if it is just the name. - if (decryptedData.oauthTokenData) { - incomingData.data.oauthTokenData = decryptedData.oauthTokenData; - } - - // Encrypt the data - const credentials = new Credentials( - { id, name: incomingData.name }, - incomingData.type, - incomingData.nodesAccess, - ); - credentials.setData(incomingData.data, encryptionKey); - const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; - - // Add special database related data - newCredentialsData.updatedAt = this.getCurrentDate(); - - await this.externalHooks.run('credentials.update', [newCredentialsData]); - - // Update the credentials in DB - await Db.collections.Credentials!.update(id, newCredentialsData); - - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the hopefully updated entry. - const responseData = await Db.collections.Credentials!.findOne(id); - - if (responseData === undefined) { - throw new ResponseHelper.ResponseError( - `Credentials with id "${id}" could not be found to be updated.`, - undefined, - 400, - ); - } - - // Remove the encrypted data as it is not needed in the frontend - responseData.data = ''; - - // Convert to response format in which the id is a string - (responseData as unknown as ICredentialsResponse).id = responseData.id.toString(); - return responseData as unknown as ICredentialsResponse; - }, - ), - ); - - // Returns specific credentials - this.app.get( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send( - async ( - req: express.Request, - res: express.Response, - ): Promise => { - const findQuery = {} as FindManyOptions; - - // Make sure the variable has an expected value - const includeData = ['true', true].includes(req.query.includeData as string); - - if (!includeData) { - // Return only the fields we need - findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; - } - - const result = await Db.collections.Credentials!.findOne(req.params.id); - - if (result === undefined) { - return result; - } - - let encryptionKey; - if (includeData) { - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); - } - - const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, - result.data, - ); - (result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey); - } - - (result as ICredentialsDecryptedResponse).id = result.id.toString(); - - return result as ICredentialsDecryptedResponse; - }, - ), - ); - - // Returns all the saved credentials - this.app.get( - `/${this.restEndpoint}/credentials`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const findQuery = {} as FindManyOptions; - if (req.query.filter) { - findQuery.where = JSON.parse(req.query.filter as string) as IDataObject; - if (findQuery.where.id !== undefined) { - // No idea if multiple where parameters make db search - // slower but to be sure that that is not the case we - // remove all unnecessary fields in case the id is defined. - // @ts-ignore - findQuery.where = { id: findQuery.where.id }; - } - } - - findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; - - const results = (await Db.collections.Credentials!.find( - findQuery, - )) as unknown as ICredentialsResponse[]; - - let encryptionKey; - - const includeData = ['true', true].includes(req.query.includeData as string); - if (includeData) { - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - throw new Error('No encryption key got found to decrypt the credentials!'); - } - } - - let result; - for (result of results) { - (result as ICredentialsDecryptedResponse).id = result.id.toString(); - } - - return results; - }, - ), - ); - // ---------------------------------------- // Credential-Types // ---------------------------------------- @@ -1713,36 +1673,53 @@ class App { // Authorize OAuth Data this.app.get( `/${this.restEndpoint}/oauth1-credential/auth`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - if (req.query.id === undefined) { - res.status(500).send('Required credential id is missing!'); - return ''; + ResponseHelper.send(async (req: OAuthRequest.OAuth1Credential.Auth): Promise => { + const { id: credentialId } = req.query; + + if (!credentialId) { + LoggerProxy.error('OAuth1 credential authorization failed due to missing credential ID'); + throw new ResponseHelper.ResponseError( + 'Required credential ID is missing', + undefined, + 400, + ); } - const result = await Db.collections.Credentials!.findOne(req.query.id as string); - if (result === undefined) { - res.status(404).send('The credential is not known.'); - return ''; + const credential = await getCredentialForUser(credentialId, req.user); + + if (!credential) { + LoggerProxy.error( + 'OAuth1 credential authorization failed because the current user does not have the correct permissions', + { userId: req.user.id }, + ); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, + undefined, + 404, + ); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - res.status(500).send('No encryption key got found to decrypt the credentials!'); - return ''; + const encryptionKey = await UserSettings.getEncryptionKey(); + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); } const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -1764,7 +1741,7 @@ class App { const oauthRequestData = { oauth_callback: `${WebhookHelpers.getWebhookBaseUrl()}${ this.restEndpoint - }/oauth1-credential/callback?cid=${req.query.id}`, + }/oauth1-credential/callback?cid=${credentialId}`, }; await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]); @@ -1795,9 +1772,9 @@ class App { // Encrypt the data const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); credentials.setData(decryptedDataOriginal, encryptionKey); @@ -1807,7 +1784,12 @@ class App { newCredentialsData.updatedAt = this.getCurrentDate(); // Update the credentials in DB - await Db.collections.Credentials!.update(req.query.id as string, newCredentialsData); + await Db.collections.Credentials!.update(credentialId, newCredentialsData); + + LoggerProxy.verbose('OAuth1 authorization successful for new credential', { + userId: req.user.id, + credentialId, + }); return returnUri; }), @@ -1816,11 +1798,11 @@ class App { // Verify and store app code. Generate access tokens and store for respective credential. this.app.get( `/${this.restEndpoint}/oauth1-credential/callback`, - async (req: express.Request, res: express.Response) => { + async (req: OAuthRequest.OAuth1Credential.Callback, res: express.Response) => { try { - const { oauth_verifier, oauth_token, cid } = req.query; + const { oauth_verifier, oauth_token, cid: credentialId } = req.query; - if (oauth_verifier === undefined || oauth_token === undefined) { + if (!oauth_verifier || !oauth_token) { const errorResponse = new ResponseHelper.ResponseError( `Insufficient parameters for OAuth1 callback. Received following query parameters: ${JSON.stringify( req.query, @@ -1828,24 +1810,36 @@ class App { undefined, 503, ); + LoggerProxy.error( + 'OAuth1 callback failed because of insufficient parameters received', + { + userId: req.user.id, + credentialId, + }, + ); return ResponseHelper.sendErrorResponse(res, errorResponse); } - const result = await Db.collections.Credentials!.findOne(cid as any); - if (result === undefined) { + const credential = await getCredentialForUser(credentialId, req.user); + + if (!credential) { + LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', { + userId: req.user.id, + credentialId, + }); const errorResponse = new ResponseHelper.ResponseError( - 'The credential is not known.', + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, undefined, 404, ); return ResponseHelper.sendErrorResponse(res, errorResponse); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { const errorResponse = new ResponseHelper.ResponseError( - 'No encryption key got found to decrypt the credentials!', + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, undefined, 503, ); @@ -1855,14 +1849,14 @@ class App { const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -1880,6 +1874,10 @@ class App { try { oauthToken = await requestPromise(options); } catch (error) { + LoggerProxy.error('Unable to fetch tokens for OAuth1 callback', { + userId: req.user.id, + credentialId, + }); const errorResponse = new ResponseHelper.ResponseError( 'Unable to get access tokens!', undefined, @@ -1895,19 +1893,27 @@ class App { decryptedDataOriginal.oauthTokenData = oauthTokenJson; const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data newCredentialsData.updatedAt = this.getCurrentDate(); // Save the credentials in DB - await Db.collections.Credentials!.update(cid as any, newCredentialsData); + await Db.collections.Credentials!.update(credentialId, newCredentialsData); + LoggerProxy.verbose('OAuth1 callback successful for new credential', { + 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, + credentialId: req.query.cid, + }); // Error response return ResponseHelper.sendErrorResponse(res, error); } @@ -1921,36 +1927,52 @@ class App { // Authorize OAuth Data this.app.get( `/${this.restEndpoint}/oauth2-credential/auth`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - if (req.query.id === undefined) { - res.status(500).send('Required credential id is missing.'); - return ''; + ResponseHelper.send(async (req: OAuthRequest.OAuth2Credential.Auth): Promise => { + const { id: credentialId } = req.query; + + if (!credentialId) { + throw new ResponseHelper.ResponseError( + 'Required credential ID is missing', + undefined, + 400, + ); } - const result = await Db.collections.Credentials!.findOne(req.query.id as string); - if (result === undefined) { - res.status(404).send('The credential is not known.'); - return ''; + const credential = await getCredentialForUser(credentialId, req.user); + + if (!credential) { + LoggerProxy.error('Failed to authorize OAuth2 due to lack of permissions', { + userId: req.user.id, + credentialId, + }); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, + undefined, + 404, + ); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - res.status(500).send('No encryption key got found to decrypt the credentials!'); - return ''; + const encryptionKey = await UserSettings.getEncryptionKey(); + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); } const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -1981,9 +2003,9 @@ class App { // Encrypt the data const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); decryptedDataOriginal.csrfSecret = csrfSecret; @@ -2010,6 +2032,10 @@ class App { returnUri += `&${authQueryParameters}`; } + LoggerProxy.verbose('OAuth2 authentication successful for new credential', { + userId: req.user.id, + credentialId, + }); return returnUri; }), ); @@ -2021,12 +2047,12 @@ class App { // Verify and store app code. Generate access tokens and store for respective credential. this.app.get( `/${this.restEndpoint}/oauth2-credential/callback`, - async (req: express.Request, res: express.Response) => { + async (req: OAuthRequest.OAuth2Credential.Callback, res: express.Response) => { try { // realmId it's currently just use for the quickbook OAuth2 flow const { code, state: stateEncoded } = req.query; - if (code === undefined || stateEncoded === undefined) { + if (!code || !stateEncoded) { const errorResponse = new ResponseHelper.ResponseError( `Insufficient parameters for OAuth2 callback. Received following query parameters: ${JSON.stringify( req.query, @@ -2039,7 +2065,7 @@ class App { let state; try { - state = JSON.parse(Buffer.from(stateEncoded as string, 'base64').toString()); + state = JSON.parse(Buffer.from(stateEncoded, 'base64').toString()); } catch (error) { const errorResponse = new ResponseHelper.ResponseError( 'Invalid state format returned', @@ -2049,21 +2075,26 @@ class App { return ResponseHelper.sendErrorResponse(res, errorResponse); } - const result = await Db.collections.Credentials!.findOne(state.cid); - if (result === undefined) { + const credential = await getCredentialForUser(state.cid, req.user); + + if (!credential) { + LoggerProxy.error('OAuth2 callback failed because of insufficient permissions', { + userId: req.user.id, + credentialId: state.cid, + }); const errorResponse = new ResponseHelper.ResponseError( - 'The credential is not known.', + RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, undefined, 404, ); return ResponseHelper.sendErrorResponse(res, errorResponse); } - let encryptionKey; - encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { const errorResponse = new ResponseHelper.ResponseError( - 'No encryption key got found to decrypt the credentials!', + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, undefined, 503, ); @@ -2073,14 +2104,14 @@ class App { const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result as INodeCredentialsDetails, - result.type, + credential as INodeCredentialsDetails, + credential.type, mode, true, ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( decryptedDataOriginal, - result.type, + credential.type, mode, ); @@ -2089,6 +2120,10 @@ class App { decryptedDataOriginal.csrfSecret === undefined || !token.verify(decryptedDataOriginal.csrfSecret as string, state.token) ) { + LoggerProxy.debug('OAuth2 callback state is invalid', { + userId: req.user.id, + credentialId: state.cid, + }); const errorResponse = new ResponseHelper.ResponseError( 'The OAuth2 callback state is invalid!', undefined, @@ -2136,6 +2171,10 @@ class App { } if (oauthToken === undefined) { + LoggerProxy.error('OAuth2 callback failed: unable to get access tokens', { + userId: req.user.id, + credentialId: state.cid, + }); const errorResponse = new ResponseHelper.ResponseError( 'Unable to get access tokens!', undefined, @@ -2156,9 +2195,9 @@ class App { _.unset(decryptedDataOriginal, 'csrfSecret'); const credentials = new Credentials( - result as INodeCredentialsDetails, - result.type, - result.nodesAccess, + credential as INodeCredentialsDetails, + credential.type, + credential.nodesAccess, ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; @@ -2166,6 +2205,10 @@ class App { newCredentialsData.updatedAt = this.getCurrentDate(); // 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, + credentialId: state.cid, + }); res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html')); } catch (error) { @@ -2183,107 +2226,113 @@ class App { this.app.get( `/${this.restEndpoint}/executions`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - let filter: any = {}; + async (req: ExecutionRequest.GetAll): Promise => { + const filter = req.query.filter ? JSON.parse(req.query.filter) : {}; - if (req.query.filter) { - filter = JSON.parse(req.query.filter as string); - } - - let limit = 20; - if (req.query.limit) { - limit = parseInt(req.query.limit as string, 10); - } + const limit = req.query.limit + ? parseInt(req.query.limit, 10) + : DEFAULT_EXECUTIONS_GET_ALL_LIMIT; const executingWorkflowIds: string[] = []; if (config.get('executions.mode') === 'queue') { const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); - executingWorkflowIds.push( - ...(currentJobs.map((job) => job.data.executionId) as string[]), - ); + executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId)); } + // We may have manual executions even with queue so we must account for these. executingWorkflowIds.push( - ...this.activeExecutionsInstance - .getActiveExecutions() - .map((execution) => execution.id.toString()), + ...this.activeExecutionsInstance.getActiveExecutions().map(({ id }) => id), ); - const countFilter = JSON.parse(JSON.stringify(filter)); - if (countFilter.waitTill !== undefined) { - countFilter.waitTill = Not(IsNull()); - } + const countFilter = cloneDeep(filter); + countFilter.waitTill &&= Not(IsNull()); countFilter.id = Not(In(executingWorkflowIds)); - const resultsQuery = await Db.collections - .Execution!.createQueryBuilder('execution') - .select([ - 'execution.id', - 'execution.finished', - 'execution.mode', - 'execution.retryOf', - 'execution.retrySuccessId', - 'execution.waitTill', - 'execution.startedAt', - 'execution.stoppedAt', - 'execution.workflowData', - ]) - .orderBy('execution.id', 'DESC') - .take(limit); + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - Object.keys(filter).forEach((filterField) => { - if (filterField === 'waitTill') { - resultsQuery.andWhere(`execution.${filterField} is not null`); - } else if (filterField === 'finished' && filter[filterField] === false) { - resultsQuery.andWhere(`execution.${filterField} = :${filterField}`, { - [filterField]: filter[filterField], - }); - resultsQuery.andWhere(`execution.waitTill is null`); + const findOptions: FindManyOptions = { + select: [ + 'id', + 'finished', + 'mode', + 'retryOf', + 'retrySuccessId', + 'waitTill', + 'startedAt', + 'stoppedAt', + 'workflowData', + ], + where: { workflowId: In(sharedWorkflowIds) }, + order: { id: 'DESC' }, + take: limit, + }; + + Object.entries(filter).forEach(([key, value]) => { + let filterToAdd = {}; + + if (key === 'waitTill') { + filterToAdd = { waitTill: !IsNull() }; + } else if (key === 'finished' && value === false) { + filterToAdd = { finished: false, waitTill: IsNull() }; } else { - resultsQuery.andWhere(`execution.${filterField} = :${filterField}`, { - [filterField]: filter[filterField], - }); + filterToAdd = { [key]: value }; } + + Object.assign(findOptions.where, filterToAdd); }); + + const rangeQuery: string[] = []; + const rangeQueryParams: { + lastId?: string; + firstId?: string; + executingWorkflowIds?: string[]; + } = {}; + if (req.query.lastId) { - resultsQuery.andWhere(`execution.id < :lastId`, { lastId: req.query.lastId }); + rangeQuery.push('id < :lastId'); + rangeQueryParams.lastId = req.query.lastId; } + if (req.query.firstId) { - resultsQuery.andWhere(`execution.id > :firstId`, { firstId: req.query.firstId }); + rangeQuery.push('id > :firstId'); + rangeQueryParams.firstId = req.query.firstId; } + if (executingWorkflowIds.length > 0) { - resultsQuery.andWhere(`execution.id NOT IN (:...ids)`, { ids: executingWorkflowIds }); + rangeQuery.push(`id NOT IN (:...executingWorkflowIds)`); + rangeQueryParams.executingWorkflowIds = executingWorkflowIds; } - const resultsPromise = resultsQuery.getMany(); - - const countPromise = getExecutionsCount(countFilter); - - const results: IExecutionFlattedDb[] = await resultsPromise; - const countedObjects = await countPromise; - - const returnResults: IExecutionsSummary[] = []; - - for (const result of results) { - returnResults.push({ - id: result.id.toString(), - finished: result.finished, - mode: result.mode, - retryOf: result.retryOf ? result.retryOf.toString() : undefined, - retrySuccessId: result.retrySuccessId ? result.retrySuccessId.toString() : undefined, - waitTill: result.waitTill as Date | undefined, - startedAt: result.startedAt, - stoppedAt: result.stoppedAt, - workflowId: result.workflowData.id ? result.workflowData.id.toString() : '', - workflowName: result.workflowData.name, + if (rangeQuery.length) { + Object.assign(findOptions.where, { + id: Raw(() => rangeQuery.join(' and '), rangeQueryParams), }); } + const executions = await Db.collections.Execution!.find(findOptions); + + const { count, estimated } = await getExecutionsCount(countFilter, req.user); + + const formattedExecutions = executions.map((execution) => { + return { + id: execution.id.toString(), + finished: execution.finished, + mode: execution.mode, + retryOf: execution.retryOf?.toString(), + retrySuccessId: execution?.retrySuccessId?.toString(), + waitTill: execution.waitTill as Date | undefined, + startedAt: execution.startedAt, + stoppedAt: execution.stoppedAt, + workflowId: execution.workflowData?.id?.toString() ?? '', + workflowName: execution.workflowData.name, + }; + }); + return { - count: countedObjects.count, - results: returnResults, - estimated: countedObjects.estimate, + count, + results: formattedExecutions, + estimated, }; }, ), @@ -2294,22 +2343,43 @@ class App { `/${this.restEndpoint}/executions/:id`, ResponseHelper.send( async ( - req: express.Request, - res: express.Response, + req: ExecutionRequest.Get, ): Promise => { - const result = await Db.collections.Execution!.findOne(req.params.id); + const { id: executionId } = req.params; - if (result === undefined) { + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + + if (!sharedWorkflowIds.length) return undefined; + + const execution = await Db.collections.Execution!.findOne({ + where: { + id: executionId, + workflowId: In(sharedWorkflowIds), + }, + }); + + if (!execution) { + LoggerProxy.info( + 'Attempt to read execution was blocked due to insufficient permissions', + { + userId: req.user.id, + executionId, + }, + ); return undefined; } if (req.query.unflattedResponse === 'true') { - const fullExecutionData = ResponseHelper.unflattenExecutionData(result); - return fullExecutionData; + return ResponseHelper.unflattenExecutionData(execution); } - // Convert to response format in which the id is a string - (result as IExecutionFlatted as IExecutionFlattedResponse).id = result.id.toString(); - return result as IExecutionFlatted as IExecutionFlattedResponse; + + const { id, ...rest } = execution; + + // @ts-ignore + return { + id: id.toString(), + ...rest, + }; }, ), ); @@ -2317,22 +2387,39 @@ class App { // Retries a failed execution this.app.post( `/${this.restEndpoint}/executions/:id/retry`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - // Get the data to execute - const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(req.params.id); + ResponseHelper.send(async (req: ExecutionRequest.Retry): Promise => { + const { id: executionId } = req.params; - if (fullExecutionDataFlatted === undefined) { + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + + if (!sharedWorkflowIds.length) return false; + + const execution = await Db.collections.Execution!.findOne({ + where: { + id: executionId, + workflowId: In(sharedWorkflowIds), + }, + }); + + if (!execution) { + LoggerProxy.info( + 'Attempt to retry an execution was blocked due to insufficient permissions', + { + userId: req.user.id, + executionId, + }, + ); throw new ResponseHelper.ResponseError( - `The execution with the id "${req.params.id}" does not exist.`, + `The execution with the ID "${executionId}" does not exist.`, 404, 404, ); } - const fullExecutionData = ResponseHelper.unflattenExecutionData(fullExecutionDataFlatted); + const fullExecutionData = ResponseHelper.unflattenExecutionData(execution); if (fullExecutionData.finished) { - throw new Error('The execution did succeed and can so not be retried.'); + throw new Error('The execution succeeded, so it cannot be retried.'); } const executionMode = 'retry'; @@ -2345,6 +2432,7 @@ class App { executionData: fullExecutionData.data, retryOf: req.params.id, workflowData: fullExecutionData.workflowData, + userId: req.user.id, }; const { lastNodeExecuted } = data.executionData!.resultData; @@ -2364,7 +2452,7 @@ class App { } } - if (req.body.loadWorkflow === true) { + if (req.body.loadWorkflow) { // Loads the currently saved workflow to execute instead of the // one saved at the time of the execution. const workflowId = fullExecutionData.workflowData.id; @@ -2396,6 +2484,11 @@ class App { // Find the data of the last executed node in the new workflow const node = workflowInstance.getNode(stack.node.name); if (node === null) { + LoggerProxy.error('Failed to retry an execution because a node could not be found', { + userId: req.user.id, + executionId, + nodeName: stack.node.name, + }); throw new Error( `Could not find the node "${stack.node.name}" in workflow. It probably got deleted or renamed. Without it the workflow can sadly not be retried.`, ); @@ -2407,13 +2500,13 @@ class App { } const workflowRunner = new WorkflowRunner(); - const executionId = await workflowRunner.run(data); + const retriedExecutionId = await workflowRunner.run(data); const executionData = await this.activeExecutionsInstance.getPostExecutePromise( - executionId, + retriedExecutionId, ); - if (executionData === undefined) { + if (!executionData) { throw new Error('The retry did not start for an unknown reason.'); } @@ -2426,38 +2519,72 @@ class App { // with the query data getting to long this.app.post( `/${this.restEndpoint}/executions/delete`, - ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const deleteData = req.body as IExecutionDeleteFilter; + ResponseHelper.send(async (req: ExecutionRequest.Delete): Promise => { + const { deleteBefore, ids, filters: requestFilters } = req.body; - if (deleteData.deleteBefore !== undefined) { - const filters = { - startedAt: LessThanOrEqual(deleteData.deleteBefore), + if (!deleteBefore && !ids) { + throw new Error('Either "deleteBefore" or "ids" must be present in the request body'); + } + + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + const binaryDataManager = BinaryDataManager.getInstance(); + + // delete executions by date, if user may access the underyling worfklows + + if (deleteBefore) { + const filters: IDataObject = { + startedAt: LessThanOrEqual(deleteBefore), }; - if (deleteData.filters !== undefined) { - Object.assign(filters, deleteData.filters); + if (filters) { + Object.assign(filters, requestFilters); } - const execs = await Db.collections.Execution!.find({ ...filters, select: ['id'] }); + const executions = await Db.collections.Execution!.find({ + where: { + workflowId: In(sharedWorkflowIds), + ...filters, + }, + }); + + if (!executions.length) return; + + const idsToDelete = executions.map(({ id }) => id.toString()); await Promise.all( - execs.map(async (item) => - BinaryDataManager.getInstance().deleteBinaryDataByExecutionId(item.id.toString()), - ), + idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)), ); - await Db.collections.Execution!.delete(filters); - } else if (deleteData.ids !== undefined) { + await Db.collections.Execution!.delete({ id: In(idsToDelete) }); + + return; + } + + // delete executions by IDs, if user may access the underyling worfklows + + if (ids) { + const executions = await Db.collections.Execution!.find({ + where: { + id: In(ids), + workflowId: In(sharedWorkflowIds), + }, + }); + + if (!executions.length) { + LoggerProxy.error('Failed to delete an execution due to insufficient permissions', { + userId: req.user.id, + executionIds: ids, + }); + return; + } + + const idsToDelete = executions.map(({ id }) => id.toString()); + await Promise.all( - deleteData.ids.map(async (id) => - BinaryDataManager.getInstance().deleteBinaryDataByExecutionId(id), - ), + idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)), ); - // Deletes all executions with the given ids - await Db.collections.Execution!.delete(deleteData.ids); - } else { - throw new Error('Required body-data "ids" or "deleteBefore" is missing!'); + await Db.collections.Execution!.delete(idsToDelete); } }), ); @@ -2470,7 +2597,7 @@ class App { this.app.get( `/${this.restEndpoint}/executions-current`, ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { + async (req: ExecutionRequest.GetAllCurrent): Promise => { if (config.get('executions.mode') === 'queue') { const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); @@ -2485,56 +2612,62 @@ class App { const currentlyRunningExecutionIds = currentlyRunningQueueIds.concat(manualExecutionIds); - if (currentlyRunningExecutionIds.length === 0) { - return []; - } + if (!currentlyRunningExecutionIds.length) return []; - const resultsQuery = await Db.collections - .Execution!.createQueryBuilder('execution') - .select([ - 'execution.id', - 'execution.workflowId', - 'execution.mode', - 'execution.retryOf', - 'execution.startedAt', - ]) - .orderBy('execution.id', 'DESC') - .andWhere(`execution.id IN (:...ids)`, { ids: currentlyRunningExecutionIds }); + const findOptions: FindManyOptions = { + select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt'], + order: { id: 'DESC' }, + where: { + id: In(currentlyRunningExecutionIds), + }, + }; + + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); + + if (!sharedWorkflowIds.length) return []; if (req.query.filter) { - const filter = JSON.parse(req.query.filter as string); - if (filter.workflowId !== undefined) { - resultsQuery.andWhere('execution.workflowId = :workflowId', { - workflowId: filter.workflowId, - }); + const { workflowId } = JSON.parse(req.query.filter); + if (workflowId && sharedWorkflowIds.includes(workflowId)) { + Object.assign(findOptions.where, { workflowId }); } + } else { + Object.assign(findOptions.where, { workflowId: In(sharedWorkflowIds) }); } - const results = await resultsQuery.getMany(); + const executions = await Db.collections.Execution!.find(findOptions); - return results.map((result) => { + if (!executions.length) return []; + + return executions.map((execution) => { return { - id: result.id, - workflowId: result.workflowId, - mode: result.mode, - retryOf: result.retryOf !== null ? result.retryOf : undefined, - startedAt: new Date(result.startedAt), + id: execution.id, + workflowId: execution.workflowId, + mode: execution.mode, + retryOf: execution.retryOf !== null ? execution.retryOf : undefined, + startedAt: new Date(execution.startedAt), } as IExecutionsSummary; }); } + const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions(); const returnData: IExecutionsSummary[] = []; - let filter: any = {}; - if (req.query.filter) { - filter = JSON.parse(req.query.filter as string); - } + const filter = req.query.filter ? JSON.parse(req.query.filter) : {}; + + const sharedWorkflowIds = await getSharedWorkflowIds(req.user).then((ids) => + ids.map((id) => id.toString()), + ); for (const data of executingWorkflows) { - if (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) { + if ( + (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) || + !sharedWorkflowIds.includes(data.workflowId) + ) { continue; } + returnData.push({ id: data.id.toString(), workflowId: data.workflowId === undefined ? '' : data.workflowId.toString(), @@ -2543,6 +2676,7 @@ class App { startedAt: new Date(data.startedAt), }); } + returnData.sort((a, b) => parseInt(b.id, 10) - parseInt(a.id, 10)); return returnData; @@ -2553,85 +2687,100 @@ class App { // Forces the execution to stop this.app.post( `/${this.restEndpoint}/executions-current/:id/stop`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - if (config.get('executions.mode') === 'queue') { - // Manual executions should still be stoppable, so - // try notifying the `activeExecutions` to stop it. - const result = await this.activeExecutionsInstance.stopExecution(req.params.id); + ResponseHelper.send(async (req: ExecutionRequest.Stop): Promise => { + const { id: executionId } = req.params; - if (result === undefined) { - // If active execution could not be found check if it is a waiting one - try { - return await this.waitTracker.stopExecution(req.params.id); - } catch (error) { - // Ignore, if it errors as then it is probably a currently running - // execution - } - } else { - return { - mode: result.mode, - startedAt: new Date(result.startedAt), - stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, - finished: result.finished, - } as IExecutionsStopData; - } + const sharedWorkflowIds = await getSharedWorkflowIds(req.user); - const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); + if (!sharedWorkflowIds.length) { + throw new ResponseHelper.ResponseError('Execution not found', undefined, 404); + } - const job = currentJobs.find( - (job) => job.data.executionId.toString() === req.params.id, - ); + const execution = await Db.collections.Execution!.findOne({ + where: { + id: executionId, + workflowId: In(sharedWorkflowIds), + }, + }); - if (!job) { - throw new Error(`Could not stop "${req.params.id}" as it is no longer in queue.`); - } else { - await Queue.getInstance().stopJob(job); - } + if (!execution) { + throw new ResponseHelper.ResponseError('Execution not found', undefined, 404); + } - const executionDb = (await Db.collections.Execution?.findOne( - req.params.id, - )) as IExecutionFlattedDb; - const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb); + if (config.get('executions.mode') === 'queue') { + // Manual executions should still be stoppable, so + // try notifying the `activeExecutions` to stop it. + const result = await this.activeExecutionsInstance.stopExecution(req.params.id); - const returnData: IExecutionsStopData = { - mode: fullExecutionData.mode, - startedAt: new Date(fullExecutionData.startedAt), - stoppedAt: fullExecutionData.stoppedAt - ? new Date(fullExecutionData.stoppedAt) - : undefined, - finished: fullExecutionData.finished, - }; - - return returnData; - } - const executionId = req.params.id; - - // Stopt he execution and wait till it is done and we got the data - const result = await this.activeExecutionsInstance.stopExecution(executionId); - - let returnData: IExecutionsStopData; if (result === undefined) { // If active execution could not be found check if it is a waiting one - returnData = await this.waitTracker.stopExecution(executionId); + try { + return await this.waitTracker.stopExecution(req.params.id); + } catch (error) { + // Ignore, if it errors as then it is probably a currently running + // execution + } } else { - returnData = { + return { mode: result.mode, startedAt: new Date(result.startedAt), stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, finished: result.finished, - }; + } as IExecutionsStopData; } + const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); + + const job = currentJobs.find((job) => job.data.executionId.toString() === req.params.id); + + if (!job) { + throw new Error(`Could not stop "${req.params.id}" as it is no longer in queue.`); + } else { + await Queue.getInstance().stopJob(job); + } + + const executionDb = (await Db.collections.Execution?.findOne( + req.params.id, + )) as IExecutionFlattedDb; + const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb); + + const returnData: IExecutionsStopData = { + mode: fullExecutionData.mode, + startedAt: new Date(fullExecutionData.startedAt), + stoppedAt: fullExecutionData.stoppedAt + ? new Date(fullExecutionData.stoppedAt) + : undefined, + finished: fullExecutionData.finished, + }; + return returnData; - }, - ), + } + + // Stop the execution and wait till it is done and we got the data + const result = await this.activeExecutionsInstance.stopExecution(executionId); + + let returnData: IExecutionsStopData; + if (result === undefined) { + // If active execution could not be found check if it is a waiting one + returnData = await this.waitTracker.stopExecution(executionId); + } else { + returnData = { + mode: result.mode, + startedAt: new Date(result.startedAt), + stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, + finished: result.finished, + }; + } + + return returnData; + }), ); // Removes a test webhook this.app.delete( `/${this.restEndpoint}/test-webhook/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + // TODO UM: check if this needs validation with user management. const workflowId = req.params.id; return this.testWebhooks.cancelTestWebhook(workflowId); }), @@ -2657,6 +2806,7 @@ class App { this.app.get( `/${this.restEndpoint}/data/:path`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + // TODO UM: check if this needs permission check for UM const dataPath = req.params.path; return BinaryDataManager.getInstance() .retrieveBinaryDataByIdentifier(dataPath) @@ -2670,41 +2820,16 @@ class App { // Settings // ---------------------------------------- - // Returns the settings which are needed in the UI + // Returns the current settings for the UI this.app.get( `/${this.restEndpoint}/settings`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { - return this.frontendSettings; + return this.getSettingsForFrontend(); }, ), ); - // ---------------------------------------- - // User Survey - // ---------------------------------------- - - // Process personalization survey responses - this.app.post( - `/${this.restEndpoint}/user-survey`, - async (req: express.Request, res: express.Response) => { - if (!this.frontendSettings.personalizationSurvey.shouldShow) { - ResponseHelper.sendErrorResponse( - res, - new ResponseHelper.ResponseError('User survey already submitted', undefined, 400), - false, - ); - } - - const answers = req.body as IPersonalizationSurveyAnswers; - await PersonalizationSurvey.writeSurveyToDisk(answers); - this.frontendSettings.personalizationSurvey.shouldShow = false; - this.frontendSettings.personalizationSurvey.answers = answers; - ResponseHelper.sendSuccessResponse(res, undefined, true, 200); - void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted(answers); - }, - ); - // ---------------------------------------- // Webhooks // ---------------------------------------- @@ -2908,6 +3033,10 @@ export async function start(): Promise { }, deploymentType: config.get('deployment.type'), binaryDataMode: binarDataConfig.mode, + n8n_multi_user_allowed: + config.get('userManagement.disabled') === false || + config.get('userManagement.isInstanceOwnerSetUp') === true, + smtp_set_up: config.get('userManagement.emails.mode') === 'smtp', }; void Db.collections @@ -2932,15 +3061,24 @@ export async function start(): Promise { async function getExecutionsCount( countFilter: IDataObject, -): Promise<{ count: number; estimate: boolean }> { + user: User, +): Promise<{ count: number; estimated: boolean }> { const dbType = (await GenericHelpers.getConfigValue('database.type')) as DatabaseType; const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id'); - // Do regular count for other databases than pgsql and - // if we are filtering based on workflowId or finished fields. - if (dbType !== 'postgresdb' || filteredFields.length > 0) { - const count = await Db.collections.Execution!.count(countFilter); - return { count, estimate: false }; + // For databases other than Postgres, do a regular count + // when filtering based on `workflowId` or `finished` fields. + if (dbType !== 'postgresdb' || filteredFields.length > 0 || user.globalRole.name !== 'owner') { + const sharedWorkflowIds = await getSharedWorkflowIds(user); + + const count = await Db.collections.Execution!.count({ + where: { + workflowId: In(sharedWorkflowIds), + ...countFilter, + }, + }); + + return { count, estimated: false }; } try { @@ -2953,15 +3091,22 @@ async function getExecutionsCount( const estimate = parseInt(rows[0].n_live_tup, 10); // If over 100k, return just an estimate. - if (estimate > 100000) { + if (estimate > 100_000) { // if less than 100k, we get the real count as even a full // table scan should not take so long. - return { count: estimate, estimate: true }; + return { count: estimate, estimated: true }; } - } catch (err) { - LoggerProxy.warn(`Unable to get executions count from postgres: ${err}`); + } catch (error) { + LoggerProxy.warn(`Failed to get executions count from Postgres: ${error}`); } - const count = await Db.collections.Execution!.count(countFilter); - return { count, estimate: false }; + const sharedWorkflowIds = await getSharedWorkflowIds(user); + + const count = await Db.collections.Execution!.count({ + where: { + workflowId: In(sharedWorkflowIds), + }, + }); + + return { count, estimated: false }; } diff --git a/packages/cli/src/TagHelpers.ts b/packages/cli/src/TagHelpers.ts index dc54fb2469..31e2a063f9 100644 --- a/packages/cli/src/TagHelpers.ts +++ b/packages/cli/src/TagHelpers.ts @@ -2,9 +2,6 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable import/no-cycle */ import { getConnection } from 'typeorm'; -import { validate } from 'class-validator'; - -import { ResponseHelper } from '.'; import { TagEntity } from './databases/entities/TagEntity'; @@ -15,43 +12,18 @@ import { ITagWithCountDb } from './Interfaces'; // ---------------------------------- /** - * Sort a `TagEntity[]` by the order of the tag IDs in the incoming request. + * Sort tags based on the order of the tag IDs in the request. */ -export function sortByRequestOrder(tagsDb: TagEntity[], tagIds: string[]) { - const tagMap = tagsDb.reduce((acc, tag) => { - // @ts-ignore - tag.id = tag.id.toString(); - acc[tag.id] = tag; +export function sortByRequestOrder( + tags: TagEntity[], + { requestOrder }: { requestOrder: string[] }, +) { + const tagMap = tags.reduce>((acc, tag) => { + acc[tag.id.toString()] = tag; return acc; - }, {} as { [key: string]: TagEntity }); + }, {}); - return tagIds.map((tagId) => tagMap[tagId]); -} - -// ---------------------------------- -// validators -// ---------------------------------- - -/** - * Validate a new tag based on `class-validator` constraints. - */ -export async function validateTag(newTag: TagEntity) { - const errors = await validate(newTag); - - if (errors.length) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const validationErrorMessage = Object.values(errors[0].constraints!)[0]; - throw new ResponseHelper.ResponseError(validationErrorMessage, undefined, 400); - } -} - -export function throwDuplicateEntryError(error: Error) { - const errorMessage = error.message.toLowerCase(); - if (errorMessage.includes('unique') || errorMessage.includes('duplicate')) { - throw new ResponseHelper.ResponseError('Tag name already exists', undefined, 400); - } - - throw new ResponseHelper.ResponseError(errorMessage, undefined, 400); + return requestOrder.map((tagId) => tagMap[tagId]); } // ---------------------------------- diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts new file mode 100644 index 0000000000..4f64479b92 --- /dev/null +++ b/packages/cli/src/UserManagement/Interfaces.ts @@ -0,0 +1,40 @@ +/* eslint-disable import/no-cycle */ +import { Application } from 'express'; +import { JwtFromRequestFunction } from 'passport-jwt'; +import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces'; +import { ActiveWorkflowRunner } from '..'; + +export interface JwtToken { + token: string; + expiresIn: number; +} + +export interface JwtOptions { + secretOrKey: string; + jwtFromRequest: JwtFromRequestFunction; +} + +export interface JwtPayload { + id: string; + email: string | null; + password: string | null; +} + +export interface PublicUser { + id: string; + email?: string; + firstName?: string; + lastName?: string; + personalizationAnswers?: IPersonalizationSurveyAnswers | null; + password?: string; + passwordResetToken?: string; + isPending: boolean; +} + +export interface N8nApp { + app: Application; + restEndpoint: string; + externalHooks: IExternalHooksClass; + defaultCredentialsName: string; + activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner; +} diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts new file mode 100644 index 0000000000..332a0efb77 --- /dev/null +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -0,0 +1,218 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ +import { Workflow } from 'n8n-workflow'; +import { In, IsNull, Not } from 'typeorm'; +import express = require('express'); +import { PublicUser } from './Interfaces'; +import { Db, GenericHelpers, ResponseHelper } from '..'; +import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '../databases/entities/User'; +import { Role } from '../databases/entities/Role'; +import { AuthenticatedRequest } from '../requests'; +import config = require('../../config'); +import { getWebhookBaseUrl } from '../WebhookHelpers'; + +export async function getWorkflowOwner(workflowId: string | number): Promise { + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOneOrFail({ + where: { workflow: { id: workflowId } }, + relations: ['user', 'user.globalRole'], + }); + + return sharedWorkflow.user; +} + +export function isEmailSetUp(): boolean { + const smtp = config.get('userManagement.emails.mode') === 'smtp'; + const host = !!config.get('userManagement.emails.smtp.host'); + const user = !!config.get('userManagement.emails.smtp.auth.user'); + const pass = !!config.get('userManagement.emails.smtp.auth.pass'); + + return smtp && host && user && pass; +} + +async function getInstanceOwnerRole(): Promise { + const ownerRole = await Db.collections.Role!.findOneOrFail({ + where: { + name: 'owner', + scope: 'global', + }, + }); + return ownerRole; +} + +export async function getInstanceOwner(): Promise { + const ownerRole = await getInstanceOwnerRole(); + + const owner = await Db.collections.User!.findOneOrFail({ + relations: ['globalRole'], + where: { + globalRole: ownerRole, + }, + }); + return owner; +} + +/** + * Return the n8n instance base URL without trailing slash. + */ +export function getInstanceBaseUrl(): string { + const n8nBaseUrl = config.get('editorBaseUrl') || getWebhookBaseUrl(); + + return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; +} + +export async function isInstanceOwnerSetup(): Promise { + const users = await Db.collections.User!.find({ email: Not(IsNull()) }); + return users.length !== 0; +} + +// TODO: Enforce at model level +export function validatePassword(password?: string): string { + if (!password) { + throw new ResponseHelper.ResponseError('Password is mandatory', undefined, 400); + } + + const hasInvalidLength = + password.length < MIN_PASSWORD_LENGTH || password.length > MAX_PASSWORD_LENGTH; + + const hasNoNumber = !/\d/.test(password); + + const hasNoUppercase = !/[A-Z]/.test(password); + + if (hasInvalidLength || hasNoNumber || hasNoUppercase) { + const message: string[] = []; + + if (hasInvalidLength) { + message.push( + `Password must be ${MIN_PASSWORD_LENGTH} to ${MAX_PASSWORD_LENGTH} characters long.`, + ); + } + + if (hasNoNumber) { + message.push('Password must contain at least 1 number.'); + } + + if (hasNoUppercase) { + message.push('Password must contain at least 1 uppercase letter.'); + } + + throw new ResponseHelper.ResponseError(message.join(' '), undefined, 400); + } + + return password; +} + +/** + * Remove sensitive properties from the user to return to the client. + */ +export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { + const { + password, + resetPasswordToken, + resetPasswordTokenExpiration, + createdAt, + updatedAt, + ...sanitizedUser + } = user; + if (withoutKeys) { + withoutKeys.forEach((key) => { + // @ts-ignore + delete sanitizedUser[key]; + }); + } + return sanitizedUser; +} + +export async function getUserById(userId: string): Promise { + const user = await Db.collections.User!.findOneOrFail(userId, { + relations: ['globalRole'], + }); + return user; +} + +export async function checkPermissionsForExecution( + workflow: Workflow, + userId: string, +): Promise { + const credentialIds = new Set(); + const nodeNames = Object.keys(workflow.nodes); + // Iterate over all nodes + nodeNames.forEach((nodeName) => { + const node = workflow.nodes[nodeName]; + // And check if any of the nodes uses credentials. + if (node.credentials) { + const credentialNames = Object.keys(node.credentials); + // For every credential this node uses + credentialNames.forEach((credentialName) => { + const credentialDetail = node.credentials![credentialName]; + // If it does not contain an id, it means it is a very old + // workflow. Nowaways it should not happen anymore. + // Migrations should handle the case where a credential does + // not have an id. + if (!credentialDetail.id) { + throw new Error( + 'Error initializing workflow: credential ID not present. Please open the workflow and save it to fix this error.', + ); + } + credentialIds.add(credentialDetail.id.toString()); + }); + } + }); + + // Now that we obtained all credential IDs used by this workflow, we can + // now check if the owner of this workflow has access to all of them. + + const ids = Array.from(credentialIds); + + if (ids.length === 0) { + // If the workflow does not use any credentials, then we're fine + return true; + } + // If this check happens on top, we may get + // unitialized db errors. + // Db is certainly initialized if workflow uses credentials. + const user = await getUserById(userId); + if (user.globalRole.name === 'owner') { + return true; + } + + // Check for the user's permission to all used credentials + const credentialCount = await Db.collections.SharedCredentials!.count({ + where: { + user: { id: userId }, + credentials: In(ids), + }, + }); + + // Considering the user needs to have access to all credentials + // then both arrays (allowed credentials vs used credentials) + // must be the same length + if (ids.length !== credentialCount) { + throw new Error('One or more of the used credentials are not accessable.'); + } + return true; +} + +/** + * Check if a URL contains an auth-excluded endpoint. + */ +export function isAuthExcluded(url: string, ignoredEndpoints: string[]): boolean { + return !!ignoredEndpoints + .filter(Boolean) // skip empty paths + .find((ignoredEndpoint) => url.includes(ignoredEndpoint)); +} + +/** + * Check if the endpoint is `POST /users/:id`. + */ +export function isPostUsersId(req: express.Request, restEndpoint: string): boolean { + return ( + req.method === 'POST' && + new RegExp(`/${restEndpoint}/users/[\\w\\d-]*`).test(req.url) && + !req.url.includes('reinvite') + ); +} + +export function isAuthenticatedRequest(request: express.Request): request is AuthenticatedRequest { + return request.user !== undefined; +} diff --git a/packages/cli/src/UserManagement/auth/jwt.ts b/packages/cli/src/UserManagement/auth/jwt.ts new file mode 100644 index 0000000000..2dca697801 --- /dev/null +++ b/packages/cli/src/UserManagement/auth/jwt.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ + +import * as jwt from 'jsonwebtoken'; +import { Response } from 'express'; +import { createHash } from 'crypto'; +import { Db } from '../..'; +import { AUTH_COOKIE_NAME } from '../../constants'; +import { JwtToken, JwtPayload } from '../Interfaces'; +import { User } from '../../databases/entities/User'; +import config = require('../../../config'); + +export function issueJWT(user: User): JwtToken { + const { id, email, password } = user; + const expiresIn = 7 * 86400000; // 7 days + + const payload: JwtPayload = { + id, + email, + password: password ?? null, + }; + + if (password) { + payload.password = createHash('sha256') + .update(password.slice(password.length / 2)) + .digest('hex'); + } + + const signedToken = jwt.sign(payload, config.get('userManagement.jwtSecret'), { + expiresIn: expiresIn / 1000 /* in seconds */, + }); + + return { + token: signedToken, + expiresIn, + }; +} + +export async function resolveJwtContent(jwtPayload: JwtPayload): Promise { + const user = await Db.collections.User!.findOne(jwtPayload.id, { + relations: ['globalRole'], + }); + + let passwordHash = null; + if (user?.password) { + passwordHash = createHash('sha256') + .update(user.password.slice(user.password.length / 2)) + .digest('hex'); + } + + if (!user || jwtPayload.password !== passwordHash || user.email !== jwtPayload.email) { + // When owner hasn't been set up, the default user + // won't have email nor password (both equals null) + throw new Error('Invalid token content'); + } + return user; +} + +export async function resolveJwt(token: string): Promise { + const jwtPayload = jwt.verify(token, config.get('userManagement.jwtSecret')) as JwtPayload; + return resolveJwtContent(jwtPayload); +} + +export async function issueCookie(res: Response, user: User): Promise { + const userData = issueJWT(user); + res.cookie(AUTH_COOKIE_NAME, userData.token, { maxAge: userData.expiresIn, httpOnly: true }); +} diff --git a/packages/cli/src/UserManagement/email/Interfaces.ts b/packages/cli/src/UserManagement/email/Interfaces.ts new file mode 100644 index 0000000000..97da3d49ee --- /dev/null +++ b/packages/cli/src/UserManagement/email/Interfaces.ts @@ -0,0 +1,32 @@ +export interface UserManagementMailerImplementation { + sendMail: (mailData: MailData) => Promise; + verifyConnection: () => Promise; +} + +export type InviteEmailData = { + email: string; + firstName?: string; + lastName?: string; + inviteAcceptUrl: string; + domain: string; +}; + +export type PasswordResetData = { + email: string; + firstName?: string; + lastName?: string; + passwordResetUrl: string; + domain: string; +}; + +export type SendEmailResult = { + success: boolean; + error?: Error; +}; + +export type MailData = { + body: string | Buffer; + emailRecipients: string | string[]; + subject: string; + textOnly?: string; +}; diff --git a/packages/cli/src/UserManagement/email/NodeMailer.ts b/packages/cli/src/UserManagement/email/NodeMailer.ts new file mode 100644 index 0000000000..8ea708a2f3 --- /dev/null +++ b/packages/cli/src/UserManagement/email/NodeMailer.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { createTransport, Transporter } from 'nodemailer'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import config = require('../../../config'); +import { MailData, SendEmailResult, UserManagementMailerImplementation } from './Interfaces'; + +export class NodeMailer implements UserManagementMailerImplementation { + private transport: Transporter; + + constructor() { + this.transport = createTransport({ + host: config.get('userManagement.emails.smtp.host'), + port: config.get('userManagement.emails.smtp.port'), + secure: config.get('userManagement.emails.smtp.secure'), + auth: { + user: config.get('userManagement.emails.smtp.auth.user'), + pass: config.get('userManagement.emails.smtp.auth.pass'), + }, + }); + } + + async verifyConnection(): Promise { + const host = config.get('userManagement.emails.smtp.host') as string; + const user = config.get('userManagement.emails.smtp.auth.user') as string; + const pass = config.get('userManagement.emails.smtp.auth.pass') as string; + + return new Promise((resolve, reject) => { + this.transport.verify((error: Error) => { + if (!error) { + resolve(); + return; + } + + const message = []; + + if (!host) message.push('SMTP host not defined (N8N_SMTP_HOST).'); + if (!user) message.push('SMTP user not defined (N8N_SMTP_USER).'); + if (!pass) message.push('SMTP pass not defined (N8N_SMTP_PASS).'); + + reject(new Error(message.length ? message.join(' ') : error.message)); + }); + }); + } + + async sendMail(mailData: MailData): Promise { + let sender = config.get('userManagement.emails.smtp.sender'); + const user = config.get('userManagement.emails.smtp.auth.user') as string; + + if (!sender && user.includes('@')) { + sender = user; + } + + try { + await this.transport.sendMail({ + from: sender, + to: mailData.emailRecipients, + subject: mailData.subject, + text: mailData.textOnly, + html: mailData.body, + }); + Logger.verbose( + `Email sent successfully to the following recipients: ${mailData.emailRecipients.toString()}`, + ); + } catch (error) { + Logger.error('Failed to send email', { recipients: mailData.emailRecipients, error }); + return { + success: false, + error, + }; + } + + return { success: true }; + } +} diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts new file mode 100644 index 0000000000..525f5397d3 --- /dev/null +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -0,0 +1,98 @@ +/* eslint-disable import/no-cycle */ +import { existsSync, readFileSync } from 'fs'; +import { IDataObject } from 'n8n-workflow'; +import { join as pathJoin } from 'path'; +import { GenericHelpers } from '../..'; +import config = require('../../../config'); +import { + InviteEmailData, + PasswordResetData, + SendEmailResult, + UserManagementMailerImplementation, +} from './Interfaces'; +import { NodeMailer } from './NodeMailer'; + +async function getTemplate(configKeyName: string, defaultFilename: string) { + const templateOverride = (await GenericHelpers.getConfigValue( + `userManagement.emails.templates.${configKeyName}`, + )) as string; + + let template; + if (templateOverride && existsSync(templateOverride)) { + template = readFileSync(templateOverride, { + encoding: 'utf-8', + }); + } else { + template = readFileSync(pathJoin(__dirname, `templates/${defaultFilename}`), { + encoding: 'utf-8', + }); + } + return template; +} + +function replaceStrings(template: string, data: IDataObject) { + let output = template; + const keys = Object.keys(data); + keys.forEach((key) => { + const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'); + output = output.replace(regex, data[key] as string); + }); + return output; +} + +export class UserManagementMailer { + private mailer: UserManagementMailerImplementation | undefined; + + constructor() { + // Other implementations can be used in the future. + if (config.get('userManagement.emails.mode') === 'smtp') { + this.mailer = new NodeMailer(); + } + } + + async verifyConnection(): Promise { + if (!this.mailer) return Promise.reject(); + + return this.mailer.verifyConnection(); + } + + async invite(inviteEmailData: InviteEmailData): Promise { + 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', + body: template, + }); + + // If mailer does not exist it means mail has been disabled. + return result ?? { success: true }; + } + + async passwordReset(passwordResetData: PasswordResetData): Promise { + let template = await getTemplate('passwordReset', 'passwordReset.html'); + template = replaceStrings(template, passwordResetData); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = await this.mailer?.sendMail({ + emailRecipients: passwordResetData.email, + subject: 'n8n password reset', + body: template, + }); + + // If mailer does not exist it means mail has been disabled. + return result ?? { success: true }; + } +} + +let mailerInstance: UserManagementMailer | undefined; + +export async function getInstance(): Promise { + if (mailerInstance === undefined) { + mailerInstance = new UserManagementMailer(); + await mailerInstance.verifyConnection(); + } + return mailerInstance; +} diff --git a/packages/cli/src/UserManagement/email/index.ts b/packages/cli/src/UserManagement/email/index.ts new file mode 100644 index 0000000000..1d49343ff7 --- /dev/null +++ b/packages/cli/src/UserManagement/email/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/no-cycle */ +import { getInstance, UserManagementMailer } from './UserManagementMailer'; + +export { getInstance, UserManagementMailer }; diff --git a/packages/cli/src/UserManagement/email/templates/instanceSetup.html b/packages/cli/src/UserManagement/email/templates/instanceSetup.html new file mode 100644 index 0000000000..0a6c785912 --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/instanceSetup.html @@ -0,0 +1,5 @@ +

Hi there!

+

Welcome to n8n, {{firstName}} {{lastName}}

+

Your instance is set up!

+

Use your email to login: {{email}} and the chosen password.

+

Have fun automating!

diff --git a/packages/cli/src/UserManagement/email/templates/invite.html b/packages/cli/src/UserManagement/email/templates/invite.html new file mode 100644 index 0000000000..178017d19d --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/invite.html @@ -0,0 +1,4 @@ +

Hi there,

+

You have been invited to join n8n ({{ domain }}).

+

To accept, click the following link:

+

{{ inviteAcceptUrl }}

diff --git a/packages/cli/src/UserManagement/email/templates/passwordReset.html b/packages/cli/src/UserManagement/email/templates/passwordReset.html new file mode 100644 index 0000000000..161407592e --- /dev/null +++ b/packages/cli/src/UserManagement/email/templates/passwordReset.html @@ -0,0 +1,5 @@ +

Hi {{firstName}},

+

Somebody asked to reset your password on n8n ({{ domain }}).

+

If it was not you, you can safely ignore this email.

+

Click the following link to choose a new password. The link is valid for 2 hours.

+{{ passwordResetUrl }} diff --git a/packages/cli/src/UserManagement/index.ts b/packages/cli/src/UserManagement/index.ts new file mode 100644 index 0000000000..b2d04508fe --- /dev/null +++ b/packages/cli/src/UserManagement/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/no-cycle */ +import { addRoutes } from './routes'; + +export const userManagementRouter = { addRoutes }; diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts new file mode 100644 index 0000000000..a91e46497a --- /dev/null +++ b/packages/cli/src/UserManagement/routes/auth.ts @@ -0,0 +1,119 @@ +/* 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 { compare } from 'bcryptjs'; +import { IDataObject } from 'n8n-workflow'; +import { Db, ResponseHelper } from '../..'; +import { AUTH_COOKIE_NAME } from '../../constants'; +import { issueCookie, resolveJwt } from '../auth/jwt'; +import { N8nApp, PublicUser } from '../Interfaces'; +import { isInstanceOwnerSetup, sanitizeUser } from '../UserManagementHelper'; +import { User } from '../../databases/entities/User'; +import type { LoginRequest } from '../../requests'; + +export function authenticationMethods(this: N8nApp): void { + /** + * Log in a user. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/login`, + ResponseHelper.send(async (req: LoginRequest, res: Response): Promise => { + if (!req.body.email) { + throw new Error('Email is required to log in'); + } + + if (!req.body.password) { + throw new Error('Password is required to log in'); + } + + let user; + try { + user = await Db.collections.User!.findOne( + { + email: req.body.email, + }, + { + relations: ['globalRole'], + }, + ); + } catch (error) { + throw new Error('Unable to access database.'); + } + if (!user || !user.password || !(await compare(req.body.password, user.password))) { + // password is empty until user signs up + const error = new Error('Wrong username or password. Do you have caps lock on?'); + // @ts-ignore + error.httpStatusCode = 401; + throw error; + } + + await issueCookie(res, user); + + return sanitizeUser(user); + }), + ); + + /** + * Manually check the `n8n-auth` cookie. + */ + this.app.get( + `/${this.restEndpoint}/login`, + ResponseHelper.send(async (req: Request, res: Response): Promise => { + // Manually check the existing cookie. + const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined; + + let user: User; + if (cookieContents) { + // If logged in, return user + try { + user = await resolveJwt(cookieContents); + return sanitizeUser(user); + } catch (error) { + res.clearCookie(AUTH_COOKIE_NAME); + } + } + + if (await isInstanceOwnerSetup()) { + const error = new Error('Not logged in'); + // @ts-ignore + error.httpStatusCode = 401; + throw error; + } + + try { + 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.', + ); + } + + if (user.email || user.password) { + throw new Error('Invalid database state - user has password set.'); + } + + await issueCookie(res, user); + + return sanitizeUser(user); + }), + ); + + /** + * Log out a user. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/logout`, + ResponseHelper.send(async (_, res: Response): Promise => { + res.clearCookie(AUTH_COOKIE_NAME); + return { + loggedOut: true, + }; + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/UserManagement/routes/index.ts new file mode 100644 index 0000000000..24fe8a26e0 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable import/no-cycle */ +import cookieParser = require('cookie-parser'); +import * as passport from 'passport'; +import { Strategy } from 'passport-jwt'; +import { NextFunction, Request, Response } from 'express'; +import * as jwt from 'jsonwebtoken'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { JwtPayload, N8nApp } from '../Interfaces'; +import { authenticationMethods } from './auth'; +import config = require('../../../config'); +import { AUTH_COOKIE_NAME } from '../../constants'; +import { issueCookie, resolveJwtContent } from '../auth/jwt'; +import { meNamespace } from './me'; +import { usersNamespace } from './users'; +import { passwordResetNamespace } from './passwordReset'; +import { AuthenticatedRequest } from '../../requests'; +import { ownerNamespace } from './owner'; +import { isAuthExcluded, isPostUsersId, isAuthenticatedRequest } from '../UserManagementHelper'; + +export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { + // needed for testing; not adding overhead since it directly returns if req.cookies exists + this.app.use(cookieParser()); + + const options = { + jwtFromRequest: (req: Request) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null; + }, + secretOrKey: config.get('userManagement.jwtSecret') as string, + }; + + passport.use( + new Strategy(options, async function validateCookieContents(jwtPayload: JwtPayload, done) { + try { + const user = await resolveJwtContent(jwtPayload); + return done(null, user); + } catch (error) { + Logger.debug('Failed to extract user from JWT payload', { jwtPayload }); + return done(null, false, { message: 'User not found' }); + } + }), + ); + + this.app.use(passport.initialize()); + + this.app.use((req: Request, res: Response, next: NextFunction) => { + if ( + // TODO: refactor me!!! + // skip authentication for preflight requests + req.method === 'OPTIONS' || + req.url === '/index.html' || + req.url === '/favicon.ico' || + req.url.startsWith('/css/') || + req.url.startsWith('/js/') || + req.url.startsWith('/fonts/') || + req.url.includes('.svg') || + req.url.startsWith(`/${restEndpoint}/settings`) || + req.url.includes('login') || + req.url.includes('logout') || + req.url.startsWith(`/${restEndpoint}/resolve-signup-token`) || + isPostUsersId(req, restEndpoint) || + req.url.startsWith(`/${restEndpoint}/forgot-password`) || + req.url.startsWith(`/${restEndpoint}/resolve-password-token`) || + req.url.startsWith(`/${restEndpoint}/change-password`) || + isAuthExcluded(req.url, ignoredEndpoints) + ) { + return next(); + } + + return passport.authenticate('jwt', { session: false })(req, res, next); + }); + + this.app.use((req: Request | AuthenticatedRequest, res: Response, next: NextFunction) => { + // req.user is empty for public routes, so just proceed + // owner can do anything, so proceed as well + if (!req.user || (isAuthenticatedRequest(req) && req.user.globalRole.name === 'owner')) { + next(); + return; + } + // Not owner and user exists. We now protect restricted urls. + const postRestrictedUrls = [`/${this.restEndpoint}/users`, `/${this.restEndpoint}/owner`]; + const getRestrictedUrls = [`/${this.restEndpoint}/users`]; + const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; + if ( + (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || + (req.method === 'GET' && getRestrictedUrls.includes(trimmedUrl)) || + (req.method === 'DELETE' && + new RegExp(`/${restEndpoint}/users/[^/]+`, 'gm').test(trimmedUrl)) || + (req.method === 'POST' && + new RegExp(`/${restEndpoint}/users/[^/]+/reinvite`, 'gm').test(trimmedUrl)) || + new RegExp(`/${restEndpoint}/owner/[^/]+`, 'gm').test(trimmedUrl) + ) { + Logger.verbose('User attempted to access endpoint without authorization', { + endpoint: `${req.method} ${trimmedUrl}`, + userId: isAuthenticatedRequest(req) ? req.user.id : 'unknown', + }); + res.status(403).json({ status: 'error', message: 'Unauthorized' }); + return; + } + + next(); + }); + + // middleware to refresh cookie before it expires + this.app.use(async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const cookieAuth = options.jwtFromRequest(req); + if (cookieAuth && req.user) { + const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number }; + if (cookieContents.exp * 1000 - Date.now() < 259200000) { + // if cookie expires in < 3 days, renew it. + await issueCookie(res, req.user); + } + } + next(); + }); + + authenticationMethods.apply(this); + ownerNamespace.apply(this); + meNamespace.apply(this); + passwordResetNamespace.apply(this); + usersNamespace.apply(this); +} diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts new file mode 100644 index 0000000000..1c4ed9f427 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/me.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ + +import { compare, genSaltSync, hashSync } from 'bcryptjs'; +import express = require('express'); +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { issueCookie } from '../auth/jwt'; +import { N8nApp, PublicUser } from '../Interfaces'; +import { validatePassword, sanitizeUser } from '../UserManagementHelper'; +import type { AuthenticatedRequest, MeRequest } from '../../requests'; +import { validateEntity } from '../../GenericHelpers'; +import { User } from '../../databases/entities/User'; + +export function meNamespace(this: N8nApp): void { + /** + * Return the logged-in user. + */ + this.app.get( + `/${this.restEndpoint}/me`, + ResponseHelper.send(async (req: AuthenticatedRequest): Promise => { + return sanitizeUser(req.user); + }), + ); + + /** + * Update the logged-in user's settings, except password. + */ + this.app.patch( + `/${this.restEndpoint}/me`, + ResponseHelper.send( + async (req: MeRequest.Settings, res: express.Response): Promise => { + if (!req.body.email) { + Logger.debug('Request to update user email failed because of missing email in payload', { + userId: req.user.id, + payload: req.body, + }); + throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); + } + + if (!validator.isEmail(req.body.email)) { + Logger.debug('Request to update user email failed because of invalid email in payload', { + userId: req.user.id, + invalidEmail: req.body.email, + }); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + const newUser = new User(); + + Object.assign(newUser, req.user, req.body); + + await validateEntity(newUser); + + const user = await Db.collections.User!.save(newUser); + + Logger.info('User updated successfully', { userId: user.id }); + + await issueCookie(res, user); + + const updatedkeys = Object.keys(req.body); + void InternalHooksManager.getInstance().onUserUpdate({ + user_id: req.user.id, + fields_changed: updatedkeys, + }); + + return sanitizeUser(user); + }, + ), + ); + + /** + * Update the logged-in user's password. + */ + this.app.patch( + `/${this.restEndpoint}/me/password`, + ResponseHelper.send(async (req: MeRequest.Password, res: express.Response) => { + const { currentPassword, newPassword } = req.body; + + if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { + throw new ResponseHelper.ResponseError('Invalid payload.', undefined, 400); + } + + if (!req.user.password) { + throw new ResponseHelper.ResponseError('Requesting user not set up.'); + } + + const isCurrentPwCorrect = await compare(currentPassword, req.user.password); + if (!isCurrentPwCorrect) { + throw new ResponseHelper.ResponseError( + 'Provided current password is incorrect.', + undefined, + 400, + ); + } + + const validPassword = validatePassword(newPassword); + + req.user.password = hashSync(validPassword, genSaltSync(10)); + + const user = await Db.collections.User!.save(req.user); + Logger.info('Password updated successfully', { userId: user.id }); + + await issueCookie(res, user); + + void InternalHooksManager.getInstance().onUserUpdate({ + user_id: req.user.id, + fields_changed: ['password'], + }); + + return { success: true }; + }), + ); + + /** + * Store the logged-in user's survey answers. + */ + this.app.post( + `/${this.restEndpoint}/me/survey`, + ResponseHelper.send(async (req: MeRequest.SurveyAnswers) => { + const { body: personalizationAnswers } = req; + + if (!personalizationAnswers) { + Logger.debug( + 'Request to store user personalization survey failed because of empty payload', + { + userId: req.user.id, + }, + ); + throw new ResponseHelper.ResponseError( + 'Personalization answers are mandatory', + undefined, + 400, + ); + } + + await Db.collections.User!.save({ + id: req.user.id, + personalizationAnswers, + }); + + Logger.info('User survey updated successfully', { userId: req.user.id }); + + void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted( + req.user.id, + personalizationAnswers, + ); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/owner.ts b/packages/cli/src/UserManagement/routes/owner.ts new file mode 100644 index 0000000000..a526afc948 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/owner.ts @@ -0,0 +1,122 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { hashSync, genSaltSync } from 'bcryptjs'; +import * as express from 'express'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import config = require('../../../config'); +import { validateEntity } from '../../GenericHelpers'; +import { AuthenticatedRequest, OwnerRequest } from '../../requests'; +import { issueCookie } from '../auth/jwt'; +import { N8nApp } from '../Interfaces'; +import { sanitizeUser, validatePassword } from '../UserManagementHelper'; + +export function ownerNamespace(this: N8nApp): void { + /** + * Promote a shell into the owner of the n8n instance, + * and enable `isInstanceOwnerSetUp` setting. + */ + this.app.post( + `/${this.restEndpoint}/owner`, + ResponseHelper.send(async (req: OwnerRequest.Post, res: express.Response) => { + const { email, firstName, lastName, password } = req.body; + const { id: userId } = req.user; + + if (config.get('userManagement.isInstanceOwnerSetUp')) { + Logger.debug( + 'Request to claim instance ownership failed because instance owner already exists', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + if (!email || !validator.isEmail(email)) { + Logger.debug('Request to claim instance ownership failed because of invalid email', { + userId, + invalidEmail: email, + }); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + const validPassword = validatePassword(password); + + if (!firstName || !lastName) { + Logger.debug( + 'Request to claim instance ownership failed because of missing first name or last name in payload', + { userId, payload: req.body }, + ); + throw new ResponseHelper.ResponseError( + 'First and last names are mandatory', + undefined, + 400, + ); + } + + let owner = await Db.collections.User!.findOne(userId, { + relations: ['globalRole'], + }); + + if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) { + Logger.debug( + 'Request to claim instance ownership failed because user shell does not exist or has wrong role!', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + owner = Object.assign(owner, { + email, + firstName, + lastName, + password: hashSync(validPassword, genSaltSync(10)), + }); + + await validateEntity(owner); + + owner = await Db.collections.User!.save(owner); + + Logger.info('Owner was set up successfully', { userId: req.user.id }); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + + config.set('userManagement.isInstanceOwnerSetUp', true); + + Logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id }); + + await issueCookie(res, owner); + + void InternalHooksManager.getInstance().onInstanceOwnerSetup({ + user_id: userId, + }); + + return sanitizeUser(owner); + }), + ); + + /** + * Persist that the instance owner setup has been skipped + */ + this.app.post( + `/${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( + { key: 'userManagement.skipInstanceOwnerSetup' }, + { value: JSON.stringify(true) }, + ); + + config.set('userManagement.skipInstanceOwnerSetup', true); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/passwordReset.ts b/packages/cli/src/UserManagement/routes/passwordReset.ts new file mode 100644 index 0000000000..574551e142 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/passwordReset.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ + +import express = require('express'); +import { v4 as uuid } from 'uuid'; +import { URL } from 'url'; +import { genSaltSync, hashSync } from 'bcryptjs'; +import validator from 'validator'; +import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import { N8nApp } from '../Interfaces'; +import { getInstanceBaseUrl, validatePassword } from '../UserManagementHelper'; +import * as UserManagementMailer from '../email'; +import type { PasswordResetRequest } from '../../requests'; +import { issueCookie } from '../auth/jwt'; +import config = require('../../../config'); + +export function passwordResetNamespace(this: N8nApp): void { + /** + * Send a password reset email. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/forgot-password`, + ResponseHelper.send(async (req: PasswordResetRequest.Email) => { + if (config.get('userManagement.emails.mode') === '') { + Logger.debug('Request to send password reset email failed because emailing was not set up'); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + + const { email } = req.body; + + if (!email) { + Logger.debug( + 'Request to send password reset email failed because of missing email in payload', + { payload: req.body }, + ); + throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); + } + + if (!validator.isEmail(email)) { + Logger.debug( + 'Request to send password reset email failed because of invalid email in payload', + { invalidEmail: email }, + ); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + // User should just be able to reset password if one is already present + const user = await Db.collections.User!.findOne({ email, password: Not(IsNull()) }); + + if (!user || !user.password) { + Logger.debug( + 'Request to send password reset email failed because no user was found for the provided email', + { invalidEmail: email }, + ); + return; + } + + user.resetPasswordToken = uuid(); + + const { id, firstName, lastName, resetPasswordToken } = user; + + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200; + + await Db.collections.User!.update(id, { resetPasswordToken, resetPasswordTokenExpiration }); + + const baseUrl = getInstanceBaseUrl(); + const url = new URL(`${baseUrl}/change-password`); + url.searchParams.append('userId', id); + url.searchParams.append('token', resetPasswordToken); + + try { + const mailer = await UserManagementMailer.getInstance(); + await mailer.passwordReset({ + email, + firstName, + lastName, + passwordResetUrl: url.toString(), + domain: baseUrl, + }); + } catch (error) { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: user.id, + message_type: 'Reset password', + }); + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + `Please contact your administrator: ${error.message}`, + undefined, + 500, + ); + } + } + + Logger.info('Sent password reset email successfully', { userId: user.id, email }); + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: id, + message_type: 'Reset password', + }); + + void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({ + user_id: id, + }); + }), + ); + + /** + * Verify password reset token and user ID. + * + * Authless endpoint. + */ + this.app.get( + `/${this.restEndpoint}/resolve-password-token`, + ResponseHelper.send(async (req: PasswordResetRequest.Credentials) => { + const { token: resetPasswordToken, userId: id } = req.query; + + if (!resetPasswordToken || !id) { + Logger.debug( + 'Request to resolve password token failed because of missing password reset token or user ID in query string', + { + queryString: req.query, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 400); + } + + // Timestamp is saved in seconds + const currentTimestamp = Math.floor(Date.now() / 1000); + + const user = await Db.collections.User!.findOne({ + id, + resetPasswordToken, + resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + }); + + if (!user) { + Logger.debug( + 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', + { + userId: id, + resetPasswordToken, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 404); + } + + Logger.info('Reset-password token resolved successfully', { userId: id }); + void InternalHooksManager.getInstance().onUserPasswordResetEmailClick({ + user_id: id, + }); + }), + ); + + /** + * Verify password reset token and user ID and update password. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/change-password`, + ResponseHelper.send(async (req: PasswordResetRequest.NewPassword, res: express.Response) => { + const { token: resetPasswordToken, userId, password } = req.body; + + if (!resetPasswordToken || !userId || !password) { + Logger.debug( + 'Request to change password failed because of missing user ID or password or reset password token in payload', + { + payload: req.body, + }, + ); + throw new ResponseHelper.ResponseError( + 'Missing user ID or password or reset password token', + undefined, + 400, + ); + } + + const validPassword = validatePassword(password); + + // Timestamp is saved in seconds + const currentTimestamp = Math.floor(Date.now() / 1000); + + const user = await Db.collections.User!.findOne({ + id: userId, + resetPasswordToken, + resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + }); + + if (!user) { + Logger.debug( + 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', + { + userId, + resetPasswordToken, + }, + ); + throw new ResponseHelper.ResponseError('', undefined, 404); + } + + await Db.collections.User!.update(userId, { + password: hashSync(validPassword, genSaltSync(10)), + resetPasswordToken: null, + resetPasswordTokenExpiration: null, + }); + + Logger.info('User password updated successfully', { userId }); + + await issueCookie(res, user); + }), + ); +} diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts new file mode 100644 index 0000000000..1e7e41a9f0 --- /dev/null +++ b/packages/cli/src/UserManagement/routes/users.ts @@ -0,0 +1,562 @@ +/* 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 { genSaltSync, hashSync } from 'bcryptjs'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ITelemetryUserDeletionData, ResponseHelper } from '../..'; +import { N8nApp, PublicUser } from '../Interfaces'; +import { UserRequest } from '../../requests'; +import { + getInstanceBaseUrl, + isEmailSetUp, + sanitizeUser, + validatePassword, +} from '../UserManagementHelper'; +import { User } from '../../databases/entities/User'; +import { SharedWorkflow } from '../../databases/entities/SharedWorkflow'; +import { SharedCredentials } from '../../databases/entities/SharedCredentials'; +import * as UserManagementMailer from '../email/UserManagementMailer'; + +import config = require('../../../config'); +import { issueCookie } from '../auth/jwt'; + +export function usersNamespace(this: N8nApp): void { + /** + * Send email invite(s) to one or multiple users and create user shell(s). + */ + this.app.post( + `/${this.restEndpoint}/users`, + ResponseHelper.send(async (req: UserRequest.Invite) => { + if (config.get('userManagement.emails.mode') === '') { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because emailing was not set up', + ); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + `There is a problem with your SMTP setup! ${error.message}`, + undefined, + 500, + ); + } + } + + // TODO: this should be checked in the middleware rather than here + if (config.get('userManagement.disabled')) { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because user management is disabled', + ); + throw new ResponseHelper.ResponseError('User management is disabled'); + } + + if (!config.get('userManagement.isInstanceOwnerSetUp')) { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because the owner account is not set up', + ); + throw new ResponseHelper.ResponseError( + 'You must set up your own account before inviting others', + undefined, + 400, + ); + } + + if (!Array.isArray(req.body)) { + Logger.debug( + 'Request to send email invite(s) to user(s) failed because the payload is not an array', + { + payload: req.body, + }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + if (!req.body.length) return []; + + const createUsers: { [key: string]: string | null } = {}; + // Validate payload + req.body.forEach((invite) => { + if (typeof invite !== 'object' || !invite.email) { + throw new ResponseHelper.ResponseError( + 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', + undefined, + 400, + ); + } + + if (!validator.isEmail(invite.email)) { + Logger.debug('Invalid email in payload', { invalidEmail: invite.email }); + throw new ResponseHelper.ResponseError( + `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, + undefined, + 400, + ); + } + createUsers[invite.email] = null; + }); + + const role = await Db.collections.Role!.findOne({ scope: 'global', name: 'member' }); + + if (!role) { + Logger.error( + 'Request to send email invite(s) to user(s) failed because no global member role was found in database', + ); + throw new ResponseHelper.ResponseError( + 'Members role not found in database - inconsistent state', + undefined, + 500, + ); + } + + // remove/exclude existing users from creation + const existingUsers = await Db.collections.User!.find({ + where: { email: In(Object.keys(createUsers)) }, + }); + existingUsers.forEach((user) => { + if (user.password) { + delete createUsers[user.email]; + return; + } + createUsers[user.email] = user.id; + }); + + const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null); + const total = usersToSetUp.length; + + Logger.debug(total > 1 ? `Creating ${total} user shells...` : `Creating 1 user shell...`); + + try { + await Db.transaction(async (transactionManager) => { + return Promise.all( + usersToSetUp.map(async (email) => { + const newUser = Object.assign(new User(), { + email, + globalRole: role, + }); + const savedUser = await transactionManager.save(newUser); + createUsers[savedUser.email] = savedUser.id; + return savedUser; + }), + ); + }); + + void InternalHooksManager.getInstance().onUserInvite({ + user_id: req.user.id, + target_user_id: Object.values(createUsers) as string[], + }); + } catch (error) { + Logger.error('Failed to create user shells', { userShells: createUsers }); + throw new ResponseHelper.ResponseError('An error occurred during user creation'); + } + + Logger.info('Created user shell(s) successfully', { userId: req.user.id }); + Logger.verbose(total > 1 ? `${total} user shells created` : `1 user shell created`, { + userShells: createUsers, + }); + + const baseUrl = getInstanceBaseUrl(); + + const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email); + + // send invite email to new or not yet setup users + + const emailingResults = await Promise.all( + usersPendingSetup.map(async ([email, id]) => { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${id}`; + const result = await mailer?.invite({ + email, + inviteAcceptUrl, + domain: baseUrl, + }); + const resp: { user: { id: string | null; email: string }; error?: string } = { + user: { + id, + email, + }, + }; + if (result?.success) { + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: id!, + message_type: 'New user invite', + }); + } else { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: req.user.id, + message_type: 'New user invite', + }); + Logger.error('Failed to send email', { + userId: req.user.id, + inviteAcceptUrl, + domain: baseUrl, + email, + }); + resp.error = `Email could not be sent`; + } + return resp; + }), + ); + + Logger.debug( + usersPendingSetup.length > 1 + ? `Sent ${usersPendingSetup.length} invite emails successfully` + : `Sent 1 invite email successfully`, + { userShells: createUsers }, + ); + + return emailingResults; + }), + ); + + /** + * Validate invite token to enable invitee to set up their account. + * + * Authless endpoint. + */ + this.app.get( + `/${this.restEndpoint}/resolve-signup-token`, + ResponseHelper.send(async (req: UserRequest.ResolveSignUp) => { + const { inviterId, inviteeId } = req.query; + + if (!inviterId || !inviteeId) { + Logger.debug( + 'Request to resolve signup token failed because of missing user IDs in query string', + { inviterId, inviteeId }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + // Postgres validates UUID format + for (const userId of [inviterId, inviteeId]) { + if (!validator.isUUID(userId)) { + Logger.debug('Request to resolve signup token failed because of invalid user ID', { + userId, + }); + throw new ResponseHelper.ResponseError('Invalid userId', undefined, 400); + } + } + + const users = await Db.collections.User!.find({ where: { id: In([inviterId, inviteeId]) } }); + + if (users.length !== 2) { + Logger.debug( + 'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database', + { inviterId, inviteeId }, + ); + throw new ResponseHelper.ResponseError('Invalid invite URL', undefined, 400); + } + + const invitee = users.find((user) => user.id === inviteeId); + + if (!invitee || invitee.password) { + Logger.error('Invalid invite URL - invitee already setup', { + inviterId, + inviteeId, + }); + throw new ResponseHelper.ResponseError('The invitation was likely either deleted or already claimed', undefined, 400); + } + + const inviter = users.find((user) => user.id === inviterId); + + if (!inviter || !inviter.email || !inviter.firstName) { + Logger.error( + 'Request to resolve signup token failed because inviter does not exist or is not set up', + { + inviterId: inviter?.id, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + void InternalHooksManager.getInstance().onUserInviteEmailClick({ + user_id: inviteeId, + }); + + const { firstName, lastName } = inviter; + + return { inviter: { firstName, lastName } }; + }), + ); + + /** + * Fill out user shell with first name, last name, and password. + * + * Authless endpoint. + */ + this.app.post( + `/${this.restEndpoint}/users/:id`, + ResponseHelper.send(async (req: UserRequest.Update, res: Response) => { + const { id: inviteeId } = req.params; + + const { inviterId, firstName, lastName, password } = req.body; + + if (!inviterId || !inviteeId || !firstName || !lastName || !password) { + Logger.debug( + 'Request to fill out a user shell failed because of missing properties in payload', + { payload: req.body }, + ); + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + const validPassword = validatePassword(password); + + const users = await Db.collections.User!.find({ + where: { id: In([inviterId, inviteeId]) }, + relations: ['globalRole'], + }); + + if (users.length !== 2) { + Logger.debug( + 'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database', + { + inviterId, + inviteeId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid payload or URL', undefined, 400); + } + + const invitee = users.find((user) => user.id === inviteeId) as User; + + if (invitee.password) { + Logger.debug( + 'Request to fill out a user shell failed because the invite had already been accepted', + { inviteeId }, + ); + throw new ResponseHelper.ResponseError( + 'This invite has been accepted already', + undefined, + 400, + ); + } + + invitee.firstName = firstName; + invitee.lastName = lastName; + invitee.password = hashSync(validPassword, genSaltSync(10)); + + const updatedUser = await Db.collections.User!.save(invitee); + + await issueCookie(res, updatedUser); + + void InternalHooksManager.getInstance().onUserSignup({ + user_id: invitee.id, + }); + + return sanitizeUser(updatedUser); + }), + ); + + this.app.get( + `/${this.restEndpoint}/users`, + ResponseHelper.send(async () => { + const users = await Db.collections.User!.find({ relations: ['globalRole'] }); + + return users.map((user): PublicUser => sanitizeUser(user, ['personalizationAnswers'])); + }), + ); + + /** + * Delete a user. Optionally, designate a transferee for their workflows and credentials. + */ + this.app.delete( + `/${this.restEndpoint}/users/:id`, + ResponseHelper.send(async (req: UserRequest.Delete) => { + const { id: idToDelete } = req.params; + + if (req.user.id === idToDelete) { + Logger.debug( + 'Request to delete a user failed because it attempted to delete the requesting user', + { userId: req.user.id }, + ); + throw new ResponseHelper.ResponseError('Cannot delete your own user', undefined, 400); + } + + const { transferId } = req.query; + + if (transferId === idToDelete) { + throw new ResponseHelper.ResponseError( + 'Request to delete a user failed because the user to delete and the transferee are the same user', + undefined, + 400, + ); + } + + const users = await Db.collections.User!.find({ + where: { id: In([transferId, idToDelete]) }, + }); + + if (!users.length || (transferId && users.length !== 2)) { + throw new ResponseHelper.ResponseError( + 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', + undefined, + 404, + ); + } + + const userToDelete = users.find((user) => user.id === req.params.id) as User; + + if (transferId) { + const transferee = users.find((user) => user.id === transferId); + await Db.transaction(async (transactionManager) => { + await transactionManager.update( + SharedWorkflow, + { user: userToDelete }, + { user: transferee }, + ); + await transactionManager.update( + SharedCredentials, + { user: userToDelete }, + { user: transferee }, + ); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + + return { success: true }; + } + + const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ + Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: { user: userToDelete }, + }), + Db.collections.SharedCredentials!.find({ + relations: ['credentials'], + where: { user: userToDelete }, + }), + ]); + + await Db.transaction(async (transactionManager) => { + const ownedWorkflows = await Promise.all( + ownedSharedWorkflows.map(async ({ workflow }) => { + if (workflow.active) { + // deactivate before deleting + await this.activeWorkflowRunner.remove(workflow.id.toString()); + } + return workflow; + }), + ); + await transactionManager.remove(ownedWorkflows); + await transactionManager.remove( + ownedSharedCredentials.map(({ credentials }) => credentials), + ); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + + const telemetryData: ITelemetryUserDeletionData = { + user_id: req.user.id, + target_user_old_status: userToDelete.isPending ? 'invited' : 'active', + target_user_id: idToDelete, + }; + + telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data'; + + if (transferId) { + telemetryData.migration_user_id = transferId; + } + + void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData); + + return { success: true }; + }), + ); + + /** + * Resend email invite to user. + */ + this.app.post( + `/${this.restEndpoint}/users/:id/reinvite`, + ResponseHelper.send(async (req: UserRequest.Reinvite) => { + const { id: idToReinvite } = req.params; + + if (!isEmailSetUp()) { + Logger.error('Request to reinvite a user failed because email sending was not set up'); + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to invite other users', + undefined, + 500, + ); + } + + const reinvitee = await Db.collections.User!.findOne({ id: idToReinvite }); + + if (!reinvitee) { + Logger.debug( + 'Request to reinvite a user failed because the ID of the reinvitee was not found in database', + ); + throw new ResponseHelper.ResponseError('Could not find user', undefined, 404); + } + + if (reinvitee.password) { + Logger.debug( + 'Request to reinvite a user failed because the invite had already been accepted', + { userId: reinvitee.id }, + ); + throw new ResponseHelper.ResponseError( + 'User has already accepted the invite', + undefined, + 400, + ); + } + + const baseUrl = getInstanceBaseUrl(); + const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`; + + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError(error.message, undefined, 500); + } + } + + const result = await mailer?.invite({ + email: reinvitee.email, + inviteAcceptUrl, + domain: baseUrl, + }); + + if (!result?.success) { + void InternalHooksManager.getInstance().onEmailFailed({ + user_id: req.user.id, + message_type: 'Resend invite', + }); + Logger.error('Failed to send email', { + email: reinvitee.email, + inviteAcceptUrl, + domain: baseUrl, + }); + throw new ResponseHelper.ResponseError( + `Failed to send email to ${reinvitee.email}`, + undefined, + 500, + ); + } + + void InternalHooksManager.getInstance().onUserReinvite({ + user_id: req.user.id, + target_user_id: reinvitee.id, + }); + + void InternalHooksManager.getInstance().onUserTransactionalEmail({ + user_id: reinvitee.id, + message_type: 'Resend invite', + }); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index 791ff68e8e..1534ac325b 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -24,6 +24,7 @@ import { WorkflowCredentials, WorkflowRunner, } from '.'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; export class WaitTrackerClass { activeExecutionsInstance: ActiveExecutions.ActiveExecutions; @@ -157,10 +158,16 @@ export class WaitTrackerClass { throw new Error('The execution did succeed and can so not be started again.'); } + if (!fullExecutionData.workflowData.id) { + throw new Error('Only saved workflows can be resumed.'); + } + const user = await getWorkflowOwner(fullExecutionData.workflowData.id.toString()); + const data: IWorkflowExecutionDataProcess = { executionMode: fullExecutionData.mode, executionData: fullExecutionData.data, workflowData: fullExecutionData.workflowData, + userId: user.id, }; // Start the execution again diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index dd54b37a96..71e7b88a12 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -26,6 +26,7 @@ import { WorkflowCredentials, WorkflowExecuteAdditionalData, } from '.'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; export class WaitingWebhooks { async executeWebhook( @@ -111,7 +112,14 @@ export class WaitingWebhooks { settings: workflowData.settings, }); - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + let workflowOwner; + try { + workflowOwner = await getWorkflowOwner(workflowData.id!.toString()); + } catch (error) { + throw new ResponseHelper.ResponseError('Could not find workflow', undefined, 404); + } + + const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id); const webhookData = NodeHelpers.getNodeWebhooks( workflow, diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 7dfb7fa75c..7b1e0b650b 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/prefer-optional-chain */ @@ -53,6 +54,9 @@ import { // eslint-disable-next-line import/no-cycle import * as ActiveExecutions from './ActiveExecutions'; +import { User } from './databases/entities/User'; +import { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; const activeExecutions = ActiveExecutions.getInstance(); @@ -223,8 +227,22 @@ export async function executeWebhook( throw new ResponseHelper.ResponseError(errorMessage, 500, 500); } + let user: User; + if ( + (workflowData as WorkflowEntity).shared?.length && + (workflowData as WorkflowEntity).shared[0].user + ) { + user = (workflowData as WorkflowEntity).shared[0].user; + } else { + try { + user = await getWorkflowOwner(workflowData.id.toString()); + } catch (error) { + throw new ResponseHelper.ResponseError('Cannot find workflow', undefined, 404); + } + } + // Prepare everything that is needed to run the workflow - const additionalData = await WorkflowExecuteAdditionalData.getBase(); + const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); // Add the Response and Request so that this data can be accessed in the node additionalData.httpRequest = req; @@ -404,6 +422,7 @@ export async function executeWebhook( executionData: runExecutionData, sessionId, workflowData, + userId: user.id, }; let responsePromise: IDeferredPromise | undefined; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 0faada56f1..ee2b227744 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable no-restricted-syntax */ /* eslint-disable @typescript-eslint/restrict-plus-operands */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ @@ -39,7 +40,6 @@ import { import { LessThanOrEqual } from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import * as config from '../config'; -// eslint-disable-next-line import/no-cycle import { ActiveExecutions, CredentialsHelper, @@ -60,6 +60,12 @@ import { WorkflowCredentials, WorkflowHelpers, } from '.'; +import { + checkPermissionsForExecution, + getUserById, + getWorkflowOwner, +} from './UserManagement/UserManagementHelper'; +import { whereClause } from './WorkflowHelpers'; const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string; @@ -120,20 +126,36 @@ function executeErrorWorkflow( workflowId: workflowData.id, }); // If a specific error workflow is set run only that one + + // First, do permission checks. + if (!workflowData.id) { + // Manual executions do not trigger error workflows + // So this if should never happen. It was added to + // make sure there are no possible security gaps + return; + } + // eslint-disable-next-line @typescript-eslint/no-floating-promises - WorkflowHelpers.executeErrorWorkflow( - workflowData.settings.errorWorkflow as string, - workflowErrorData, - ); + getWorkflowOwner(workflowData.id).then((user) => { + void WorkflowHelpers.executeErrorWorkflow( + workflowData.settings!.errorWorkflow as string, + workflowErrorData, + user, + ); + }); } else if ( mode !== 'error' && workflowData.id !== undefined && workflowData.nodes.some((node) => node.type === ERROR_TRIGGER_TYPE) ) { Logger.verbose(`Start internal error workflow`, { executionId, workflowId: workflowData.id }); - // If the workflow contains - // eslint-disable-next-line @typescript-eslint/no-floating-promises - WorkflowHelpers.executeErrorWorkflow(workflowData.id.toString(), workflowErrorData); + void getWorkflowOwner(workflowData.id).then((user) => { + void WorkflowHelpers.executeErrorWorkflow( + workflowData.id!.toString(), + workflowErrorData, + user, + ); + }); } } } @@ -698,6 +720,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { export async function getRunData( workflowData: IWorkflowBase, + userId: string, inputData?: INodeExecutionData[], ): Promise { const mode = 'integrated'; @@ -751,27 +774,47 @@ export async function getRunData( executionData: runExecutionData, // @ts-ignore workflowData, + userId, }; return runData; } -export async function getWorkflowData(workflowInfo: IExecuteWorkflowInfo): Promise { +export async function getWorkflowData( + workflowInfo: IExecuteWorkflowInfo, + userId: string, +): Promise { if (workflowInfo.id === undefined && workflowInfo.code === undefined) { throw new Error( `No information about the workflow to execute found. Please provide either the "id" or "code"!`, ); } - if (Db.collections.Workflow === null) { - // The first time executeWorkflow gets called the Database has - // to get initialized first - await Db.init(); - } - let workflowData: IWorkflowBase | undefined; if (workflowInfo.id !== undefined) { - workflowData = await Db.collections.Workflow!.findOne(workflowInfo.id); + if (Db.collections.Workflow === null) { + // The first time executeWorkflow gets called the Database has + // to get initialized first + await Db.init(); + } + const user = await getUserById(userId); + let relations = ['workflow', 'workflow.tags']; + + if (config.get('workflowTagsDisabled')) { + relations = relations.filter((relation) => relation !== 'workflow.tags'); + } + + const shared = await Db.collections.SharedWorkflow!.findOne({ + relations, + where: whereClause({ + user, + entityType: 'workflow', + entityId: workflowInfo.id, + }), + }); + + workflowData = shared?.workflow; + if (workflowData === undefined) { throw new Error(`The workflow with the id "${workflowInfo.id}" does not exist.`); } @@ -805,7 +848,7 @@ export async function executeWorkflow( const nodeTypes = NodeTypes(); const workflowData = - loadedWorkflowData !== undefined ? loadedWorkflowData : await getWorkflowData(workflowInfo); + loadedWorkflowData ?? (await getWorkflowData(workflowInfo, additionalData.userId)); const workflowName = workflowData ? workflowData.name : undefined; const workflow = new Workflow({ @@ -819,7 +862,7 @@ export async function executeWorkflow( }); const runData = - loadedRunData !== undefined ? loadedRunData : await getRunData(workflowData, inputData); + loadedRunData ?? (await getRunData(workflowData, additionalData.userId, inputData)); let executionId; @@ -834,9 +877,11 @@ export async function executeWorkflow( let data; try { + await checkPermissionsForExecution(workflow, additionalData.userId); + // Create new additionalData to have different workflow loaded and to call // different webooks - const additionalDataIntegrated = await getBase(); + const additionalDataIntegrated = await getBase(additionalData.userId); additionalDataIntegrated.hooks = getWorkflowHooksIntegrated( runData.executionMode, executionId, @@ -908,6 +953,9 @@ export async function executeWorkflow( stoppedAt: fullRunData.stoppedAt, workflowData, }; + if (workflowData.id) { + fullExecutionData.workflowId = workflowData.id as string; + } const executionData = ResponseHelper.flattenExecutionData(fullExecutionData); @@ -919,7 +967,12 @@ export async function executeWorkflow( } await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); - void InternalHooksManager.getInstance().onWorkflowPostExecute(executionId, workflowData, data); + void InternalHooksManager.getInstance().onWorkflowPostExecute( + executionId, + workflowData, + data, + additionalData.userId, + ); if (data.finished === true) { // Workflow did finish successfully @@ -969,6 +1022,7 @@ export function sendMessageToUI(source: string, messages: any[]) { * @returns {Promise} */ export async function getBase( + userId: string, currentNodeParameters?: INodeParameters, executionTimeoutTimestamp?: number, ): Promise { @@ -995,6 +1049,7 @@ export async function getBase( webhookTestBaseUrl, currentNodeParameters, executionTimeoutTimestamp, + userId, }; } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 25bac0cbc0..d5a0f60e87 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ @@ -19,7 +20,6 @@ import { LoggerProxy as Logger, Workflow, } from 'n8n-workflow'; -import { validate } from 'class-validator'; // eslint-disable-next-line import/no-cycle import { CredentialTypes, @@ -29,13 +29,15 @@ import { IWorkflowErrorData, IWorkflowExecutionDataProcess, NodeTypes, - ResponseHelper, + WhereClause, WorkflowRunner, } from '.'; import * as config from '../config'; // eslint-disable-next-line import/no-cycle import { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { User } from './databases/entities/User'; +import { getWorkflowOwner } from './UserManagement/UserManagementHelper'; const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string; @@ -91,10 +93,36 @@ export function isWorkflowIdValid(id: string | null | undefined | number): boole export async function executeErrorWorkflow( workflowId: string, workflowErrorData: IWorkflowErrorData, + runningUser: User, ): Promise { // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here try { - const workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) }); + let workflowData; + if (workflowId.toString() !== workflowErrorData.workflow.id?.toString()) { + // To make this code easier to understand, we split it in 2 parts: + // 1) Fetch the owner of the errored workflows and then + // 2) if now instance owner, then check if the user has access to the + // triggered workflow. + + const user = await getWorkflowOwner(workflowErrorData.workflow.id!); + + if (user.globalRole.name === 'owner') { + workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) }); + } else { + const sharedWorkflowData = await Db.collections.SharedWorkflow!.findOne({ + where: { + workflow: { id: workflowId }, + user, + }, + relations: ['workflow'], + }); + if (sharedWorkflowData) { + workflowData = sharedWorkflowData.workflow; + } + } + } else { + workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) }); + } if (workflowData === undefined) { // The error workflow could not be found @@ -106,6 +134,15 @@ export async function executeErrorWorkflow( return; } + const user = await getWorkflowOwner(workflowId); + if (user.id !== runningUser.id) { + // The error workflow could not be found + Logger.warn( + `An attempt to execute workflow ID ${workflowId} as error workflow was blocked due to wrong permission`, + ); + return; + } + const executionMode = 'error'; const nodeTypes = NodeTypes(); @@ -169,6 +206,7 @@ export async function executeErrorWorkflow( executionMode, executionData: runExecutionData, workflowData, + userId: user.id, }; const workflowRunner = new WorkflowRunner(); @@ -521,34 +559,40 @@ export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promi return workflow; } -// TODO: Deduplicate `validateWorkflow` and `throwDuplicateEntryError` with TagHelpers? +/** + * Build a `where` clause for a TypeORM entity search, + * checking for member access if the user is not an owner. + */ +export function whereClause({ + user, + entityType, + entityId = '', +}: { + user: User; + entityType: 'workflow' | 'credentials'; + entityId?: string; +}): WhereClause { + const where: WhereClause = entityId ? { [entityType]: { id: entityId } } : {}; -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function validateWorkflow(newWorkflow: WorkflowEntity) { - const errors = await validate(newWorkflow); - - if (errors.length) { - const validationErrorMessage = Object.values(errors[0].constraints!)[0]; - throw new ResponseHelper.ResponseError(validationErrorMessage, undefined, 400); - } -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function throwDuplicateEntryError(error: Error) { - const errorMessage = error.message.toLowerCase(); - if (errorMessage.includes('unique') || errorMessage.includes('duplicate')) { - throw new ResponseHelper.ResponseError( - 'There is already a workflow with this name', - undefined, - 400, - ); + // TODO: Decide if owner access should be restricted + if (user.globalRole.name !== 'owner') { + where.user = { id: user.id }; } - throw new ResponseHelper.ResponseError(errorMessage, undefined, 400); + return where; } -export type NameRequest = Express.Request & { - query: { - name?: string; - }; -}; +/** + * Get the IDs of the workflows that have been shared with the user. + */ +export async function getSharedWorkflowIds(user: User): Promise { + const sharedWorkflows = await Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: whereClause({ + user, + entityType: 'workflow', + }), + }); + + return sharedWorkflows.map(({ workflow }) => workflow.id); +} diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index ce27cfd2c9..6d4abbaaaf 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -57,6 +57,7 @@ import { } from '.'; import * as Queue from './Queue'; import { InternalHooksManager } from './InternalHooksManager'; +import { checkPermissionsForExecution } from './UserManagement/UserManagementHelper'; export class WorkflowRunner { activeExecutions: ActiveExecutions.ActiveExecutions; @@ -177,6 +178,7 @@ export class WorkflowRunner { executionId!, data.workflowData, executionData, + data.userId, ); }) .catch((error) => { @@ -246,6 +248,7 @@ export class WorkflowRunner { staticData: data.workflowData.staticData, }); const additionalData = await WorkflowExecuteAdditionalData.getBase( + data.userId, undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, ); @@ -265,6 +268,9 @@ export class WorkflowRunner { `Execution for workflow ${data.workflowData.name} was assigned id ${executionId}`, { executionId }, ); + + await checkPermissionsForExecution(workflow, data.userId); + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain( data, executionId, diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 22e9c98bf2..8c2d457c4f 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -52,6 +52,7 @@ import { getLogger } from './Logger'; import * as config from '../config'; import { InternalHooksManager } from './InternalHooksManager'; +import { checkPermissionsForExecution } from './UserManagement/UserManagementHelper'; export class WorkflowRunnerProcess { data: IWorkflowExecutionDataProcessWithExecution | undefined; @@ -88,6 +89,7 @@ export class WorkflowRunnerProcess { LoggerProxy.init(logger); this.data = inputData; + const { userId } = inputData; logger.verbose('Initializing n8n sub-process', { pid: process.pid, @@ -235,7 +237,9 @@ export class WorkflowRunnerProcess { staticData: this.data.workflowData.staticData, settings: this.data.workflowData.settings, }); + await checkPermissionsForExecution(this.workflow, userId); const additionalData = await WorkflowExecuteAdditionalData.getBase( + userId, undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, ); @@ -273,8 +277,15 @@ export class WorkflowRunnerProcess { additionalData: IWorkflowExecuteAdditionalData, inputData?: INodeExecutionData[] | undefined, ): Promise | IRun> => { - const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData(workflowInfo); - const runData = await WorkflowExecuteAdditionalData.getRunData(workflowData, inputData); + const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData( + workflowInfo, + userId, + ); + const runData = await WorkflowExecuteAdditionalData.getRunData( + workflowData, + additionalData.userId, + inputData, + ); await sendToParentProcess('startExecution', { runData }); const executionId: string = await new Promise((resolve) => { this.executionIdCallback = (executionId: string) => { @@ -300,6 +311,7 @@ export class WorkflowRunnerProcess { executionId, workflowData, result, + additionalData.userId, ); await sendToParentProcess('finishExecution', { executionId, result }); delete this.childExecutions[executionId]; diff --git a/packages/cli/src/api/credentials.api.ts b/packages/cli/src/api/credentials.api.ts new file mode 100644 index 0000000000..e1e6727119 --- /dev/null +++ b/packages/cli/src/api/credentials.api.ts @@ -0,0 +1,419 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ +import express = require('express'); +import { In } from 'typeorm'; +import { UserSettings, Credentials } from 'n8n-core'; +import { INodeCredentialTestResult, LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '../Logger'; + +import { + CredentialsHelper, + Db, + GenericHelpers, + ICredentialsDb, + ICredentialsResponse, + whereClause, + ResponseHelper, +} from '..'; + +import { RESPONSE_ERROR_MESSAGES } from '../constants'; +import { CredentialsEntity } from '../databases/entities/CredentialsEntity'; +import { SharedCredentials } from '../databases/entities/SharedCredentials'; +import { validateEntity } from '../GenericHelpers'; +import type { CredentialRequest } from '../requests'; +import config = require('../../config'); +import { externalHooks } from '../Server'; + +export const credentialsController = express.Router(); + +/** + * Initialize Logger if needed + */ +credentialsController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +/** + * GET /credentials + */ +credentialsController.get( + '/', + ResponseHelper.send(async (req: CredentialRequest.GetAll): Promise => { + let credentials: ICredentialsDb[] = []; + + const filter = req.query.filter ? (JSON.parse(req.query.filter) as Record) : {}; + + try { + if (req.user.globalRole.name === 'owner') { + credentials = await Db.collections.Credentials!.find({ + select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], + where: filter, + }); + } else { + const shared = await Db.collections.SharedCredentials!.find({ + where: whereClause({ + user: req.user, + entityType: 'credentials', + }), + }); + + if (!shared.length) return []; + + credentials = await Db.collections.Credentials!.find({ + select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], + where: { + id: In(shared.map(({ credentialId }) => credentialId)), + ...filter, + }, + }); + } + } catch (error) { + LoggerProxy.error('Request to list credentials failed', error); + throw error; + } + + return credentials.map((credential) => { + // eslint-disable-next-line no-param-reassign + credential.id = credential.id.toString(); + return credential as ICredentialsResponse; + }); + }), +); + +/** + * GET /credentials/new + * + * Generate a unique credential name. + */ +credentialsController.get( + '/new', + ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => { + const { name: newName } = req.query; + + return GenericHelpers.generateUniqueName( + newName ?? config.get('credentials.defaultName'), + 'credentials', + ); + }), +); + +/** + * POST /credentials/test + * + * Test if a credential is valid. + */ +credentialsController.post( + '/test', + ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { + const { credentials, nodeToTestWith } = req.body; + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + return { + status: 'Error', + message: RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + }; + } + + const helper = new CredentialsHelper(encryptionKey); + + return helper.testCredentials(req.user, credentials.type, credentials, nodeToTestWith); + }), +); + +/** + * POST /credentials + */ +credentialsController.post( + '/', + ResponseHelper.send(async (req: CredentialRequest.Create) => { + delete req.body.id; // delete if sent + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, req.body); + + await validateEntity(newCredential); + + // Add the added date for node access permissions + for (const nodeAccess of newCredential.nodesAccess) { + nodeAccess.date = new Date(); + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + // Encrypt the data + const coreCredential = new Credentials( + { id: null, name: newCredential.name }, + newCredential.type, + newCredential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(newCredential.data, encryptionKey); + + const encryptedData = coreCredential.getDataToSave() as ICredentialsDb; + + Object.assign(newCredential, encryptedData); + + await externalHooks.run('credentials.create', [encryptedData]); + + const role = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + const { id, ...rest } = await Db.transaction(async (transactionManager) => { + const savedCredential = await transactionManager.save(newCredential); + + savedCredential.data = newCredential.data; + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + role, + user: req.user, + credentials: savedCredential, + }); + + await transactionManager.save(newSharedCredential); + + return savedCredential; + }); + LoggerProxy.verbose('New credential created', { + credentialId: newCredential.id, + ownerId: req.user.id, + }); + return { id: id.toString(), ...rest }; + }), +); + +/** + * DELETE /credentials/:id + */ +credentialsController.delete( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Delete) => { + const { id: credentialId } = req.params; + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', { + credentialId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be deleted.`, + undefined, + 404, + ); + } + + await externalHooks.run('credentials.delete', [credentialId]); + + await Db.collections.Credentials!.remove(shared.credentials); + + return true; + }), +); + +/** + * PATCH /credentials/:id + */ +credentialsController.patch( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Update): Promise => { + const { id: credentialId } = req.params; + + const updateData = new CredentialsEntity(); + Object.assign(updateData, req.body); + + await validateEntity(updateData); + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + LoggerProxy.info('Attempt to update credential blocked due to lack of permissions', { + credentialId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + const { credentials: credential } = shared; + + // Add the date for newly added node access permissions + for (const nodeAccess of updateData.nodesAccess) { + if (!nodeAccess.date) { + nodeAccess.date = new Date(); + } + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + const coreCredential = new Credentials( + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, + ); + + const decryptedData = coreCredential.getData(encryptionKey); + + // Do not overwrite the oauth data else data like the access or refresh token would get lost + // everytime anybody changes anything on the credentials even if it is just the name. + if (decryptedData.oauthTokenData) { + // @ts-ignore + updateData.data.oauthTokenData = decryptedData.oauthTokenData; + } + + // Encrypt the data + const credentials = new Credentials( + { id: credentialId, name: updateData.name }, + updateData.type, + updateData.nodesAccess, + ); + + // @ts-ignore + credentials.setData(updateData.data, encryptionKey); + + const newCredentialData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialData.updatedAt = new Date(); + + await externalHooks.run('credentials.update', [newCredentialData]); + + // Update the credentials in DB + 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); + + if (responseData === undefined) { + throw new ResponseHelper.ResponseError( + `Credential ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + // Remove the encrypted data as it is not needed in the frontend + const { id, data, ...rest } = responseData; + + LoggerProxy.verbose('Credential updated', { credentialId }); + + return { + id: id.toString(), + ...rest, + }; + }), +); + +/** + * GET /credentials/:id + */ +credentialsController.get( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Get) => { + const { id: credentialId } = req.params; + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + throw new ResponseHelper.ResponseError( + `Credentials with ID "${credentialId}" could not be found.`, + undefined, + 404, + ); + } + + const { credentials: credential } = shared; + + if (req.query.includeData !== 'true') { + const { data, id, ...rest } = credential; + + return { + id: id.toString(), + ...rest, + }; + } + + const { data, id, ...rest } = credential; + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + const coreCredential = new Credentials( + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, + ); + + return { + id: id.toString(), + data: coreCredential.getData(encryptionKey), + ...rest, + }; + }), +); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 0000000000..3cdfbb00c7 --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export const RESPONSE_ERROR_MESSAGES = { + NO_CREDENTIAL: 'Credential not found', + NO_ENCRYPTION_KEY: 'Encryption key missing to decrypt credentials', +}; + +export const AUTH_COOKIE_NAME = 'n8n-auth'; diff --git a/packages/cli/src/databases/entities/CredentialsEntity.ts b/packages/cli/src/databases/entities/CredentialsEntity.ts index 9ea39eff25..d67361114d 100644 --- a/packages/cli/src/databases/entities/CredentialsEntity.ts +++ b/packages/cli/src/databases/entities/CredentialsEntity.ts @@ -8,12 +8,15 @@ import { CreateDateColumn, Entity, Index, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { IsArray, IsObject, IsString, Length } from 'class-validator'; import config = require('../../../config'); import { DatabaseType, ICredentialsDb } from '../..'; +import { SharedCredentials } from './SharedCredentials'; function resolveDataType(dataType: string) { const dbType = config.get('database.type') as DatabaseType; @@ -51,21 +54,29 @@ export class CredentialsEntity implements ICredentialsDb { @PrimaryGeneratedColumn() id: number; - @Column({ - length: 128, + @Column({ length: 128 }) + @IsString({ message: 'Credential `name` must be of type string.' }) + @Length(3, 128, { + message: 'Credential name must be $constraint1 to $constraint2 characters long.', }) name: string; @Column('text') + @IsObject() data: string; @Index() + @IsString({ message: 'Credential `type` must be of type string.' }) @Column({ length: 128, }) type: string; + @OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.credentials) + shared: SharedCredentials[]; + @Column(resolveDataType('json')) + @IsArray() nodesAccess: ICredentialNodeAccess[]; @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) diff --git a/packages/cli/src/databases/entities/Role.ts b/packages/cli/src/databases/entities/Role.ts new file mode 100644 index 0000000000..ddc8aca31b --- /dev/null +++ b/packages/cli/src/databases/entities/Role.ts @@ -0,0 +1,77 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional, IsString, Length } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { User } from './User'; +import { SharedWorkflow } from './SharedWorkflow'; +import { SharedCredentials } from './SharedCredentials'; + +type RoleScopes = 'global' | 'workflow' | 'credential'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +@Unique(['scope', 'name']) +export class Role { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 32 }) + @IsString({ message: 'Role name must be of type string.' }) + @Length(1, 32, { message: 'Role name must be 1 to 32 characters long.' }) + name: string; + + @Column() + scope: RoleScopes; + + @OneToMany(() => User, (user) => user.globalRole) + globalForUsers: User[]; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.role) + sharedWorkflows: SharedWorkflow[]; + + @OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.role) + sharedCredentials: SharedCredentials[]; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/Settings.ts b/packages/cli/src/databases/entities/Settings.ts new file mode 100644 index 0000000000..3ae0bdc91f --- /dev/null +++ b/packages/cli/src/databases/entities/Settings.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable import/no-cycle */ + +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import { ISettingsDb } from '../..'; + +@Entity() +export class Settings implements ISettingsDb { + @PrimaryColumn() + key: string; + + @Column() + value: string; + + @Column() + loadOnStartup: boolean; +} diff --git a/packages/cli/src/databases/entities/SharedCredentials.ts b/packages/cli/src/databases/entities/SharedCredentials.ts new file mode 100644 index 0000000000..43bbc4e924 --- /dev/null +++ b/packages/cli/src/databases/entities/SharedCredentials.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + CreateDateColumn, + Entity, + ManyToOne, + RelationId, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { CredentialsEntity } from './CredentialsEntity'; +import { User } from './User'; +import { Role } from './Role'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class SharedCredentials { + @ManyToOne(() => Role, (role) => role.sharedCredentials, { nullable: false }) + role: Role; + + @ManyToOne(() => User, (user) => user.sharedCredentials, { primary: true }) + user: User; + + @RelationId((sharedCredential: SharedCredentials) => sharedCredential.user) + userId: string; + + @ManyToOne(() => CredentialsEntity, (credentials) => credentials.shared, { + primary: true, + onDelete: 'CASCADE', + }) + credentials: CredentialsEntity; + + @RelationId((sharedCredential: SharedCredentials) => sharedCredential.credentials) + credentialId: number; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/SharedWorkflow.ts b/packages/cli/src/databases/entities/SharedWorkflow.ts new file mode 100644 index 0000000000..5e20477e36 --- /dev/null +++ b/packages/cli/src/databases/entities/SharedWorkflow.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + CreateDateColumn, + Entity, + ManyToOne, + RelationId, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { WorkflowEntity } from './WorkflowEntity'; +import { User } from './User'; +import { Role } from './Role'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class SharedWorkflow { + @ManyToOne(() => Role, (role) => role.sharedWorkflows, { nullable: false }) + role: Role; + + @ManyToOne(() => User, (user) => user.sharedWorkflows, { primary: true }) + user: User; + + @RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.user) + userId: string; + + @ManyToOne(() => WorkflowEntity, (workflow) => workflow.shared, { + primary: true, + onDelete: 'CASCADE', + }) + workflow: WorkflowEntity; + + @RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.workflow) + workflowId: number; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/TagEntity.ts b/packages/cli/src/databases/entities/TagEntity.ts index a845313ea7..0af6512399 100644 --- a/packages/cli/src/databases/entities/TagEntity.ts +++ b/packages/cli/src/databases/entities/TagEntity.ts @@ -5,9 +5,10 @@ import { Column, CreateDateColumn, Entity, + Generated, Index, ManyToMany, - PrimaryGeneratedColumn, + PrimaryColumn, UpdateDateColumn, } from 'typeorm'; import { IsDate, IsOptional, IsString, Length } from 'class-validator'; @@ -15,6 +16,7 @@ import { IsDate, IsOptional, IsString, Length } from 'class-validator'; import config = require('../../../config'); import { DatabaseType } from '../../index'; import { ITagDb } from '../../Interfaces'; +import { idStringifier } from '../utils/transformers'; import { WorkflowEntity } from './WorkflowEntity'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -33,13 +35,16 @@ function getTimestampSyntax() { @Entity() export class TagEntity implements ITagDb { - @PrimaryGeneratedColumn() + @Generated() + @PrimaryColumn({ + transformer: idStringifier, + }) id: number; @Column({ length: 24 }) @Index({ unique: true }) @IsString({ message: 'Tag name must be of type string.' }) - @Length(1, 24, { message: 'Tag name must be 1 to 24 characters long.' }) + @Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' }) name: string; @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts new file mode 100644 index 0000000000..b345b26534 --- /dev/null +++ b/packages/cli/src/databases/entities/User.ts @@ -0,0 +1,137 @@ +/* eslint-disable import/no-cycle */ +import { + AfterLoad, + AfterUpdate, + BeforeUpdate, + Column, + ColumnOptions, + CreateDateColumn, + Entity, + Index, + OneToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsEmail, IsString, Length } from 'class-validator'; +import config = require('../../../config'); +import { DatabaseType, IPersonalizationSurveyAnswers } from '../..'; +import { Role } from './Role'; +import { SharedWorkflow } from './SharedWorkflow'; +import { SharedCredentials } from './SharedCredentials'; +import { NoXss } from '../utils/customValidators'; +import { answersFormatter } from '../utils/transformers'; + +export const MIN_PASSWORD_LENGTH = 8; + +export const MAX_PASSWORD_LENGTH = 64; + +function resolveDataType(dataType: string) { + const dbType = config.get('database.type') as DatabaseType; + + const typeMap: { [key in DatabaseType]: { [key: string]: string } } = { + sqlite: { + json: 'simple-json', + }, + postgresdb: { + datetime: 'timestamptz', + }, + mysqldb: {}, + mariadb: {}, + }; + + return typeMap[dbType][dataType] ?? dataType; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 254 }) + @Index({ unique: true }) + @IsEmail() + email: string; + + @Column({ length: 32, nullable: true }) + @NoXss() + @IsString({ message: 'First name must be of type string.' }) + @Length(1, 32, { message: 'First name must be $constraint1 to $constraint2 characters long.' }) + firstName: string; + + @Column({ length: 32, nullable: true }) + @NoXss() + @IsString({ message: 'Last name must be of type string.' }) + @Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' }) + lastName: string; + + @Column({ nullable: true }) + @IsString({ message: 'Password must be of type string.' }) + password?: string; + + @Column({ type: String, nullable: true }) + resetPasswordToken?: string | null; + + // Expiration timestamp saved in seconds + @Column({ type: Number, nullable: true }) + resetPasswordTokenExpiration?: number | null; + + @Column({ + type: resolveDataType('json') as ColumnOptions['type'], + nullable: true, + transformer: answersFormatter, + }) + personalizationAnswers: IPersonalizationSurveyAnswers | null; + + @ManyToOne(() => Role, (role) => role.globalForUsers, { + cascade: true, + nullable: false, + }) + globalRole: Role; + + @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.user) + sharedWorkflows: SharedWorkflow[]; + + @OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.user) + sharedCredentials: SharedCredentials[]; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } + + /** + * Whether the user is pending setup completion. + */ + isPending: boolean; + + @AfterLoad() + @AfterUpdate() + computeIsPending(): void { + this.isPending = this.password == null; + } +} diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts index b07c4e6a06..097ae84229 100644 --- a/packages/cli/src/databases/entities/WorkflowEntity.ts +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -13,6 +13,7 @@ import { Index, JoinTable, ManyToMany, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -20,6 +21,7 @@ import { import config = require('../../../config'); import { DatabaseType, IWorkflowDb } from '../..'; import { TagEntity } from './TagEntity'; +import { SharedWorkflow } from './SharedWorkflow'; function resolveDataType(dataType: string) { const dbType = config.get('database.type') as DatabaseType; @@ -57,8 +59,11 @@ export class WorkflowEntity implements IWorkflowDb { @PrimaryGeneratedColumn() id: number; + // TODO: Add XSS check @Index({ unique: true }) - @Length(1, 128, { message: 'Workflow name must be 1 to 128 characters long.' }) + @Length(1, 128, { + message: 'Workflow name must be $constraint1 to $constraint2 characters long.', + }) @Column({ length: 128 }) name: string; @@ -107,6 +112,9 @@ export class WorkflowEntity implements IWorkflowDb { }) tags: TagEntity[]; + @OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.workflow) + shared: SharedWorkflow[]; + @BeforeUpdate() setUpdateDate() { this.updatedAt = new Date(); diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 4e980573e2..1492f8615e 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -5,6 +5,11 @@ import { ExecutionEntity } from './ExecutionEntity'; import { WorkflowEntity } from './WorkflowEntity'; import { WebhookEntity } from './WebhookEntity'; import { TagEntity } from './TagEntity'; +import { User } from './User'; +import { Role } from './Role'; +import { Settings } from './Settings'; +import { SharedWorkflow } from './SharedWorkflow'; +import { SharedCredentials } from './SharedCredentials'; export const entities = { CredentialsEntity, @@ -12,4 +17,9 @@ export const entities = { WorkflowEntity, WebhookEntity, TagEntity, + User, + Role, + Settings, + SharedWorkflow, + SharedCredentials, }; diff --git a/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts b/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts index 0ed0348800..ee2aa560e1 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1626183952959-AddWaitColumn.ts @@ -6,7 +6,6 @@ export class AddWaitColumnId1626183952959 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); - console.log('\n\nINFO: Started with migration for wait functionality.\n Depending on the number of saved executions, that may take a little bit.\n\n'); await queryRunner.query('ALTER TABLE `' + tablePrefix + 'execution_entity` ADD `waitTill` DATETIME NULL'); await queryRunner.query('CREATE INDEX `IDX_' + tablePrefix + 'ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity` (`waitTill`)'); diff --git a/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts index 0061052c2a..3357f86853 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts @@ -9,8 +9,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac name = 'UpdateWorkflowCredentials1630451444017'; public async up(queryRunner: QueryRunner): Promise { - console.log('Start migration', this.name); - console.time(this.name); const tablePrefix = config.get('database.tablePrefix'); const helpers = new MigrationHelpers(queryRunner); @@ -145,7 +143,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac queryRunner.query(updateQuery, updateParams); } }); - console.timeEnd(this.name); } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts b/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts index 62ced03077..7f7ce7285f 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1644424784709-AddExecutionEntityIndexes.ts @@ -2,31 +2,70 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; export class AddExecutionEntityIndexes1644424784709 implements MigrationInterface { - name = 'AddExecutionEntityIndexes1644424784709' + name = 'AddExecutionEntityIndexes1644424784709'; - public async up(queryRunner: QueryRunner): Promise { - console.log('\n\nINFO: Started migration for execution entity indexes.\n Depending on the number of saved executions, it may take a while.\n\n'); + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); - const tablePrefix = config.get('database.tablePrefix'); - - await queryRunner.query('DROP INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('CREATE INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + tablePrefix + 'execution_entity` (`workflowId`, `waitTill`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + tablePrefix + 'execution_entity` (`workflowId`, `finished`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_1688846335d274033e15c846a4` ON `' + tablePrefix + 'execution_entity` (`finished`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + tablePrefix + 'execution_entity` (`waitTill`, `id`)'); - await queryRunner.query('CREATE INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity` (`workflowId`, `id`)'); - } - - public async down(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query('DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_1688846335d274033e15c846a4` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('DROP INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + tablePrefix + 'execution_entity`'); - await queryRunner.query('CREATE INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity` (`waitTill`)'); - await queryRunner.query('CREATE INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity` (`workflowId`)'); - } + await queryRunner.query( + 'DROP INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`, `waitTill`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`, `finished`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_1688846335d274033e15c846a4` ON `' + + tablePrefix + + 'execution_entity` (`finished`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + + tablePrefix + + 'execution_entity` (`waitTill`, `id`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`, `id`)', + ); + } + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + 'DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_b94b45ce2c73ce46c54f20b5f9` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_1688846335d274033e15c846a4` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_78d62b89dc1433192b86dce18a` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_06da892aaf92a48e7d3e400003` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_ca4a71b47f28ac6ea88293a8e2` ON `' + + tablePrefix + + 'execution_entity` (`waitTill`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + + tablePrefix + + 'execution_entity` (`workflowId`)', + ); + } } diff --git a/packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts b/packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts new file mode 100644 index 0000000000..f0fd8a047c --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1646992772331-CreateUserManagement.ts @@ -0,0 +1,171 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import config = require('../../../../config'); +import { loadSurveyFromDisk } from '../../utils/migrationHelpers'; + +export class CreateUserManagement1646992772331 implements MigrationInterface { + name = 'CreateUserManagement1646992772331'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}role ( + \`id\` int NOT NULL AUTO_INCREMENT, + \`name\` varchar(32) NOT NULL, + \`scope\` varchar(255) NOT NULL, + \`createdAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + UNIQUE KEY \`UQ_${tablePrefix}5b49d0f504f7ef31045a1fb2eb8\` (\`scope\`,\`name\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}user ( + \`id\` VARCHAR(36) NOT NULL, + \`email\` VARCHAR(255) NULL DEFAULT NULL, + \`firstName\` VARCHAR(32) NULL DEFAULT NULL, + \`lastName\` VARCHAR(32) NULL DEFAULT NULL, + \`password\` VARCHAR(255) NULL DEFAULT NULL, + \`resetPasswordToken\` VARCHAR(255) NULL DEFAULT NULL, + \`resetPasswordTokenExpiration\` INT NULL DEFAULT NULL, + \`personalizationAnswers\` TEXT NULL DEFAULT NULL, + \`createdAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`globalRoleId\` INT NOT NULL, + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`IDX_${tablePrefix}e12875dfb3b1d92d7d7c5377e2\` (\`email\` ASC), + INDEX \`FK_${tablePrefix}f0609be844f9200ff4365b1bb3d\` (\`globalRoleId\` ASC) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}user\` ADD CONSTRAINT \`FK_${tablePrefix}f0609be844f9200ff4365b1bb3d\` FOREIGN KEY (\`globalRoleId\`) REFERENCES \`${tablePrefix}role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_workflow ( + \`createdAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`roleId\` INT NOT NULL, + \`userId\` VARCHAR(36) NOT NULL, + \`workflowId\` INT NOT NULL, + INDEX \`FK_${tablePrefix}3540da03964527aa24ae014b780x\` (\`roleId\` ASC), + INDEX \`FK_${tablePrefix}82b2fd9ec4e3e24209af8160282x\` (\`userId\` ASC), + INDEX \`FK_${tablePrefix}b83f8d2530884b66a9c848c8b88x\` (\`workflowId\` ASC), + PRIMARY KEY (\`userId\`, \`workflowId\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_workflow\` ADD CONSTRAINT \`FK_${tablePrefix}3540da03964527aa24ae014b780\` FOREIGN KEY (\`roleId\`) REFERENCES \`${tablePrefix}role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_workflow\` ADD CONSTRAINT \`FK_${tablePrefix}82b2fd9ec4e3e24209af8160282\` FOREIGN KEY (\`userId\`) REFERENCES \`${tablePrefix}user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_workflow\` ADD CONSTRAINT \`FK_${tablePrefix}b83f8d2530884b66a9c848c8b88\` FOREIGN KEY (\`workflowId\`) REFERENCES \`${tablePrefix}workflow_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_credentials ( + \`createdAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`roleId\` INT NOT NULL, + \`userId\` VARCHAR(36) NOT NULL, + \`credentialsId\` INT NOT NULL, + INDEX \`FK_${tablePrefix}c68e056637562000b68f480815a\` (\`roleId\` ASC), + INDEX \`FK_${tablePrefix}484f0327e778648dd04f1d70493\` (\`userId\` ASC), + INDEX \`FK_${tablePrefix}68661def1d4bcf2451ac8dbd949\` (\`credentialsId\` ASC), + PRIMARY KEY (\`userId\`, \`credentialsId\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_credentials\` ADD CONSTRAINT \`FK_${tablePrefix}484f0327e778648dd04f1d70493\` FOREIGN KEY (\`userId\`) REFERENCES \`${tablePrefix}user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_credentials\` ADD CONSTRAINT \`FK_${tablePrefix}68661def1d4bcf2451ac8dbd949\` FOREIGN KEY (\`credentialsId\`) REFERENCES \`${tablePrefix}credentials_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}shared_credentials\` ADD CONSTRAINT \`FK_${tablePrefix}c68e056637562000b68f480815a\` FOREIGN KEY (\`roleId\`) REFERENCES \`${tablePrefix}role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}settings ( + \`key\` VARCHAR(255) NOT NULL, + \`value\` TEXT NOT NULL, + \`loadOnStartup\` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (\`key\`) + ) ENGINE=InnoDB;`, + ); + + await queryRunner.query( + `ALTER TABLE ${tablePrefix}workflow_entity DROP INDEX IDX_${tablePrefix}943d8f922be094eb507cb9a7f9`, + ); + + // Insert initial roles + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("owner", "global");`, + ); + + const instanceOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("member", "global");`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("owner", "workflow");`, + ); + + const workflowOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ("owner", "credential");`, + ); + + const credentialOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId'); + + const survey = loadSurveyFromDisk(); + + const ownerUserId = uuid(); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}user (id, globalRoleId, personalizationAnswers) values (?, ?, ?)`, + [ownerUserId, instanceOwnerRole[0].insertId, survey], + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_workflow (createdAt, updatedAt, roleId, userId, workflowId) select + NOW(), NOW(), '${workflowOwnerRole[0].insertId}', '${ownerUserId}', id FROM ${tablePrefix}workflow_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_credentials (createdAt, updatedAt, roleId, userId, credentialsId) SELECT NOW(), NOW(), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', id FROM ${tablePrefix} credentials_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}settings (\`key\`, value, loadOnStartup) VALUES ("userManagement.isInstanceOwnerSetUp", "false", 1), ("userManagement.skipInstanceOwnerSetup", "false", 1)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE ${tablePrefix}workflow_entity ADD UNIQUE INDEX \`IDX_${tablePrefix}943d8f922be094eb507cb9a7f9\` (\`name\`)`, + ); + + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_credentials"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_workflow"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}user"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}role"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}settings"`); + } +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index 3e16cf69a4..16ee37da46 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -11,6 +11,7 @@ import { CertifyCorrectCollation1623936588000 } from './1623936588000-CertifyCor import { AddWaitColumnId1626183952959 } from './1626183952959-AddWaitColumn'; import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes'; +import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -26,4 +27,5 @@ export const mysqlMigrations = [ AddWaitColumnId1626183952959, UpdateWorkflowCredentials1630451444017, AddExecutionEntityIndexes1644424784709, + CreateUserManagement1646992772331, ]; diff --git a/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts b/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts index ab6adb2995..620dcffb79 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts @@ -1,59 +1,70 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; -import config = require("../../../../config"); +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); export class UniqueWorkflowNames1620824779533 implements MigrationInterface { - name = 'UniqueWorkflowNames1620824779533'; + name = 'UniqueWorkflowNames1620824779533'; - async up(queryRunner: QueryRunner): Promise { - let tablePrefix = config.get('database.tablePrefix'); - const tablePrefixPure = tablePrefix; - const schema = config.get('database.postgresdb.schema'); - if (schema) { - tablePrefix = schema + '.' + tablePrefix; - } + async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } - const workflowNames = await queryRunner.query(` + const workflowNames = await queryRunner.query(` SELECT name FROM ${tablePrefix}workflow_entity `); - for (const { name } of workflowNames) { - const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters(` + for (const { name } of workflowNames) { + const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters( + ` SELECT id, name FROM ${tablePrefix}workflow_entity WHERE name = :name ORDER BY "createdAt" ASC - `, { name }, {}); + `, + { name }, + {}, + ); - const duplicates = await queryRunner.query(duplicatesQuery, parameters); + const duplicates = await queryRunner.query(duplicatesQuery, parameters); - if (duplicates.length > 1) { - await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => { + if (duplicates.length > 1) { + await Promise.all( + duplicates.map(({ id, name }: { id: number; name: string }, index: number) => { if (index === 0) return Promise.resolve(); - const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(` + const [updateQuery, updateParams] = + queryRunner.connection.driver.escapeQueryWithParameters( + ` UPDATE ${tablePrefix}workflow_entity SET name = :name WHERE id = '${id}' - `, { name: `${name} ${index + 1}`}, {}); + `, + { name: `${name} ${index + 1}` }, + {}, + ); return queryRunner.query(updateQuery, updateParams); - })); - } + }), + ); } - - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name") `); } - async down(queryRunner: QueryRunner): Promise { - let tablePrefix = config.get('database.tablePrefix'); - const tablePrefixPure = tablePrefix; - const schema = config.get('database.postgresdb.schema'); - if (schema) { - tablePrefix = schema + '.' + tablePrefix; - } - - await queryRunner.query(`DROP INDEX "public"."IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; } + await queryRunner.query(`DROP INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); + } } diff --git a/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts b/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts index 61f545fd76..b37834fa75 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1626176912946-AddwaitTill.ts @@ -11,7 +11,6 @@ export class AddwaitTill1626176912946 implements MigrationInterface { if (schema) { tablePrefix = schema + '.' + tablePrefix; } - console.log('\n\nINFO: Started with migration for wait functionality.\n Depending on the number of saved executions, that may take a little bit.\n\n'); await queryRunner.query(`ALTER TABLE ${tablePrefix}execution_entity ADD "waitTill" TIMESTAMP`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2 ON ${tablePrefix}execution_entity ("waitTill")`); diff --git a/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts index ad3e44f0e6..2061241ce0 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts @@ -9,8 +9,6 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac name = 'UpdateWorkflowCredentials1630419189837'; public async up(queryRunner: QueryRunner): Promise { - console.log('Start migration', this.name); - console.time(this.name); let tablePrefix = config.get('database.tablePrefix'); const schema = config.get('database.postgresdb.schema'); if (schema) { @@ -151,7 +149,6 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac queryRunner.query(updateQuery, updateParams); } }); - console.timeEnd(this.name); } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts b/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts index b0b6814b90..933eb2131e 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1644422880309-AddExecutionEntityIndexes.ts @@ -2,46 +2,75 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; export class AddExecutionEntityIndexes1644422880309 implements MigrationInterface { - name = 'AddExecutionEntityIndexes1644422880309' + name = 'AddExecutionEntityIndexes1644422880309'; - public async up(queryRunner: QueryRunner): Promise { - console.log('\n\nINFO: Started migration for execution entity indexes.\n Depending on the number of saved executions, it may take a while.\n\n'); - - let tablePrefix = config.get('database.tablePrefix'); + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); const tablePrefixPure = tablePrefix; const schema = config.get('database.postgresdb.schema'); - if (schema) { + if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2"`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}85b981df7b444f905f8bf50747" ON ${tablePrefix}execution_entity ("waitTill", "id") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3" ON ${tablePrefix}execution_entity ("workflowId", "id") `); - } + await queryRunner.query( + `DROP INDEX "${schema}".IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d`, + ); + await queryRunner.query( + `DROP INDEX "${schema}".IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}85b981df7b444f905f8bf50747" ON ${tablePrefix}execution_entity ("waitTill", "id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3" ON ${tablePrefix}execution_entity ("workflowId", "id") `, + ); + } - public async down(queryRunner: QueryRunner): Promise { - let tablePrefix = config.get('database.tablePrefix'); + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); const tablePrefixPure = tablePrefix; const schema = config.get('database.postgresdb.schema'); - if (schema) { + if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}85b981df7b444f905f8bf50747"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}4f474ac92be81610439aaad61e"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}58154df94c686818c99fb754ce"`); - await queryRunner.query(`DROP INDEX IF EXISTS "${schema}"."IDX_${tablePrefixPure}33228da131bb1112247cf52a42"`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2" ON ${tablePrefix}execution_entity ("waitTill") `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d" ON ${tablePrefix}execution_entity ("workflowId") `); - } - + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}85b981df7b444f905f8bf50747"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}4f474ac92be81610439aaad61e"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}58154df94c686818c99fb754ce"`, + ); + await queryRunner.query( + `DROP INDEX "${schema}"."IDX_${tablePrefixPure}33228da131bb1112247cf52a42"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2" ON ${tablePrefix}execution_entity ("waitTill") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d" ON ${tablePrefix}execution_entity ("workflowId") `, + ); + } } diff --git a/packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts b/packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts new file mode 100644 index 0000000000..5550f4d464 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1646992772331-CreateUserManagement.ts @@ -0,0 +1,160 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import config = require('../../../../config'); +import { loadSurveyFromDisk } from '../../utils/migrationHelpers'; + +export class CreateUserManagement1646992772331 implements MigrationInterface { + name = 'CreateUserManagement1646992772331'; + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}role ( + "id" serial NOT NULL, + "name" VARCHAR(32) NOT NULL, + "scope" VARCHAR(255) NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_${tablePrefixPure}e853ce24e8200abe5721d2c6ac552b73" PRIMARY KEY ("id"), + CONSTRAINT "UQ_${tablePrefixPure}5b49d0f504f7ef31045a1fb2eb8" UNIQUE ("scope", "name") + );`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}user ( + "id" UUID NOT NULL DEFAULT uuid_in(overlay(overlay(md5(random()::text || ':' || clock_timestamp()::text) placing '4' from 13) placing to_hex(floor(random()*(11-8+1) + 8)::int)::text from 17)::cstring), + "email" VARCHAR(255), + "firstName" VARCHAR(32), + "lastName" VARCHAR(32), + "password" VARCHAR(255), + "resetPasswordToken" VARCHAR(255), + "resetPasswordTokenExpiration" int, + "personalizationAnswers" text, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "globalRoleId" int NOT NULL, + CONSTRAINT "PK_${tablePrefixPure}ea8f538c94b6e352418254ed6474a81f" PRIMARY KEY ("id"), + CONSTRAINT "UQ_${tablePrefixPure}e12875dfb3b1d92d7d7c5377e2" UNIQUE (email), + CONSTRAINT "FK_${tablePrefixPure}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES ${tablePrefix}role (id) + );`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_workflow ( + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "roleId" INT NOT NULL, + "userId" UUID NOT NULL, + "workflowId" INT NOT NULL, + CONSTRAINT "PK_${tablePrefixPure}cc5d5a71c7b2591f5154ffb0c785e85e" PRIMARY KEY ("userId", "workflowId"), + CONSTRAINT "FK_${tablePrefixPure}3540da03964527aa24ae014b780" FOREIGN KEY ("roleId") REFERENCES ${tablePrefix}role ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}82b2fd9ec4e3e24209af8160282" FOREIGN KEY ("userId") REFERENCES ${tablePrefix}user ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}b83f8d2530884b66a9c848c8b88" FOREIGN KEY ("workflowId") REFERENCES + ${tablePrefixPure}workflow_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION + );`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}65a0933c0f19d278881653bf81d35064" ON "shared_workflow" ("workflowId");`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}shared_credentials ( + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "roleId" INT NOT NULL, + "userId" UUID NOT NULL, + "credentialsId" INT NOT NULL, + CONSTRAINT "PK_${tablePrefixPure}10dd1527ffb639609be7aadd98f628c6" PRIMARY KEY ("userId", "credentialsId"), + CONSTRAINT "FK_${tablePrefixPure}c68e056637562000b68f480815a" FOREIGN KEY ("roleId") REFERENCES ${tablePrefix}role ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}484f0327e778648dd04f1d70493" FOREIGN KEY ("userId") REFERENCES ${tablePrefix}user ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_${tablePrefixPure}68661def1d4bcf2451ac8dbd949" FOREIGN KEY ("credentialsId") REFERENCES ${tablePrefix}credentials_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION + );`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefixPure}829d16efa0e265cb076d50eca8d21733" ON ${tablePrefix}shared_credentials ("credentialsId");`, + ); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}settings ( + "key" VARCHAR(255) NOT NULL, + "value" TEXT NOT NULL, + "loadOnStartup" boolean NOT NULL DEFAULT false, + CONSTRAINT "PK_${tablePrefixPure}dc0fe14e6d9943f268e7b119f69ab8bd" PRIMARY KEY ("key") + );`, + ); + + await queryRunner.query(`DROP INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); + + // Insert initial roles + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('owner', 'global');`, + ); + + const instanceOwnerRole = await queryRunner.query('SELECT lastval() as "insertId"'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('member', 'global');`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('owner', 'workflow');`, + ); + + const workflowOwnerRole = await queryRunner.query('SELECT lastval() as "insertId"'); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}role (name, scope) VALUES ('owner', 'credential');`, + ); + + const credentialOwnerRole = await queryRunner.query('SELECT lastval() as "insertId"'); + + const survey = loadSurveyFromDisk(); + + const ownerUserId = uuid(); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}user ("id", "globalRoleId", "personalizationAnswers") values ($1, $2, $3)`, + [ownerUserId, instanceOwnerRole[0].insertId, survey], + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_workflow ("createdAt", "updatedAt", "roleId", "userId", "workflowId") select + NOW(), NOW(), '${workflowOwnerRole[0].insertId}', '${ownerUserId}', "id" FROM ${tablePrefix}workflow_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}shared_credentials ("createdAt", "updatedAt", "roleId", "userId", "credentialsId") SELECT NOW(), NOW(), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', "id" FROM ${tablePrefix} credentials_entity`, + ); + + await queryRunner.query( + `INSERT INTO ${tablePrefix}settings ("key", "value", "loadOnStartup") VALUES ('userManagement.isInstanceOwnerSetUp', 'false', true), ('userManagement.skipInstanceOwnerSetup', 'false', true)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixPure = tablePrefix; + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name")`, + ); + + await queryRunner.query(`DROP TABLE ${tablePrefix}shared_credentials`); + await queryRunner.query(`DROP TABLE ${tablePrefix}shared_workflow`); + await queryRunner.query(`DROP TABLE ${tablePrefix}user`); + await queryRunner.query(`DROP TABLE ${tablePrefix}role`); + await queryRunner.query(`DROP TABLE ${tablePrefix}settings`); + } +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index c1aaac39fa..903b6a7e72 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -9,6 +9,7 @@ import { AddwaitTill1626176912946 } from './1626176912946-AddwaitTill'; import { UpdateWorkflowCredentials1630419189837 } from './1630419189837-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644422880309 } from './1644422880309-AddExecutionEntityIndexes'; import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit'; +import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -22,4 +23,5 @@ export const postgresMigrations = [ UpdateWorkflowCredentials1630419189837, AddExecutionEntityIndexes1644422880309, IncreaseTypeVarcharLimit1646834195327, + CreateUserManagement1646992772331, ]; diff --git a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts index 321560fc90..e0d192c65c 100644 --- a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts +++ b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts @@ -1,21 +1,37 @@ -import { - MigrationInterface, - QueryRunner, -} from 'typeorm'; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class InitialMigration1588102412422 implements MigrationInterface { name = 'InitialMigration1588102412422'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(128) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime NOT NULL, "workflowData" text NOT NULL, "workflowId" varchar)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, undefined); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(128) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime NOT NULL, "workflowData" text NOT NULL, "workflowId" varchar)`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, + undefined, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { @@ -27,5 +43,4 @@ export class InitialMigration1588102412422 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`, undefined); await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`, undefined); } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts index b011061e28..43d38bd50c 100644 --- a/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts +++ b/packages/cli/src/databases/sqlite/migrations/1592445003908-WebhookModel.ts @@ -1,17 +1,20 @@ -import { - MigrationInterface, - QueryRunner, -} from 'typeorm'; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class WebhookModel1592445003908 implements MigrationInterface { name = 'WebhookModel1592445003908'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts index 7c8104f06a..92fe62984e 100644 --- a/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts +++ b/packages/cli/src/databases/sqlite/migrations/1594825041918-CreateIndexStoppedAt.ts @@ -1,14 +1,20 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class CreateIndexStoppedAt1594825041918 implements MigrationInterface { name = 'CreateIndexStoppedAt1594825041918'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt") `); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt") `, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { @@ -16,5 +22,4 @@ export class CreateIndexStoppedAt1594825041918 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1"`); } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts b/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts index 29285c9192..a2aee48c64 100644 --- a/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts +++ b/packages/cli/src/databases/sqlite/migrations/1607431743769-MakeStoppedAtNullable.ts @@ -1,10 +1,13 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; - +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class MakeStoppedAtNullable1607431743769 implements MigrationInterface { + name = 'MakeStoppedAtNullable1607431743769'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); // SQLite does not allow us to simply "alter column" // We're hacking the way sqlite identifies tables @@ -12,12 +15,16 @@ export class MakeStoppedAtNullable1607431743769 implements MigrationInterface { // This is a very strict case when this can be done safely // As no collateral effects exist. await queryRunner.query(`PRAGMA writable_schema = 1; `, undefined); - await queryRunner.query(`UPDATE SQLITE_MASTER SET SQL = 'CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)' WHERE NAME = "${tablePrefix}execution_entity";`, undefined); + await queryRunner.query( + `UPDATE SQLITE_MASTER SET SQL = 'CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)' WHERE NAME = "${tablePrefix}execution_entity";`, + undefined, + ); await queryRunner.query(`PRAGMA writable_schema = 0;`, undefined); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { // This cannot be undone as the table might already have nullable values } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts b/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts index c4489ad6a6..adf62f76c9 100644 --- a/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts +++ b/packages/cli/src/databases/sqlite/migrations/1611071044839-AddWebhookId.ts @@ -1,26 +1,45 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class AddWebhookId1611071044839 implements MigrationInterface { name = 'AddWebhookId1611071044839'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE "temporary_webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, "webhookId" varchar, "pathLength" integer, PRIMARY KEY ("webhookPath", "method"))`); - await queryRunner.query(`INSERT INTO "temporary_webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "${tablePrefix}webhook_entity"`); + await queryRunner.query( + `CREATE TABLE "temporary_webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, "webhookId" varchar, "pathLength" integer, PRIMARY KEY ("webhookPath", "method"))`, + ); + await queryRunner.query( + `INSERT INTO "temporary_webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "${tablePrefix}webhook_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}webhook_entity"`); - await queryRunner.query(`ALTER TABLE "temporary_webhook_entity" RENAME TO "${tablePrefix}webhook_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}742496f199721a057051acf4c2" ON "${tablePrefix}webhook_entity" ("webhookId", "method", "pathLength") `); + await queryRunner.query( + `ALTER TABLE "temporary_webhook_entity" RENAME TO "${tablePrefix}webhook_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}742496f199721a057051acf4c2" ON "${tablePrefix}webhook_entity" ("webhookId", "method", "pathLength") `, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}742496f199721a057051acf4c2"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}webhook_entity" RENAME TO "temporary_webhook_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`); - await queryRunner.query(`INSERT INTO "${tablePrefix}webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "temporary_webhook_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}webhook_entity" RENAME TO "temporary_webhook_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}webhook_entity" ("workflowId" integer NOT NULL, "webhookPath" varchar NOT NULL, "method" varchar NOT NULL, "node" varchar NOT NULL, PRIMARY KEY ("webhookPath", "method"))`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}webhook_entity"("workflowId", "webhookPath", "method", "node") SELECT "workflowId", "webhookPath", "method", "node" FROM "temporary_webhook_entity"`, + ); await queryRunner.query(`DROP TABLE "temporary_webhook_entity"`); } } diff --git a/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts b/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts index 3f52eed827..547cc31127 100644 --- a/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts +++ b/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts @@ -1,38 +1,75 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class CreateTagEntity1617213344594 implements MigrationInterface { name = 'CreateTagEntity1617213344594'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); // create tags table + relationship with workflow entity - await queryRunner.query(`CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `); - await queryRunner.query(`CREATE TABLE "${tablePrefix}workflows_tags" ("workflowId" integer NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_54b2f0343d6a2078fa137443869" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_77505b341625b0b4768082e2171" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"))`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}54b2f0343d6a2078fa13744386" ON "${tablePrefix}workflows_tags" ("workflowId") `); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}77505b341625b0b4768082e217" ON "${tablePrefix}workflows_tags" ("tagId") `); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}workflows_tags" ("workflowId" integer NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_54b2f0343d6a2078fa137443869" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_77505b341625b0b4768082e2171" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}54b2f0343d6a2078fa13744386" ON "${tablePrefix}workflows_tags" ("workflowId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}77505b341625b0b4768082e217" ON "${tablePrefix}workflows_tags" ("tagId") `, + ); // set default dates for `createdAt` and `updatedAt` await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}credentials_entity"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}credentials_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_credentials_entity" RENAME TO "${tablePrefix}credentials_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_credentials_entity" RENAME TO "${tablePrefix}credentials_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, + ); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}tag_entity"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}tag_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}tag_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_tag_entity" RENAME TO "${tablePrefix}tag_entity"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}workflow_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_tag_entity" RENAME TO "${tablePrefix}tag_entity"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}workflow_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`, + ); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { @@ -40,22 +77,44 @@ export class CreateTagEntity1617213344594 implements MigrationInterface { // `createdAt` and `updatedAt` - await queryRunner.query(`ALTER TABLE "${tablePrefix}workflow_entity" RENAME TO "${tablePrefix}temporary_workflow_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}temporary_workflow_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}workflow_entity" RENAME TO "${tablePrefix}temporary_workflow_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}temporary_workflow_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_workflow_entity"`); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}tag_entity" RENAME TO "${tablePrefix}temporary_tag_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_tag_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}tag_entity" RENAME TO "${tablePrefix}temporary_tag_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_tag_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_tag_entity"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `, + ); await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}credentials_entity" RENAME TO "temporary_credentials_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`); - await queryRunner.query(`INSERT INTO "${tablePrefix}credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_credentials_entity"`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}credentials_entity" RENAME TO "temporary_credentials_entity"`, + ); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_credentials_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_credentials_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "credentials_entity" ("type") `); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "credentials_entity" ("type") `, + ); // tags @@ -65,5 +124,4 @@ export class CreateTagEntity1617213344594 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`); await queryRunner.query(`DROP TABLE "${tablePrefix}tag_entity"`); } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts b/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts index 732b080ed0..6b50d94012 100644 --- a/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts +++ b/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts @@ -1,47 +1,64 @@ -import {MigrationInterface, QueryRunner} from "typeorm"; -import config = require("../../../../config"); +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class UniqueWorkflowNames1620821879465 implements MigrationInterface { - name = 'UniqueWorkflowNames1620821879465'; + name = 'UniqueWorkflowNames1620821879465'; - async up(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); - const workflowNames = await queryRunner.query(` + const tablePrefix = config.get('database.tablePrefix'); + + const workflowNames = await queryRunner.query(` SELECT name FROM "${tablePrefix}workflow_entity" `); - for (const { name } of workflowNames) { - const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters(` + for (const { name } of workflowNames) { + const [duplicatesQuery, parameters] = queryRunner.connection.driver.escapeQueryWithParameters( + ` SELECT id, name FROM "${tablePrefix}workflow_entity" WHERE name = :name ORDER BY createdAt ASC - `, { name }, {}); + `, + { name }, + {}, + ); - const duplicates = await queryRunner.query(duplicatesQuery, parameters); + const duplicates = await queryRunner.query(duplicatesQuery, parameters); - if (duplicates.length > 1) { - await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => { + if (duplicates.length > 1) { + await Promise.all( + duplicates.map(({ id, name }: { id: number; name: string }, index: number) => { if (index === 0) return Promise.resolve(); - const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(` + const [updateQuery, updateParams] = + queryRunner.connection.driver.escapeQueryWithParameters( + ` UPDATE "${tablePrefix}workflow_entity" SET name = :name WHERE id = '${id}' - `, { name: `${name} ${index + 1}`}, {}); + `, + { name: `${name} ${index + 1}` }, + {}, + ); return queryRunner.query(updateQuery, updateParams); - })); - } + }), + ); } - - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `); } - async down(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); - } + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `, + ); + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); + } } diff --git a/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts b/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts index 950bef59f1..0c5c2b28c2 100644 --- a/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts +++ b/packages/cli/src/databases/sqlite/migrations/1621707690587-AddWaitColumn.ts @@ -1,33 +1,55 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class AddWaitColumn1621707690587 implements MigrationInterface { name = 'AddWaitColumn1621707690587'; async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - console.log('\n\nINFO: Started with migration for wait functionality.\n Depending on the number of saved executions, that may take a little bit.\n\n'); await queryRunner.query(`DROP TABLE IF EXISTS "${tablePrefix}temporary_execution_entity"`); - await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar, "waitTill" DATETIME)`, undefined); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar, "waitTill" DATETIME)`, + undefined, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`, + ); await queryRunner.query(`VACUUM;`); + + logMigrationEnd(this.name); } async down(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)`, undefined); - await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}temporary_execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime, "workflowData" text NOT NULL, "workflowId" varchar)`, + undefined, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}temporary_execution_entity"("id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId") SELECT "id", "data", "finished", "mode", "retryOf", "retrySuccessId", "startedAt", "stoppedAt", "workflowData", "workflowId" FROM "${tablePrefix}execution_entity"`, + ); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity"`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`); - await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}temporary_execution_entity" RENAME TO "${tablePrefix}execution_entity"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`, + ); await queryRunner.query(`VACUUM;`); - } - } diff --git a/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts index 273f644e4e..9c9452f695 100644 --- a/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts +++ b/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts @@ -1,6 +1,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import config = require('../../../../config'); import { MigrationHelpers } from '../../MigrationHelpers'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; // replacing the credentials in workflows and execution // `nodeType: name` changes to `nodeType: { id, name }` @@ -9,8 +10,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac name = 'UpdateWorkflowCredentials1630330987096'; public async up(queryRunner: QueryRunner): Promise { - console.log('Start migration', this.name); - console.time(this.name); + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); const helpers = new MigrationHelpers(queryRunner); @@ -146,7 +147,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac queryRunner.query(updateQuery, updateParams); } }); - console.timeEnd(this.name); + + logMigrationEnd(this.name); } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts b/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts index 1f6695626f..74f6822a93 100644 --- a/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts +++ b/packages/cli/src/databases/sqlite/migrations/1644421939510-AddExecutionEntityIndexes.ts @@ -1,31 +1,49 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class AddExecutionEntityIndexes1644421939510 implements MigrationInterface { - name = 'AddExecutionEntityIndexes1644421939510' + name = 'AddExecutionEntityIndexes1644421939510'; - public async up(queryRunner: QueryRunner): Promise { - console.log('\n\nINFO: Started migration for execution entity indexes.\n Depending on the number of saved executions, it may take a while.\n\n'); + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = config.get('database.tablePrefix'); - const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}c4d999a5e90784e8caccf5589d'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003' ON '${tablePrefix}execution_entity' ('workflowId', 'waitTill', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a' ON '${tablePrefix}execution_entity' ('workflowId', 'finished', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}1688846335d274033e15c846a4' ON '${tablePrefix}execution_entity' ('finished', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `); - } + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`); - public async down(queryRunner: QueryRunner): Promise { - const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003' ON '${tablePrefix}execution_entity' ('workflowId', 'waitTill', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a' ON '${tablePrefix}execution_entity' ('workflowId', 'finished', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}1688846335d274033e15c846a4' ON '${tablePrefix}execution_entity' ('finished', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `, + ); + logMigrationEnd(this.name); + } - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}1688846335d274033e15c846a4'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a'`); - await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003'`); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2' ON '${tablePrefix}execution_entity' ('waitTill') `); - } + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}1688846335d274033e15c846a4'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}78d62b89dc1433192b86dce18a'`); + await queryRunner.query(`DROP INDEX 'IDX_${tablePrefix}06da892aaf92a48e7d3e400003'`); + await queryRunner.query( + `CREATE INDEX 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2' ON '${tablePrefix}execution_entity' ('waitTill') `, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}c4d999a5e90784e8caccf5589d' ON '${tablePrefix}execution_entity' ('workflowId') `, + ); + } } diff --git a/packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts b/packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts new file mode 100644 index 0000000000..d087335331 --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1646992772331-CreateUserManagement.ts @@ -0,0 +1,118 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import config = require('../../../../config'); +import { + loadSurveyFromDisk, + logMigrationEnd, + logMigrationStart, +} from '../../utils/migrationHelpers'; + +export class CreateUserManagement1646992772331 implements MigrationInterface { + name = 'CreateUserManagement1646992772331'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}role" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(32) NOT NULL, "scope" varchar NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "UQ_${tablePrefix}5b49d0f504f7ef31045a1fb2eb8" UNIQUE ("scope", "name"))`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}shared_workflow" ("createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "roleId" integer NOT NULL, "userId" varchar NOT NULL, "workflowId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}3540da03964527aa24ae014b780" FOREIGN KEY ("roleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}82b2fd9ec4e3e24209af8160282" FOREIGN KEY ("userId") REFERENCES "${tablePrefix}user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}b83f8d2530884b66a9c848c8b88" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("userId", "workflowId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}65a0933c0f19d278881653bf81d35064" ON "${tablePrefix}shared_workflow" ("workflowId")`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}shared_credentials" ("createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "roleId" integer NOT NULL, "userId" varchar NOT NULL, "credentialsId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}c68e056637562000b68f480815a" FOREIGN KEY ("roleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}484f0327e778648dd04f1d70493" FOREIGN KEY ("userId") REFERENCES "${tablePrefix}user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}68661def1d4bcf2451ac8dbd949" FOREIGN KEY ("credentialsId") REFERENCES "${tablePrefix}credentials_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("userId", "credentialsId"))`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}829d16efa0e265cb076d50eca8d21733" ON "${tablePrefix}shared_credentials" ("credentialsId")`, + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}settings" ("key" TEXT NOT NULL,"value" TEXT NOT NULL DEFAULT \'\',"loadOnStartup" boolean NOT NULL default false,PRIMARY KEY("key"))`, + ); + + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); + + // Insert initial roles + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("owner", "global"); + `); + + const instanceOwnerRole = await queryRunner.query('SELECT last_insert_rowid() as insertId'); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("member", "global"); + `); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("owner", "workflow"); + `); + + const workflowOwnerRole = await queryRunner.query('SELECT last_insert_rowid() as insertId'); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("owner", "credential"); + `); + + const credentialOwnerRole = await queryRunner.query('SELECT last_insert_rowid() as insertId'); + + const survey = loadSurveyFromDisk(); + + const ownerUserId = uuid(); + await queryRunner.query( + ` + INSERT INTO "${tablePrefix}user" (id, globalRoleId, personalizationAnswers) values + (?, ?, ?) + `, + [ownerUserId, instanceOwnerRole[0].insertId, survey], + ); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}shared_workflow" (createdAt, updatedAt, roleId, userId, workflowId) + select DATETIME('now'), DATETIME('now'), '${workflowOwnerRole[0].insertId}', '${ownerUserId}', id from "${tablePrefix}workflow_entity" + `); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}shared_credentials" (createdAt, updatedAt, roleId, userId, credentialsId) + select DATETIME('now'), DATETIME('now'), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', id from "${tablePrefix}credentials_entity" + `); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}settings" (key, value, loadOnStartup) values + ('userManagement.isInstanceOwnerSetUp', 'false', true), ('userManagement.skipInstanceOwnerSetup', 'false', true) + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `, + ); + + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_credentials"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}shared_workflow"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}user"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}role"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}settings"`); + } +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 75ec0830e5..fe2a71b682 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -1,3 +1,5 @@ +import config = require('../../../../config'); + import { InitialMigration1588102412422 } from './1588102412422-InitialMigration'; import { WebhookModel1592445003908 } from './1592445003908-WebhookModel'; import { CreateIndexStoppedAt1594825041918 } from './1594825041918-CreateIndexStoppedAt'; @@ -8,8 +10,9 @@ import { UniqueWorkflowNames1620821879465 } from './1620821879465-UniqueWorkflow import { AddWaitColumn1621707690587 } from './1621707690587-AddWaitColumn'; import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWorkflowCredentials'; import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes'; +import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; -export const sqliteMigrations = [ +const sqliteMigrations = [ InitialMigration1588102412422, WebhookModel1592445003908, CreateIndexStoppedAt1594825041918, @@ -20,4 +23,7 @@ export const sqliteMigrations = [ AddWaitColumn1621707690587, UpdateWorkflowCredentials1630330987096, AddExecutionEntityIndexes1644421939510, + CreateUserManagement1646992772331, ]; + +export { sqliteMigrations }; diff --git a/packages/cli/src/databases/utils/customValidators.ts b/packages/cli/src/databases/utils/customValidators.ts new file mode 100644 index 0000000000..f84832fe55 --- /dev/null +++ b/packages/cli/src/databases/utils/customValidators.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { registerDecorator } from 'class-validator'; + +export function NoXss() { + return (object: object, propertyName: string): void => { + registerDecorator({ + name: 'NoXss', + target: object.constructor, + propertyName, + constraints: [propertyName], + options: { message: `Malicious ${propertyName}` }, + validator: { + validate(value: string) { + return !/<(\s*)?(script|a|http)/.test(value); + }, + }, + }); + }; +} diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts new file mode 100644 index 0000000000..9acb0676f5 --- /dev/null +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -0,0 +1,55 @@ +import { readFileSync, rmSync } from 'fs'; +import { UserSettings } from 'n8n-core'; +import { getLogger } from '../../Logger'; + +const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; + +export function loadSurveyFromDisk(): string | null { + const userSettingsPath = UserSettings.getUserN8nFolderPath(); + try { + const filename = `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`; + const surveyFile = readFileSync(filename, 'utf-8'); + rmSync(filename); + const personalizationSurvey = JSON.parse(surveyFile) as object; + const kvPairs = Object.entries(personalizationSurvey); + if (!kvPairs.length) { + throw new Error('personalizationSurvey is empty'); + } else { + // eslint-disable-next-line @typescript-eslint/naming-convention + const emptyKeys = kvPairs.reduce((acc, [_key, value]) => { + if (!value || (Array.isArray(value) && !value.length)) { + return acc + 1; + } + return acc; + }, 0); + if (emptyKeys === kvPairs.length) { + throw new Error('incomplete personalizationSurvey'); + } + } + return surveyFile; + } catch (error) { + return null; + } +} + +let logFinishTimeout: NodeJS.Timeout; +const disableLogging = process.argv[1].split('/').includes('jest'); + +export function logMigrationStart(migrationName: string): void { + if (disableLogging) return; + const logger = getLogger(); + if (!logFinishTimeout) { + logger.warn('Migrations in progress, please do NOT stop the process.'); + } + logger.debug(`Starting migration ${migrationName}`); + clearTimeout(logFinishTimeout); +} + +export function logMigrationEnd(migrationName: string): void { + if (disableLogging) return; + const logger = getLogger(); + logger.debug(`Finished migration ${migrationName}`); + logFinishTimeout = setTimeout(() => { + logger.warn('Migrations finished.'); + }, 100); +} diff --git a/packages/cli/src/databases/utils/transformers.ts b/packages/cli/src/databases/utils/transformers.ts new file mode 100644 index 0000000000..f843c1c2f7 --- /dev/null +++ b/packages/cli/src/databases/utils/transformers.ts @@ -0,0 +1,20 @@ +// eslint-disable-next-line import/no-cycle +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), +}; + +/** + * Ensure a consistent return type for personalization answers in `User`. + * Answers currently stored as `TEXT` on Postgres. + */ +export const answersFormatter = { + to: (answers: IPersonalizationSurveyAnswers): IPersonalizationSurveyAnswers => answers, + from: (answers: IPersonalizationSurveyAnswers | string): IPersonalizationSurveyAnswers => { + return typeof answers === 'string' + ? (JSON.parse(answers) as IPersonalizationSurveyAnswers) + : answers; + }, +}; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts new file mode 100644 index 0000000000..72e1ffe0e2 --- /dev/null +++ b/packages/cli/src/requests.d.ts @@ -0,0 +1,272 @@ +/* eslint-disable import/no-cycle */ +import express = require('express'); +import { + IConnections, + ICredentialDataDecryptedObject, + ICredentialNodeAccess, + INode, + INodeCredentialTestRequest, + IRunData, + IWorkflowSettings, +} from 'n8n-workflow'; + +import { User } from './databases/entities/User'; +import type { IExecutionDeleteFilter, IWorkflowDb } from '.'; +import type { PublicUser } from './UserManagement/Interfaces'; + +export type AuthlessRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request; + +export type AuthenticatedRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request & { user: User }; + +// ---------------------------------- +// /workflows +// ---------------------------------- + +export declare namespace WorkflowRequest { + type RequestBody = Partial<{ + id: string; // delete if sent + name: string; + nodes: INode[]; + connections: IConnections; + settings: IWorkflowSettings; + active: boolean; + tags: string[]; + }>; + + type Create = AuthenticatedRequest<{}, {}, RequestBody>; + + type Get = AuthenticatedRequest<{ id: string }>; + + type Delete = Get; + + type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>; + + type NewName = express.Request<{}, {}, {}, { name?: string }>; + + type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; + + type GetAllActive = AuthenticatedRequest; + + type GetAllActivationErrors = Get; + + type ManualRun = AuthenticatedRequest< + {}, + {}, + { + workflowData: IWorkflowDb; + runData: IRunData; + startNodes?: string[]; + destinationNode?: string; + } + >; +} + +// ---------------------------------- +// /credentials +// ---------------------------------- + +export declare namespace CredentialRequest { + type RequestBody = Partial<{ + id: string; // delete if sent + name: string; + type: string; + nodesAccess: ICredentialNodeAccess[]; + data: ICredentialDataDecryptedObject; + }>; + + type Create = AuthenticatedRequest<{}, {}, RequestBody>; + + type Get = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + + type Delete = Get; + + type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; + + type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>; + + type NewName = WorkflowRequest.NewName; + + type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; +} + +// ---------------------------------- +// /executions +// ---------------------------------- + +export declare namespace ExecutionRequest { + namespace QueryParam { + type GetAll = { + filter: string; // '{ waitTill: string; finished: boolean, [other: string]: string }' + limit: string; + lastId: string; + firstId: string; + }; + + type GetAllCurrent = { + filter: string; // '{ workflowId: string }' + }; + } + + type GetAll = AuthenticatedRequest<{}, {}, {}, QueryParam.GetAll>; + type Get = AuthenticatedRequest<{ id: string }, {}, {}, { unflattedResponse: 'true' | 'false' }>; + type Delete = AuthenticatedRequest<{}, {}, IExecutionDeleteFilter>; + type Retry = AuthenticatedRequest<{ id: string }, {}, { loadWorkflow: boolean }, {}>; + type Stop = AuthenticatedRequest<{ id: string }>; + type GetAllCurrent = AuthenticatedRequest<{}, {}, {}, QueryParam.GetAllCurrent>; +} + +// ---------------------------------- +// /me +// ---------------------------------- + +export declare namespace MeRequest { + export type Settings = AuthenticatedRequest< + {}, + {}, + Pick + >; + export type Password = AuthenticatedRequest< + {}, + {}, + { currentPassword: string; newPassword: string } + >; + export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record | {}>; +} + +// ---------------------------------- +// /owner +// ---------------------------------- + +export declare namespace OwnerRequest { + type Post = AuthenticatedRequest< + {}, + {}, + Partial<{ + email: string; + password: string; + firstName: string; + lastName: string; + }>, + {} + >; +} + +// ---------------------------------- +// password reset endpoints +// ---------------------------------- + +export declare namespace PasswordResetRequest { + export type Email = AuthlessRequest<{}, {}, Pick>; + + export type Credentials = AuthlessRequest<{}, {}, {}, { userId?: string; token?: string }>; + + export type NewPassword = AuthlessRequest< + {}, + {}, + Pick & { token?: string; userId?: string } + >; +} + +// ---------------------------------- +// /users +// ---------------------------------- + +export declare namespace UserRequest { + export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; + + export type ResolveSignUp = AuthlessRequest< + {}, + {}, + {}, + { inviterId?: string; inviteeId?: string } + >; + + export type SignUp = AuthenticatedRequest< + { id: string }, + { inviterId?: string; inviteeId?: string } + >; + + export type Delete = AuthenticatedRequest<{ id: string }, {}, {}, { transferId?: string }>; + + export type Reinvite = AuthenticatedRequest<{ id: string }>; + + export type Update = AuthlessRequest< + { id: string }, + {}, + { + inviterId: string; + firstName: string; + lastName: string; + password: string; + } + >; +} + +// ---------------------------------- +// /login +// ---------------------------------- + +export type LoginRequest = AuthlessRequest< + {}, + {}, + { + email: string; + password: string; + } +>; + +// ---------------------------------- +// oauth endpoints +// ---------------------------------- + +export declare namespace OAuthRequest { + namespace OAuth1Credential { + type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>; + type Callback = AuthenticatedRequest< + {}, + {}, + {}, + { oauth_verifier: string; oauth_token: string; cid: string } + >; + } + + namespace OAuth2Credential { + type Auth = OAuth1Credential.Auth; + type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>; + } +} + +// ---------------------------------- +// /node-parameter-options +// ---------------------------------- + +export type NodeParameterOptionsRequest = AuthenticatedRequest< + {}, + {}, + {}, + { + nodeTypeAndVersion: string; + methodName: string; + path: string; + currentNodeParameters: string; + credentials: string; + } +>; + +// ---------------------------------- +// /tags +// ---------------------------------- + +export declare namespace TagsRequest { + type Delete = AuthenticatedRequest<{ id: string }>; +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 6b678f099f..69a4bad456 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import TelemetryClient = require('@rudderstack/rudder-sdk-node'); @@ -184,12 +185,17 @@ export class Telemetry { }); } - async track(eventName: string, properties?: IDataObject): Promise { + async track( + eventName: string, + properties: { [key: string]: unknown; user_id?: string } = {}, + ): Promise { return new Promise((resolve) => { if (this.client) { + const { user_id } = properties; + Object.assign(properties, { instance_id: this.instanceId }); this.client.track( { - userId: this.instanceId, + userId: `${this.instanceId}${user_id ? `#${user_id}` : ''}`, anonymousId: '000000000000', event: eventName, properties, diff --git a/packages/cli/test/integration/auth.endpoints.test.ts b/packages/cli/test/integration/auth.endpoints.test.ts new file mode 100644 index 0000000000..23ef7e3e3d --- /dev/null +++ b/packages/cli/test/integration/auth.endpoints.test.ts @@ -0,0 +1,147 @@ +import { hashSync, genSaltSync } from 'bcryptjs'; +import express = require('express'); +import validator from 'validator'; +import { v4 as uuid } from 'uuid'; + +import config = require('../../config'); +import * as utils from './shared/utils'; +import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; +import { Db } from '../../src'; +import { Role } from '../../src/databases/entities/Role'; +import { randomEmail, randomValidPassword, randomName } from './shared/random'; +import { getGlobalOwnerRole } from './shared/testDb'; +import * as testDb from './shared/testDb'; + +let globalOwnerRole: Role; + +let app: express.Application; +let testDbName = ''; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + await testDb.truncate(['User'], testDbName); + + globalOwnerRole = await getGlobalOwnerRole(); + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.createUser({ + id: uuid(), + email: TEST_USER.email, + firstName: TEST_USER.firstName, + lastName: TEST_USER.lastName, + password: hashSync(TEST_USER.password, genSaltSync(10)), + globalRole: globalOwnerRole, + }); + + config.set('userManagement.isInstanceOwnerSetUp', true); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); +}); + +afterEach(async () => { + await testDb.truncate(['User'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /login should log user in', async () => { + const authlessAgent = utils.createAgent(app); + + const response = await authlessAgent.post('/login').send({ + email: TEST_USER.email, + password: TEST_USER.password, + }); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + 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'); + expect(globalRole.scope).toBe('global'); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); +}); + +test('GET /login should receive logged in user', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.get('/login'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + 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'); + expect(globalRole.scope).toBe('global'); + + expect(response.headers['set-cookie']).toBeUndefined(); +}); + +test('POST /logout should log user out', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/logout'); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); +}); + +const TEST_USER = { + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), +}; diff --git a/packages/cli/test/integration/auth.middleware.test.ts b/packages/cli/test/integration/auth.middleware.test.ts new file mode 100644 index 0000000000..d686496aab --- /dev/null +++ b/packages/cli/test/integration/auth.middleware.test.ts @@ -0,0 +1,59 @@ +import express = require('express'); +import * as request from 'supertest'; +import { + REST_PATH_SEGMENT, + ROUTES_REQUIRING_AUTHORIZATION, + ROUTES_REQUIRING_AUTHENTICATION, +} from './shared/constants'; + +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; + +beforeAll(async () => { + app = utils.initTestServer({ + applyAuth: true, + endpointGroups: ['me', 'auth', 'owner', 'users'], + }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +ROUTES_REQUIRING_AUTHENTICATION.concat(ROUTES_REQUIRING_AUTHORIZATION).forEach((route) => { + const [method, endpoint] = getMethodAndEndpoint(route); + + test(`${route} should return 401 Unauthorized if no cookie`, async () => { + const response = await request(app)[method](endpoint).use(utils.prefix(REST_PATH_SEGMENT)); + + expect(response.statusCode).toBe(401); + }); +}); + +ROUTES_REQUIRING_AUTHORIZATION.forEach(async (route) => { + const [method, endpoint] = getMethodAndEndpoint(route); + + test(`${route} should return 403 Forbidden for member`, async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const response = await authMemberAgent[method](endpoint); + if (response.statusCode === 500) { + console.log(response); + } + + expect(response.statusCode).toBe(403); + }); +}); + +function getMethodAndEndpoint(route: string) { + return route.split(' ').map((segment, index) => { + return index % 2 === 0 ? segment.toLowerCase() : segment; + }); +} diff --git a/packages/cli/test/integration/credentials.api.test.ts b/packages/cli/test/integration/credentials.api.test.ts new file mode 100644 index 0000000000..b05276c202 --- /dev/null +++ b/packages/cli/test/integration/credentials.api.test.ts @@ -0,0 +1,551 @@ +import express = require('express'); +import { UserSettings } from 'n8n-core'; +import { Db } from '../../src'; +import { randomName, randomString } from './shared/random'; +import * as utils from './shared/utils'; +import type { CredentialPayload, SaveCredentialFunction } from './shared/types'; +import { Role } from '../../src/databases/entities/Role'; +import { User } from '../../src/databases/entities/User'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let saveCredential: SaveCredentialFunction; + +beforeAll(async () => { + app = utils.initTestServer({ + endpointGroups: ['credentials'], + applyAuth: true, + }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initConfigFile(); + + const credentialOwnerRole = await testDb.getCredentialOwnerRole(); + saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.createOwnerShell(); +}); + +afterEach(async () => { + // do not combine calls - shared table must be cleared first and separately + await testDb.truncate(['SharedCredentials'], testDbName); + await testDb.truncate(['User', 'Credentials'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /credentials should create cred', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const payload = credentialPayload(); + + const response = await authOwnerAgent.post('/credentials').send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(payload.name); + expect(type).toBe(payload.type); + expect(nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(payload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(payload.name); + expect(credential.type).toBe(payload.type); + expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(payload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user', 'credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.user.id).toBe(owner.id); + expect(sharedCredential.credentials.name).toBe(payload.name); +}); + +test('POST /credentials should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + for (const invalidPayload of INVALID_PAYLOADS) { + const response = await authOwnerAgent.post('/credentials').send(invalidPayload); + expect(response.statusCode).toBe(400); + } +}); + +test('POST /credentials should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('POST /credentials should ignore ID in payload', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const firstResponse = await authOwnerAgent + .post('/credentials') + .send({ id: '8', ...credentialPayload() }); + + expect(firstResponse.body.data.id).not.toBe('8'); + + const secondResponse = await authOwnerAgent + .post('/credentials') + .send({ id: 8, ...credentialPayload() }); + + expect(secondResponse.body.data.id).not.toBe(8); +}); + +test('DELETE /credentials/:id should delete owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const member = await testDb.createUser(); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete owned cred for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(shellCredential).toBeDefined(); // not deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeDefined(); // not deleted +}); + +test('DELETE /credentials/:id should fail if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.delete('/credentials/123'); + + expect(response.statusCode).toBe(404); +}); + +test('PATCH /credentials/:id should update owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + const patchPayload = credentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should update non-owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const member = await testDb.createUser(); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + const patchPayload = credentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should update owned cred for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + const patchPayload = credentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should not update non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + const patchPayload = credentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOneOrFail(savedCredential.id); + + expect(shellCredential.name).not.toBe(patchPayload.name); // not updated +}); + +test('PATCH /credentials/:id should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + for (const invalidPayload of INVALID_PAYLOADS) { + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(invalidPayload); + + expect(response.statusCode).toBe(400); + } +}); + +test('PATCH /credentials/:id should fail if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.patch('/credentials/123').send(credentialPayload()); + + expect(response.statusCode).toBe(404); +}); + +test('PATCH /credentials/:id should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials should retrieve all creds for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: owner }); + } + + const member = await testDb.createUser(); + + await saveCredential(credentialPayload(), { user: member }); + + const response = await authOwnerAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); // 3 owner + 1 member + + for (const credential of response.body.data) { + const { name, type, nodesAccess, data: encryptedData } = credential; + + expect(typeof name).toBe('string'); + expect(typeof type).toBe('string'); + expect(typeof nodesAccess[0].nodeType).toBe('string'); + expect(encryptedData).toBeUndefined(); + } +}); + +test('GET /credentials should retrieve owned creds for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: member }); + } + + const response = await authMemberAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + for (const credential of response.body.data) { + const { name, type, nodesAccess, data: encryptedData } = credential; + + expect(typeof name).toBe('string'); + expect(typeof type).toBe('string'); + expect(typeof nodesAccess[0].nodeType).toBe('string'); + expect(encryptedData).toBeUndefined(); + } +}); + +test('GET /credentials should not retrieve non-owned creds for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: owner }); + } + + const response = await authMemberAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(0); // owner's creds not returned +}); + +test('GET /credentials/:id should retrieve owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + expect(typeof firstResponse.body.data.name).toBe('string'); + expect(typeof firstResponse.body.data.type).toBe('string'); + expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(firstResponse.body.data.data).toBeUndefined(); + + const secondResponse = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + expect(typeof secondResponse.body.data.name).toBe('string'); + expect(typeof secondResponse.body.data.type).toBe('string'); + expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(secondResponse.body.data.data).toBeDefined(); +}); + +test('GET /credentials/:id should retrieve owned cred for member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + expect(typeof firstResponse.body.data.name).toBe('string'); + expect(typeof firstResponse.body.data.type).toBe('string'); + expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(firstResponse.body.data.data).toBeUndefined(); + + const secondResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + + expect(typeof secondResponse.body.data.name).toBe('string'); + expect(typeof secondResponse.body.data.type).toBe('string'); + expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(secondResponse.body.data.data).toBeDefined(); +}); + +test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + expect(response.body.data).toBeUndefined(); // owner's cred not returned +}); + +test('GET /credentials/:id should fail with missing encryption key', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const response = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials/:id should return 404 if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authMemberAgent.get('/credentials/789'); + + expect(response.statusCode).toBe(404); +}); + +const credentialPayload = () => ({ + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, +}); + +const INVALID_PAYLOADS = [ + { + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + type: randomName(), + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + }, + {}, + [], + undefined, +]; + +function affixRoleToSaveCredential(role: Role) { + return (credentialPayload: CredentialPayload, { user }: { user: User }) => + testDb.saveCredential(credentialPayload, { user, role }); +} diff --git a/packages/cli/test/integration/me.endpoints.test.ts b/packages/cli/test/integration/me.endpoints.test.ts new file mode 100644 index 0000000000..d8bbd66592 --- /dev/null +++ b/packages/cli/test/integration/me.endpoints.test.ts @@ -0,0 +1,529 @@ +import { hashSync, genSaltSync } from 'bcryptjs'; +import express = require('express'); +import validator from 'validator'; + +import config = require('../../config'); +import * as utils from './shared/utils'; +import { SUCCESS_RESPONSE_BODY } from './shared/constants'; +import { Db } from '../../src'; +import { Role } from '../../src/databases/entities/Role'; +import { randomValidPassword, randomEmail, randomName, randomString } from './shared/random'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +describe('Owner shell', () => { + beforeEach(async () => { + await testDb.createOwnerShell(); + }); + + afterEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test('GET /me should return sanitized owner shell', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent.get('/me'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeNull(); + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(true); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + for (const validPayload of VALID_PATCH_ME_PAYLOADS) { + const response = await authOwnerShellAgent.patch('/me').send(validPayload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const storedOwnerShell = await Db.collections.User!.findOneOrFail(id); + + expect(storedOwnerShell.email).toBe(validPayload.email); + expect(storedOwnerShell.firstName).toBe(validPayload.firstName); + expect(storedOwnerShell.lastName).toBe(validPayload.lastName); + } + }); + + test('PATCH /me should fail with invalid inputs', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { + const response = await authOwnerShellAgent.patch('/me').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedOwnerShell = await Db.collections.User!.findOneOrFail(); + expect(storedOwnerShell.email).toBeNull(); + expect(storedOwnerShell.firstName).toBeNull(); + expect(storedOwnerShell.lastName).toBeNull(); + } + }); + + test('PATCH /me/password should fail for shell', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const validPasswordPayload = { + currentPassword: randomValidPassword(), + newPassword: randomValidPassword(), + }; + + const payloads = [validPasswordPayload, ...INVALID_PASSWORD_PAYLOADS]; + + for (const payload of payloads) { + const response = await authOwnerShellAgent.patch('/me/password').send(payload); + expect([400, 500].includes(response.statusCode)).toBe(true); + + const storedMember = await Db.collections.User!.findOneOrFail(); + + if (payload.newPassword) { + expect(storedMember.password).not.toBe(payload.newPassword); + } + if (payload.currentPassword) { + expect(storedMember.password).not.toBe(payload.currentPassword); + } + } + + const storedOwnerShell = await Db.collections.User!.findOneOrFail(); + expect(storedOwnerShell.password).toBeNull(); + }); + + test('POST /me/survey should succeed with valid inputs', async () => { + const ownerShell = await Db.collections.User!.findOneOrFail(); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const validPayloads = [SURVEY, {}]; + + for (const validPayload of validPayloads) { + const response = await authOwnerShellAgent.post('/me/survey').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const { personalizationAnswers: storedAnswers } = await Db.collections.User!.findOneOrFail(); + + expect(storedAnswers).toEqual(validPayload); + } + }); +}); + +describe('Member', () => { + beforeEach(async () => { + config.set('userManagement.isInstanceOwnerSetUp', true); + + await Db.collections.Settings!.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + }); + + afterEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test('GET /me should return sanitized member', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const response = await authMemberAgent.get('/me'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(member.email); + expect(firstName).toBe(member.firstName); + expect(lastName).toBe(member.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('member'); + expect(globalRole.scope).toBe('global'); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (const validPayload of VALID_PATCH_ME_PAYLOADS) { + const response = await authMemberAgent.patch('/me').send(validPayload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('member'); + expect(globalRole.scope).toBe('global'); + + const storedMember = await Db.collections.User!.findOneOrFail(id); + + expect(storedMember.email).toBe(validPayload.email); + expect(storedMember.firstName).toBe(validPayload.firstName); + expect(storedMember.lastName).toBe(validPayload.lastName); + } + }); + + test('PATCH /me should fail with invalid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { + const response = await authMemberAgent.patch('/me').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedMember = await Db.collections.User!.findOneOrFail(); + expect(storedMember.email).toBe(member.email); + expect(storedMember.firstName).toBe(member.firstName); + expect(storedMember.lastName).toBe(member.lastName); + } + }); + + test('PATCH /me/password should succeed with valid inputs', async () => { + const memberPassword = randomValidPassword(); + const member = await testDb.createUser({ + password: hashSync(memberPassword, genSaltSync(10)), + }); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const validPayload = { + currentPassword: memberPassword, + newPassword: randomValidPassword(), + }; + + const response = await authMemberAgent.patch('/me/password').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const storedMember = await Db.collections.User!.findOneOrFail(); + expect(storedMember.password).not.toBe(member.password); + expect(storedMember.password).not.toBe(validPayload.newPassword); + }); + + test('PATCH /me/password should fail with invalid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + for (const payload of INVALID_PASSWORD_PAYLOADS) { + const response = await authMemberAgent.patch('/me/password').send(payload); + expect([400, 500].includes(response.statusCode)).toBe(true); + + const storedMember = await Db.collections.User!.findOneOrFail(); + + if (payload.newPassword) { + expect(storedMember.password).not.toBe(payload.newPassword); + } + if (payload.currentPassword) { + expect(storedMember.password).not.toBe(payload.currentPassword); + } + } + }); + + test('POST /me/survey should succeed with valid inputs', async () => { + const member = await testDb.createUser(); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const validPayloads = [SURVEY, {}]; + + for (const validPayload of validPayloads) { + const response = await authMemberAgent.post('/me/survey').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const { personalizationAnswers: storedAnswers } = await Db.collections.User!.findOneOrFail(); + + expect(storedAnswers).toEqual(validPayload); + } + }); +}); + +describe('Owner', () => { + beforeEach(async () => { + config.set('userManagement.isInstanceOwnerSetUp', true); + }); + + afterEach(async () => { + await testDb.truncate(['User'], testDbName); + }); + + test('GET /me should return sanitized owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.get('/me'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(owner.email); + expect(firstName).toBe(owner.firstName); + expect(lastName).toBe(owner.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + for (const validPayload of VALID_PATCH_ME_PAYLOADS) { + const response = await authOwnerAgent.patch('/me').send(validPayload); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const storedOwner = await Db.collections.User!.findOneOrFail(id); + + expect(storedOwner.email).toBe(validPayload.email); + 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', + 'companySize', + 'otherCompanyIndustry', + 'otherWorkArea', + 'workArea', +].reduce>((acc, cur) => { + return (acc[cur] = randomString(2, 10)), acc; +}, {}); + +const VALID_PATCH_ME_PAYLOADS = [ + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, +]; + +const INVALID_PATCH_ME_PAYLOADS = [ + { + email: 'invalid', + firstName: randomName(), + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: '', + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: '', + }, + { + email: randomEmail(), + firstName: 123, + lastName: randomName(), + }, + { + firstName: randomName(), + lastName: randomName(), + }, + { + firstName: randomName(), + }, + { + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: 'John { + app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.createOwnerShell(); +}); + +afterEach(async () => { + await testDb.truncate(['User'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/owner').send(TEST_USER); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(TEST_USER.email); + expect(firstName).toBe(TEST_USER.firstName); + expect(lastName).toBe(TEST_USER.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(isPending).toBe(false); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + + const storedOwner = await Db.collections.User!.findOneOrFail(id); + expect(storedOwner.password).not.toBe(TEST_USER.password); + expect(storedOwner.email).toBe(TEST_USER.email); + expect(storedOwner.firstName).toBe(TEST_USER.firstName); + expect(storedOwner.lastName).toBe(TEST_USER.lastName); + + const isInstanceOwnerSetUpConfig = config.get('userManagement.isInstanceOwnerSetUp'); + expect(isInstanceOwnerSetUpConfig).toBe(true); + + const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp(); + expect(isInstanceOwnerSetUpSetting).toBe(true); +}); + +test('POST /owner should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + for (const invalidPayload of INVALID_POST_OWNER_PAYLOADS) { + const response = await authOwnerAgent.post('/owner').send(invalidPayload); + expect(response.statusCode).toBe(400); + } +}); + +test('POST /owner/skip-setup should persist skipping setup to the DB', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/owner/skip-setup').send(); + + expect(response.statusCode).toBe(200); + + const skipConfig = config.get('userManagement.skipInstanceOwnerSetup'); + expect(skipConfig).toBe(true); + + const { value } = await Db.collections.Settings!.findOneOrFail({ + key: 'userManagement.skipInstanceOwnerSetup', + }); + expect(value).toBe('true'); +}); + +const TEST_USER = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; + +const INVALID_POST_OWNER_PAYLOADS = [ + { + email: '', + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: '', + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: '', + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, + { + firstName: randomName(), + lastName: randomName(), + }, + { + firstName: randomName(), + }, + { + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: 'John { + app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + await testDb.truncate(['User'], testDbName); + + globalOwnerRole = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); + + utils.initTestTelemetry(); + utils.initTestLogger(); +}); + +beforeEach(async () => { + jest.isolateModules(() => { + jest.mock('../../config'); + }); + + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', ''); + + await testDb.createUser({ + id: INITIAL_TEST_USER.id, + email: INITIAL_TEST_USER.email, + password: INITIAL_TEST_USER.password, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + globalRole: globalOwnerRole, + }); +}); + +afterEach(async () => { + await testDb.truncate(['User'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /forgot-password should send password reset email', async () => { + const authlessAgent = utils.createAgent(app); + + const { + user, + pass, + smtp: { host, port, secure }, + } = await utils.getSmtpTestAccount(); + + config.set('userManagement.emails.mode', 'smtp'); + config.set('userManagement.emails.smtp.host', host); + config.set('userManagement.emails.smtp.port', port); + config.set('userManagement.emails.smtp.secure', secure); + config.set('userManagement.emails.smtp.auth.user', user); + config.set('userManagement.emails.smtp.auth.pass', pass); + + const response = await authlessAgent + .post('/forgot-password') + .send({ email: INITIAL_TEST_USER.email }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({}); + + const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email }); + expect(owner.resetPasswordToken).toBeDefined(); + expect(owner.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000)); +}); + +test('POST /forgot-password should fail if emailing is not set up', async () => { + const authlessAgent = utils.createAgent(app); + + const response = await authlessAgent + .post('/forgot-password') + .send({ email: INITIAL_TEST_USER.email }); + + expect(response.statusCode).toBe(500); + + const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email }); + expect(owner.resetPasswordToken).toBeNull(); +}); + +test('POST /forgot-password should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + for (const invalidPayload of invalidPayloads) { + const response = await authlessAgent.post('/forgot-password').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email }); + expect(owner.resetPasswordToken).toBeNull(); + } +}); + +test('POST /forgot-password should fail if user is not found', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent.post('/forgot-password').send({ email: randomEmail() }); + + // response should have 200 to not provide any information to the requester + expect(response.statusCode).toBe(200); +}); + +test('GET /resolve-password-token should succeed with valid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id, token: resetPasswordToken }); + + expect(response.statusCode).toBe(200); +}); + +test('GET /resolve-password-token should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const first = await authlessAgent.get('/resolve-password-token').query({ token: uuid() }); + + const second = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id }); + + for (const response of [first, second]) { + expect(response.statusCode).toBe(400); + } +}); + +test('GET /resolve-password-token should fail if user is not found', async () => { + const authlessAgent = utils.createAgent(app); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id, token: uuid() }); + + expect(response.statusCode).toBe(404); +}); + +test('GET /resolve-password-token should fail if token is expired', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: INITIAL_TEST_USER.id, token: resetPasswordToken }); + + expect(response.statusCode).toBe(404); +}); + +test('POST /change-password should succeed with valid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const passwordToStore = randomValidPassword(); + + const response = await authlessAgent.post('/change-password').send({ + token: resetPasswordToken, + userId: INITIAL_TEST_USER.id, + password: passwordToStore, + }); + + expect(response.statusCode).toBe(200); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const { password: storedPassword } = await Db.collections.User!.findOneOrFail( + INITIAL_TEST_USER.id, + ); + + const comparisonResult = await compare(passwordToStore, storedPassword!); + expect(comparisonResult).toBe(true); + expect(storedPassword).not.toBe(passwordToStore); +}); + +test('POST /change-password should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const invalidPayloads = [ + { token: uuid() }, + { id: INITIAL_TEST_USER.id }, + { password: randomValidPassword() }, + { token: uuid(), id: INITIAL_TEST_USER.id }, + { token: uuid(), password: randomValidPassword() }, + { id: INITIAL_TEST_USER.id, password: randomValidPassword() }, + { + id: INITIAL_TEST_USER.id, + password: randomInvalidPassword(), + token: resetPasswordToken, + }, + { + id: INITIAL_TEST_USER.id, + password: randomValidPassword(), + token: uuid(), + }, + ]; + + const { password: originalHashedPassword } = await Db.collections.User!.findOneOrFail(); + + for (const invalidPayload of invalidPayloads) { + const response = await authlessAgent.post('/change-password').query(invalidPayload); + expect(response.statusCode).toBe(400); + + const { password: fetchedHashedPassword } = await Db.collections.User!.findOneOrFail(); + expect(originalHashedPassword).toBe(fetchedHashedPassword); + } +}); + +test('POST /change-password should fail when token has expired', async () => { + const authlessAgent = utils.createAgent(app); + + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; + + await Db.collections.User!.update(INITIAL_TEST_USER.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const passwordToStore = randomValidPassword(); + + const response = await authlessAgent.post('/change-password').send({ + token: resetPasswordToken, + userId: INITIAL_TEST_USER.id, + password: passwordToStore, + }); + + expect(response.statusCode).toBe(404); +}); + +const INITIAL_TEST_USER = { + id: uuid(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; diff --git a/packages/cli/test/integration/shared/augmentation.d.ts b/packages/cli/test/integration/shared/augmentation.d.ts new file mode 100644 index 0000000000..43a74a2f34 --- /dev/null +++ b/packages/cli/test/integration/shared/augmentation.d.ts @@ -0,0 +1,21 @@ +import superagent = require('superagent'); +import { ObjectLiteral } from 'typeorm'; + +/** + * Make `SuperTest` string-indexable. + */ +declare module 'supertest' { + interface SuperTest + extends superagent.SuperAgent, + Record {} +} + +/** + * Prevent `repository.delete({})` (non-criteria) from triggering the type error + * `Expression produces a union type that is too complex to represent.ts(2590)` + */ +declare module 'typeorm' { + interface Repository { + delete(criteria: {}): Promise; + } +} diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts new file mode 100644 index 0000000000..99c624efb4 --- /dev/null +++ b/packages/cli/test/integration/shared/constants.ts @@ -0,0 +1,59 @@ +import config = require('../../../config'); + +export const REST_PATH_SEGMENT = config.get('endpoints.rest') as Readonly; + +export const AUTHLESS_ENDPOINTS: Readonly = [ + 'healthz', + 'metrics', + config.get('endpoints.webhook') as string, + config.get('endpoints.webhookWaiting') as string, + config.get('endpoints.webhookTest') as string, +]; + +export const SUCCESS_RESPONSE_BODY = { + data: { + success: true, + }, +} as const; + +export const LOGGED_OUT_RESPONSE_BODY = { + data: { + loggedOut: true, + }, +}; + +/** + * Routes requiring a valid `n8n-auth` cookie for a user, either owner or member. + */ +export const ROUTES_REQUIRING_AUTHENTICATION: Readonly = [ + 'GET /me', + 'PATCH /me', + 'PATCH /me/password', + 'POST /me/survey', + 'POST /owner', + 'GET /non-existent', +]; + +/** + * Routes requiring a valid `n8n-auth` cookie for an owner. + */ +export const ROUTES_REQUIRING_AUTHORIZATION: Readonly = [ + 'POST /users', + 'GET /users', + 'DELETE /users/123', + 'POST /users/123/reinvite', + 'POST /owner', + 'POST /owner/skip-setup', +]; + +/** + * Name of the connection used for creating and dropping a Postgres DB + * for each suite test run. + */ +export const BOOTSTRAP_POSTGRES_CONNECTION_NAME: Readonly = 'n8n_bs_postgres'; + +/** + * Name of the connection (and database) used for creating and dropping a MySQL DB + * for each suite test run. + */ +export const BOOTSTRAP_MYSQL_CONNECTION_NAME: Readonly = 'n8n_bs_mysql'; diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts new file mode 100644 index 0000000000..4a531aba3b --- /dev/null +++ b/packages/cli/test/integration/shared/random.ts @@ -0,0 +1,41 @@ +import { randomBytes } from 'crypto'; +import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '../../../src/databases/entities/User'; + +/** + * Create a random alphanumeric string of random length between two limits, both inclusive. + * Limits should be even numbers (round down otherwise). + */ +export function randomString(min: number, max: number) { + const randomInteger = Math.floor(Math.random() * (max - min) + min) + 1; + return randomBytes(randomInteger / 2).toString('hex'); +} + +const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)]; + +const randomDigit = () => Math.floor(Math.random() * 10); + +const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); + +export const randomValidPassword = () => + randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH) + randomUppercaseLetter() + randomDigit(); + +export const randomInvalidPassword = () => + chooseRandomly([ + randomString(1, MIN_PASSWORD_LENGTH - 1), + randomString(MAX_PASSWORD_LENGTH + 2, MAX_PASSWORD_LENGTH + 100), + 'abcdefgh', // valid length, no number, no uppercase + 'abcdefg1', // valid length, has number, no uppercase + 'abcdefgA', // valid length, no number, has uppercase + 'abcdefA', // invalid length, no number, has uppercase + 'abcdef1', // invalid length, has number, no uppercase + 'abcdeA1', // invalid length, has number, has uppercase + 'abcdefg', // invalid length, no number, no uppercase + ]); + +export const randomEmail = () => `${randomName()}@${randomName()}.${randomTopLevelDomain()}`; + +const POPULAR_TOP_LEVEL_DOMAINS = ['com', 'org', 'net', 'io', 'edu']; + +const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); + +export const randomName = () => randomString(4, 8); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts new file mode 100644 index 0000000000..7c64dcfd90 --- /dev/null +++ b/packages/cli/test/integration/shared/testDb.ts @@ -0,0 +1,389 @@ +import { createConnection, getConnection, ConnectionOptions } from 'typeorm'; +import { Credentials, UserSettings } from 'n8n-core'; + +import config = require('../../../config'); +import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants'; +import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src'; +import { randomEmail, randomName, randomString, randomValidPassword } from './random'; +import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; + +import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants'; +import { entities } from '../../../src/databases/entities'; +import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations'; +import { postgresMigrations } from '../../../src/databases/postgresdb/migrations'; +import { sqliteMigrations } from '../../../src/databases/sqlite/migrations'; + +import type { Role } from '../../../src/databases/entities/Role'; +import type { User } from '../../../src/databases/entities/User'; +import type { CredentialPayload } from './types'; + +/** + * Initialize one test DB per suite run, with bootstrap connection if needed. + */ +export async function init() { + const dbType = config.get('database.type') as DatabaseType; + + if (dbType === 'sqlite') { + // no bootstrap connection required + const testDbName = `n8n_test_sqlite_${randomString(6, 10)}_${Date.now()}`; + await Db.init(getSqliteOptions({ name: testDbName })); + await getConnection(testDbName).runMigrations({ transaction: 'none' }); + + return { testDbName }; + } + + if (dbType === 'postgresdb') { + let bootstrapPostgres; + const bootstrapPostgresOptions = getBootstrapPostgresOptions(); + + try { + bootstrapPostgres = await createConnection(bootstrapPostgresOptions); + } catch (error) { + const { username, password, host, port, schema } = bootstrapPostgresOptions; + console.error( + `ERROR: Failed to connect to Postgres default DB 'postgres'.\nPlease review your Postgres connection options:\n\thost: "${host}"\n\tusername: "${username}"\n\tpassword: "${password}"\n\tport: "${port}"\n\tschema: "${schema}"\nFix by setting correct values via environment variables:\n\texport DB_POSTGRESDB_HOST=value\n\texport DB_POSTGRESDB_USER=value\n\texport DB_POSTGRESDB_PASSWORD=value\n\texport DB_POSTGRESDB_PORT=value\n\texport DB_POSTGRESDB_SCHEMA=value`, + ); + process.exit(1); + } + + const testDbName = `pg_${randomString(6, 10)}_${Date.now()}_n8n_test`; + await bootstrapPostgres.query(`CREATE DATABASE ${testDbName};`); + + await Db.init(getPostgresOptions({ name: testDbName })); + + return { testDbName }; + } + + if (dbType === 'mysqldb') { + const bootstrapMysql = await createConnection(getBootstrapMySqlOptions()); + + const testDbName = `mysql_${randomString(6, 10)}_${Date.now()}_n8n_test`; + await bootstrapMysql.query(`CREATE DATABASE ${testDbName};`); + + await Db.init(getMySqlOptions({ name: testDbName })); + + return { testDbName }; + } + + throw new Error(`Unrecognized DB type: ${dbType}`); +} + +/** + * Drop test DB, closing bootstrap connection if existing. + */ +export async function terminate(testDbName: string) { + const dbType = config.get('database.type') as DatabaseType; + + if (dbType === 'sqlite') { + await getConnection(testDbName).close(); + } + + if (dbType === 'postgresdb') { + await getConnection(testDbName).close(); + + const bootstrapPostgres = getConnection(BOOTSTRAP_POSTGRES_CONNECTION_NAME); + await bootstrapPostgres.query(`DROP DATABASE ${testDbName}`); + await bootstrapPostgres.close(); + } + + if (dbType === 'mysqldb') { + await getConnection(testDbName).close(); + + const bootstrapMySql = getConnection(BOOTSTRAP_MYSQL_CONNECTION_NAME); + await bootstrapMySql.query(`DROP DATABASE ${testDbName}`); + await bootstrapMySql.close(); + } +} + +/** + * Truncate DB tables for specified entities. + * + * @param entities Array of entity names whose tables to truncate. + * @param testDbName Name of the test DB to truncate tables in. + */ +export async function truncate(entities: Array, testDbName: string) { + const dbType = config.get('database.type'); + + if (dbType === 'sqlite') { + const testDb = getConnection(testDbName); + await testDb.query('PRAGMA foreign_keys=OFF'); + await Promise.all(entities.map((entity) => Db.collections[entity]!.clear())); + return testDb.query('PRAGMA foreign_keys=ON'); + } + + const map: { [K in keyof IDatabaseCollections]: string } = { + Credentials: 'credentials_entity', + Workflow: 'workflow_entity', + Execution: 'execution_entity', + Tag: 'tag_entity', + Webhook: 'webhook_entity', + Role: 'role', + User: 'user', + SharedCredentials: 'shared_credentials', + SharedWorkflow: 'shared_workflow', + Settings: 'settings', + }; + + if (dbType === 'postgresdb') { + return Promise.all( + entities.map((entity) => + getConnection(testDbName).query( + `TRUNCATE TABLE "${map[entity]}" RESTART IDENTITY CASCADE;`, + ), + ), + ); + } + + // MySQL truncation requires globals, which cannot be safely manipulated by parallel tests + if (dbType === 'mysqldb') { + await Promise.all( + entities.map(async (entity) => { + await Db.collections[entity]!.delete({}); + await getConnection(testDbName).query(`ALTER TABLE ${map[entity]} AUTO_INCREMENT = 1;`); + }), + ); + } +} + +// ---------------------------------- +// credential creation +// ---------------------------------- + +/** + * Save a credential to the test DB, sharing it with a user. + */ +export async function saveCredential( + credentialPayload: CredentialPayload, + { user, role }: { user: User; role: Role }, +) { + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, credentialPayload); + + const encryptedData = await encryptCredentialData(newCredential); + + Object.assign(newCredential, encryptedData); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + savedCredential.data = newCredential.data; + + await Db.collections.SharedCredentials!.save({ + user, + credentials: savedCredential, + role, + }); + + return savedCredential; +} + +// ---------------------------------- +// user creation +// ---------------------------------- + +/** + * Store a user in the DB, defaulting to a `member`. + */ +export async function createUser(attributes: Partial = {}): Promise { + const { email, password, firstName, lastName, globalRole, ...rest } = attributes; + const user = { + email: email ?? randomEmail(), + password: password ?? randomValidPassword(), + firstName: firstName ?? randomName(), + lastName: lastName ?? randomName(), + globalRole: globalRole ?? (await getGlobalMemberRole()), + ...rest, + }; + + return Db.collections.User!.save(user); +} + +export async function createOwnerShell() { + const globalRole = await getGlobalOwnerRole(); + return Db.collections.User!.save({ globalRole }); +} + +export async function createMemberShell() { + const globalRole = await getGlobalMemberRole(); + return Db.collections.User!.save({ globalRole }); +} + +// ---------------------------------- +// role fetchers +// ---------------------------------- + +export async function getGlobalOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); +} + +export async function getGlobalMemberRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); +} + +export async function getWorkflowOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); +} + +export async function getCredentialOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); +} + +export function getAllRoles() { + return Promise.all([ + getGlobalOwnerRole(), + getGlobalMemberRole(), + getWorkflowOwnerRole(), + getCredentialOwnerRole(), + ]); +} + +// ---------------------------------- +// connection options +// ---------------------------------- + +/** + * Generate options for an in-memory sqlite database connection, + * one per test suite run. + */ +export const getSqliteOptions = ({ name }: { name: string }): ConnectionOptions => { + return { + name, + type: 'sqlite', + database: ':memory:', + entityPrefix: '', + dropSchema: true, + migrations: sqliteMigrations, + migrationsTableName: 'migrations', + migrationsRun: false, + }; +}; + +/** + * Generate options for a bootstrap Postgres connection, + * to create and drop test Postgres databases. + */ +export const getBootstrapPostgresOptions = () => { + const username = config.get('database.postgresdb.user'); + const password = config.get('database.postgresdb.password'); + const host = config.get('database.postgresdb.host'); + const port = config.get('database.postgresdb.port'); + const schema = config.get('database.postgresdb.schema'); + + return { + name: BOOTSTRAP_POSTGRES_CONNECTION_NAME, + type: 'postgres', + database: 'postgres', // pre-existing default database + host, + port, + username, + password, + schema, + } as const; +}; + +export const getPostgresOptions = ({ name }: { name: string }): ConnectionOptions => { + const username = config.get('database.postgresdb.user'); + const password = config.get('database.postgresdb.password'); + const host = config.get('database.postgresdb.host'); + const port = config.get('database.postgresdb.port'); + const schema = config.get('database.postgresdb.schema'); + + return { + name, + type: 'postgres', + database: name, + host, + port, + username, + password, + entityPrefix: '', + schema, + dropSchema: true, + migrations: postgresMigrations, + migrationsRun: true, + migrationsTableName: 'migrations', + entities: Object.values(entities), + synchronize: false, + logging: false, + }; +}; + +/** + * Generate options for a bootstrap MySQL connection, + * to create and drop test MySQL databases. + */ +export const getBootstrapMySqlOptions = (): ConnectionOptions => { + const username = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + const port = config.get('database.mysqldb.port'); + + return { + name: BOOTSTRAP_MYSQL_CONNECTION_NAME, + database: BOOTSTRAP_MYSQL_CONNECTION_NAME, + type: 'mysql', + host, + port, + username, + password, + }; +}; + +/** + * Generate options for a MySQL database connection, + * one per test suite run. + */ +export const getMySqlOptions = ({ name }: { name: string }): ConnectionOptions => { + const username = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + const port = config.get('database.mysqldb.port'); + + return { + name, + database: name, + type: 'mysql', + host, + port, + username, + password, + migrations: mysqlMigrations, + migrationsTableName: 'migrations', + migrationsRun: true, + }; +}; + +// ---------------------------------- +// encryption +// ---------------------------------- + +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, + credential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(credential.data, encryptionKey); + + return coreCredential.getDataToSave() as ICredentialsDb; +} diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts new file mode 100644 index 0000000000..b160faea01 --- /dev/null +++ b/packages/cli/test/integration/shared/types.d.ts @@ -0,0 +1,28 @@ +import type { ICredentialDataDecryptedObject, ICredentialNodeAccess } from 'n8n-workflow'; +import type { ICredentialsDb } from '../../../src'; +import type { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; +import type { User } from '../../../src/databases/entities/User'; + +export type SmtpTestAccount = { + user: string; + pass: string; + smtp: { + host: string; + port: number; + secure: boolean; + }; +}; + +type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials'; + +export type CredentialPayload = { + name: string; + type: string; + nodesAccess: ICredentialNodeAccess[]; + data: ICredentialDataDecryptedObject; +}; + +export type SaveCredentialFunction = ( + credentialPayload: CredentialPayload, + { user }: { user: User }, +) => Promise; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts new file mode 100644 index 0000000000..45f97cfabf --- /dev/null +++ b/packages/cli/test/integration/shared/utils.ts @@ -0,0 +1,220 @@ +import { randomBytes } from 'crypto'; +import { existsSync } from 'fs'; +import express = require('express'); +import * as superagent from 'superagent'; +import * as request from 'supertest'; +import { URL } from 'url'; +import bodyParser = require('body-parser'); +import * as util from 'util'; +import { createTestAccount } from 'nodemailer'; +import { INodeTypes, LoggerProxy } from 'n8n-workflow'; +import { UserSettings } from 'n8n-core'; + +import config = require('../../../config'); +import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants'; +import { AUTH_COOKIE_NAME } from '../../../src/constants'; +import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; +import { Db, ExternalHooks, InternalHooksManager } from '../../../src'; +import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me'; +import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users'; +import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; +import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; +import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; +import { issueJWT } from '../../../src/UserManagement/auth/jwt'; +import { getLogger } from '../../../src/Logger'; +import { credentialsController } from '../../../src/api/credentials.api'; + +import type { User } from '../../../src/databases/entities/User'; +import { Telemetry } from '../../../src/telemetry'; +import type { EndpointGroup, SmtpTestAccount } from './types'; +import type { N8nApp } from '../../../src/UserManagement/Interfaces'; + +/** + * Initialize a test server. + * + * @param applyAuth Whether to apply auth middleware to test server. + * @param endpointGroups Groups of endpoints to apply to test server. + */ +export function initTestServer({ + applyAuth, + endpointGroups, +}: { + applyAuth: boolean; + endpointGroups?: EndpointGroup[]; +}) { + const testServer = { + app: express(), + restEndpoint: REST_PATH_SEGMENT, + ...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}), + }; + + testServer.app.use(bodyParser.json()); + testServer.app.use(bodyParser.urlencoded({ extended: true })); + + config.set('userManagement.jwtSecret', 'My JWT secret'); + config.set('userManagement.isInstanceOwnerSetUp', false); + + if (applyAuth) { + authMiddleware.apply(testServer, [AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT]); + } + + if (!endpointGroups) return testServer.app; + + const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups); + + if (routerEndpoints.length) { + const map: Record = { + credentials: credentialsController, + }; + + for (const group of routerEndpoints) { + testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + } + } + + if (functionEndpoints.length) { + const map: Record void> = { + me: meEndpoints, + users: usersEndpoints, + auth: authEndpoints, + owner: ownerEndpoints, + passwordReset: passwordResetEndpoints, + }; + + for (const group of functionEndpoints) { + map[group].apply(testServer); + } + } + + return testServer.app; +} + +export function initTestTelemetry() { + const mockNodeTypes = { nodeTypes: {} } as INodeTypes; + + void InternalHooksManager.init('test-instance-id', 'test-version', mockNodeTypes); + + jest.spyOn(Telemetry.prototype, 'track').mockResolvedValue(); +} + +/** + * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), + * and `functionEndpoints` (legacy, namespaced inside a function). + */ +const classifyEndpointGroups = (endpointGroups: string[]) => { + const routerEndpoints: string[] = []; + const functionEndpoints: string[] = []; + + endpointGroups.forEach((group) => + (group === 'credentials' ? routerEndpoints : functionEndpoints).push(group), + ); + + return [routerEndpoints, functionEndpoints]; +}; + +// ---------------------------------- +// initializers +// ---------------------------------- + +/** + * Initialize a silent logger for test runs. + */ +export function initTestLogger() { + config.set('logs.output', 'file'); // declutter console output + LoggerProxy.init(getLogger()); +} + +/** + * Initialize a user settings config file if non-existent. + */ +export function initConfigFile() { + const settingsPath = UserSettings.getUserSettingsPath(); + + if (!existsSync(settingsPath)) { + const userSettings = { encryptionKey: randomBytes(24).toString('base64') }; + UserSettings.writeUserSettings(userSettings, settingsPath); + } +} + +// ---------------------------------- +// request agent +// ---------------------------------- + +/** + * Create a request agent, optionally with an auth cookie. + */ +export function createAgent(app: express.Application, options?: { auth: true; user: User }) { + const agent = request.agent(app); + agent.use(prefix(REST_PATH_SEGMENT)); + + if (options?.auth && options?.user) { + const { token } = issueJWT(options.user); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + } + + return agent; +} + +/** + * Plugin to prefix a path segment into a request URL pathname. + * + * Example: http://127.0.0.1:62100/me/password → http://127.0.0.1:62100/rest/me/password + */ +export function prefix(pathSegment: string) { + return function (request: superagent.SuperAgentRequest) { + const url = new URL(request.url); + + // enforce consistency at call sites + if (url.pathname[0] !== '/') { + throw new Error('Pathname must start with a forward slash'); + } + + url.pathname = pathSegment + url.pathname; + request.url = url.toString(); + + return request; + }; +} + +/** + * Extract the value (token) of the auth cookie in a response. + */ +export function getAuthToken(response: request.Response, authCookieName = AUTH_COOKIE_NAME) { + const cookies: string[] = response.headers['set-cookie']; + + if (!cookies) { + throw new Error("No 'set-cookie' header found in response"); + } + + const authCookie = cookies.find((c) => c.startsWith(`${authCookieName}=`)); + + if (!authCookie) return undefined; + + const match = authCookie.match(new RegExp(`(^| )${authCookieName}=(?[^;]+)`)); + + if (!match || !match.groups) return undefined; + + return match.groups.token; +} + +// ---------------------------------- +// settings +// ---------------------------------- + +export async function isInstanceOwnerSetUp() { + const { value } = await Db.collections.Settings!.findOneOrFail({ + key: 'userManagement.isInstanceOwnerSetUp', + }); + + return Boolean(value); +} + +// ---------------------------------- +// SMTP +// ---------------------------------- + +/** + * Get an SMTP test account from https://ethereal.email to test sending emails. + */ +export const getSmtpTestAccount = util.promisify(createTestAccount); + diff --git a/packages/cli/test/integration/users.endpoints.test.ts b/packages/cli/test/integration/users.endpoints.test.ts new file mode 100644 index 0000000000..d5179950b9 --- /dev/null +++ b/packages/cli/test/integration/users.endpoints.test.ts @@ -0,0 +1,600 @@ +import express = require('express'); +import validator from 'validator'; +import { v4 as uuid } from 'uuid'; +import { compare } from 'bcryptjs'; + +import { Db } from '../../src'; +import config = require('../../config'); +import { SUCCESS_RESPONSE_BODY } from './shared/constants'; +import { Role } from '../../src/databases/entities/Role'; +import { + randomEmail, + randomValidPassword, + randomName, + randomInvalidPassword, +} from './shared/random'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let workflowOwnerRole: Role; +let credentialOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + const [ + fetchedGlobalOwnerRole, + fetchedGlobalMemberRole, + fetchedWorkflowOwnerRole, + fetchedCredentialOwnerRole, + ] = await testDb.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + workflowOwnerRole = fetchedWorkflowOwnerRole; + credentialOwnerRole = fetchedCredentialOwnerRole; + + utils.initTestTelemetry(); + utils.initTestLogger(); +}); + +beforeEach(async () => { + // do not combine calls - shared tables must be cleared first and separately + await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName); + await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName); + + jest.isolateModules(() => { + jest.mock('../../config'); + }); + + await testDb.createUser({ + id: INITIAL_TEST_USER.id, + email: INITIAL_TEST_USER.email, + password: INITIAL_TEST_USER.password, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + globalRole: globalOwnerRole, + }); + + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', ''); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('GET /users should return all users', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + await testDb.createUser(); + + const response = await authOwnerAgent.get('/users'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + for (const user of response.body.data) { + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + } = user; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBeDefined(); + expect(lastName).toBeDefined(); + expect(personalizationAnswers).toBeUndefined(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole).toBeDefined(); + } +}); + +test('DELETE /users/:id should delete the user', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const userToDelete = await testDb.createUser(); + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + nodes: [], + }); + + const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow); + + await Db.collections.SharedWorkflow!.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + await Db.collections.SharedCredentials!.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const user = await Db.collections.User!.findOne(userToDelete.id); + expect(user).toBeUndefined(); // deleted + + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOne({ + relations: ['user'], + where: { user: userToDelete }, + }); + expect(sharedWorkflow).toBeUndefined(); // deleted + + const sharedCredential = await Db.collections.SharedCredentials!.findOne({ + relations: ['user'], + where: { user: userToDelete }, + }); + expect(sharedCredential).toBeUndefined(); // deleted + + const workflow = await Db.collections.Workflow!.findOne(savedWorkflow.id); + expect(workflow).toBeUndefined(); // deleted + + // TODO: Include active workflow and check whether webhook has been removed + + const credential = await Db.collections.Credentials!.findOne(savedCredential.id); + expect(credential).toBeUndefined(); // deleted +}); + +test('DELETE /users/:id should fail to delete self', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.delete(`/users/${owner.id}`); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOne(owner.id); + expect(user).toBeDefined(); +}); + +test('DELETE /users/:id should fail if user to delete is transferee', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { id: idToDelete } = await testDb.createUser(); + + const response = await authOwnerAgent.delete(`/users/${idToDelete}`).query({ + transferId: idToDelete, + }); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOne(idToDelete); + expect(user).toBeDefined(); +}); + +test('DELETE /users/:id with transferId should perform transfer', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const userToDelete = await Db.collections.User!.save({ + id: uuid(), + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), + createdAt: new Date(), + updatedAt: new Date(), + globalRole: workflowOwnerRole, + }); + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + nodes: [], + }); + + const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow); + + await Db.collections.SharedWorkflow!.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + await Db.collections.SharedCredentials!.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`).query({ + transferId: owner.id, + }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOneOrFail({ + relations: ['user'], + where: { user: owner }, + }); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user'], + where: { user: owner }, + }); + + const deletedUser = await Db.collections.User!.findOne(userToDelete); + + expect(sharedWorkflow.user.id).toBe(owner.id); + expect(sharedCredential.user.id).toBe(owner.id); + expect(deletedUser).toBeUndefined(); +}); + +test('GET /resolve-signup-token should validate invite token', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { id: inviteeId } = await testDb.createMemberShell(); + + const response = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + data: { + inviter: { + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + }, + }, + }); +}); + +test('GET /resolve-signup-token should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { id: inviteeId } = await testDb.createUser(); + + const first = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }); + + const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); + + const third = await authOwnerAgent.get('/resolve-signup-token').query({ + inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d', + inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d', + }); + + // user is already set up, so call should error + const fourth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + // cause inconsistent DB state + await Db.collections.User!.update(owner.id, { email: '' }); + const fifth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + for (const response of [first, second, third, fourth, fifth]) { + expect(response.statusCode).toBe(400); + } +}); + +test('POST /users/:id should fill out a user shell', async () => { + const authlessAgent = utils.createAgent(app); + + const userToFillOut = await Db.collections.User!.save({ + email: randomEmail(), + globalRole: globalMemberRole, + }); + + const newPassword = randomValidPassword(); + + const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send({ + inviterId: INITIAL_TEST_USER.id, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + password: newPassword, + }); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + password, + resetPasswordToken, + globalRole, + isPending, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBe(INITIAL_TEST_USER.firstName); + expect(lastName).toBe(INITIAL_TEST_USER.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole).toBeDefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const filledOutUser = await Db.collections.User!.findOneOrFail(userToFillOut.id); + expect(filledOutUser.firstName).toBe(INITIAL_TEST_USER.firstName); + expect(filledOutUser.lastName).toBe(INITIAL_TEST_USER.lastName); + expect(filledOutUser.password).not.toBe(newPassword); +}); + +test('POST /users/:id should fail with invalid inputs', async () => { + const authlessAgent = utils.createAgent(app); + + const emailToStore = randomEmail(); + + const userToFillOut = await Db.collections.User!.save({ + email: emailToStore, + globalRole: globalMemberRole, + }); + + for (const invalidPayload of INVALID_FILL_OUT_USER_PAYLOADS) { + const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send(invalidPayload); + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOneOrFail({ where: { email: emailToStore } }); + expect(user.firstName).toBeNull(); + expect(user.lastName).toBeNull(); + expect(user.password).toBeNull(); + } +}); + +test('POST /users/:id should fail with already accepted invite', async () => { + const authlessAgent = utils.createAgent(app); + + const globalMemberRole = await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); + + const shell = await Db.collections.User!.save({ + email: randomEmail(), + password: randomValidPassword(), // simulate accepted invite + globalRole: globalMemberRole, + }); + + const newPassword = randomValidPassword(); + + const response = await authlessAgent.post(`/users/${shell.id}`).send({ + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + password: newPassword, + }); + + expect(response.statusCode).toBe(400); + + const fetchedShell = await Db.collections.User!.findOneOrFail({ where: { email: shell.email } }); + expect(fetchedShell.firstName).toBeNull(); + expect(fetchedShell.lastName).toBeNull(); + + const comparisonResult = await compare(shell.password, newPassword); + expect(comparisonResult).toBe(false); + expect(newPassword).not.toBe(fetchedShell.password); +}); + +test('POST /users should fail if emailing is not set up', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + + expect(response.statusCode).toBe(500); +}); + +test('POST /users should fail if user management is disabled', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.disabled', true); + + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + + expect(response.statusCode).toBe(500); +}); + +test('POST /users should email invites and create user shells', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + const { + user, + pass, + smtp: { host, port, secure }, + } = await utils.getSmtpTestAccount(); + + config.set('userManagement.emails.mode', 'smtp'); + config.set('userManagement.emails.smtp.host', host); + config.set('userManagement.emails.smtp.port', port); + config.set('userManagement.emails.smtp.secure', secure); + config.set('userManagement.emails.smtp.auth.user', user); + config.set('userManagement.emails.smtp.auth.pass', pass); + + const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + + const response = await authOwnerAgent.post('/users').send(payload); + + expect(response.statusCode).toBe(200); + + for (const { + user: { id, email: receivedEmail }, + error, + } of response.body.data) { + expect(validator.isUUID(id)).toBe(true); + expect(TEST_EMAILS_TO_CREATE_USER_SHELLS.some((e) => e === receivedEmail)).toBe(true); + if (error) { + expect(error).toBe('Email could not be sent'); + } + + const user = await Db.collections.User!.findOneOrFail(id); + const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = user; + + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeNull(); + expect(resetPasswordToken).toBeNull(); + } +}); + +test('POST /users should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.emails.mode', 'smtp'); + + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + for (const invalidPayload of invalidPayloads) { + const response = await authOwnerAgent.post('/users').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const users = await Db.collections.User!.find(); + expect(users.length).toBe(1); // DB unaffected + } +}); + +test('POST /users should ignore an empty payload', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authOwnerAgent.post('/users').send([]); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(0); + + const users = await Db.collections.User!.find(); + expect(users.length).toBe(1); +}); + +// TODO: /users/:id/reinvite route tests missing + +// TODO: UserManagementMailer is a singleton - cannot reinstantiate with wrong creds +// test('POST /users should error for wrong SMTP config', async () => { +// const owner = await Db.collections.User!.findOneOrFail(); +// const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + +// config.set('userManagement.emails.mode', 'smtp'); +// config.set('userManagement.emails.smtp.host', 'XYZ'); // break SMTP config + +// const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + +// const response = await authOwnerAgent.post('/users').send(payload); + +// expect(response.statusCode).toBe(500); +// }); + +const INITIAL_TEST_USER = { + id: uuid(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; + +const INVALID_FILL_OUT_USER_PAYLOADS = [ + { + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, +]; + +const TEST_EMAILS_TO_CREATE_USER_SHELLS = [randomEmail(), randomEmail(), randomEmail()]; diff --git a/packages/cli/test/setup.ts b/packages/cli/test/setup.ts new file mode 100644 index 0000000000..fa881e3fd3 --- /dev/null +++ b/packages/cli/test/setup.ts @@ -0,0 +1,34 @@ +import { exec as callbackExec } from 'child_process'; +import { promisify } from 'util'; + +import config = require('../config'); +import { BOOTSTRAP_MYSQL_CONNECTION_NAME } from './integration/shared/constants'; +import { DatabaseType } from '../src'; + +const exec = promisify(callbackExec); + +const dbType = config.get('database.type') as DatabaseType; + +if (dbType === 'mysqldb') { + const username = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + + const passwordSegment = password ? `-p${password}` : ''; + + (async () => { + try { + jest.setTimeout(30000); // 30 seconds for DB initialization + await exec( + `echo "CREATE DATABASE IF NOT EXISTS ${BOOTSTRAP_MYSQL_CONNECTION_NAME}" | mysql -h ${host} -u ${username} ${passwordSegment}; USE ${BOOTSTRAP_MYSQL_CONNECTION_NAME};`, + ); + } catch (error) { + if (error.stderr.includes('Access denied')) { + console.error( + `ERROR: Failed to log into MySQL to create bootstrap DB.\nPlease review your MySQL connection options:\n\thost: "${host}"\n\tusername: "${username}"\n\tpassword: "${password}"\nFix by setting correct values via environment variables.\n\texport DB_MYSQLDB_HOST=value\n\texport DB_MYSQLDB_USERNAME=value\n\texport DB_MYSQLDB_PASSWORD=value`, + ); + process.exit(1); + } + } + })(); +} diff --git a/packages/cli/test/teardown.ts b/packages/cli/test/teardown.ts new file mode 100644 index 0000000000..95fb810e39 --- /dev/null +++ b/packages/cli/test/teardown.ts @@ -0,0 +1,48 @@ +import { createConnection } from 'typeorm'; +import config = require('../config'); +import { exec } from 'child_process'; +import { DatabaseType } from '../src'; +import { getBootstrapMySqlOptions, getBootstrapPostgresOptions } from './integration/shared/testDb'; +import { BOOTSTRAP_MYSQL_CONNECTION_NAME } from './integration/shared/constants'; + +export default async () => { + const dbType = config.get('database.type') as DatabaseType; + + if (dbType === 'postgresdb') { + const bootstrapPostgres = await createConnection(getBootstrapPostgresOptions()); + + const results: { db_name: string }[] = await bootstrapPostgres.query( + 'SELECT datname as db_name FROM pg_database;', + ); + + const promises = results + .filter(({ db_name: dbName }) => dbName.startsWith('pg_') && dbName.endsWith('_n8n_test')) + .map(({ db_name: dbName }) => bootstrapPostgres.query(`DROP DATABASE ${dbName};`)); + + await Promise.all(promises); + + bootstrapPostgres.close(); + } + + if (dbType === 'mysqldb') { + const user = config.get('database.mysqldb.user'); + const password = config.get('database.mysqldb.password'); + const host = config.get('database.mysqldb.host'); + + const bootstrapMySql = await createConnection(getBootstrapMySqlOptions()); + + const results: { Database: string }[] = await bootstrapMySql.query('SHOW DATABASES;'); + + const promises = results + .filter(({ Database: dbName }) => dbName.startsWith('mysql_') && dbName.endsWith('_n8n_test')) + .map(({ Database: dbName }) => bootstrapMySql.query(`DROP DATABASE ${dbName};`)); + + await Promise.all(promises); + + await bootstrapMySql.close(); + + exec( + `echo "DROP DATABASE ${BOOTSTRAP_MYSQL_CONNECTION_NAME}" | mysql -h ${host} -u ${user} -p${password}`, + ); + } +}; diff --git a/packages/cli/test/CredentialsHelper.test.ts b/packages/cli/test/unit/CredentialsHelper.test.ts similarity index 99% rename from packages/cli/test/CredentialsHelper.test.ts rename to packages/cli/test/unit/CredentialsHelper.test.ts index e26131f579..7617630588 100644 --- a/packages/cli/test/CredentialsHelper.test.ts +++ b/packages/cli/test/unit/CredentialsHelper.test.ts @@ -1,4 +1,4 @@ -import { CredentialsHelper, CredentialTypes } from '../src'; +import { CredentialsHelper, CredentialTypes } from '../../src'; import * as Helpers from './Helpers'; import { IAuthenticateBasicAuth, diff --git a/packages/cli/test/Helpers.ts b/packages/cli/test/unit/Helpers.ts similarity index 100% rename from packages/cli/test/Helpers.ts rename to packages/cli/test/unit/Helpers.ts diff --git a/packages/core/package.json b/packages/core/package.json index 0262cb088b..e4d36ef240 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,15 +30,15 @@ "@types/cron": "~1.7.1", "@types/crypto-js": "^4.0.1", "@types/express": "^4.17.6", - "@types/jest": "^26.0.13", + "@types/jest": "^27.4.0", "@types/lodash.get": "^4.4.6", "@types/mime-types": "^2.1.0", "@types/node": "14.17.27", "@types/request-promise-native": "~1.0.15", "@types/uuid": "^8.3.4", - "jest": "^26.4.2", + "jest": "^27.4.7", "source-map-support": "^0.5.9", - "ts-jest": "^26.3.0", + "ts-jest": "^27.1.3", "tslint": "^6.1.2", "typescript": "~4.3.5" }, diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts index fa0ee578c9..518ba1d64e 100644 --- a/packages/core/src/ActiveWebhooks.ts +++ b/packages/core/src/ActiveWebhooks.ts @@ -51,7 +51,7 @@ export class ActiveWebhooks { // check that there is not a webhook already registed with that path/method if (this.webhookUrls[webhookKey] && !webhookData.webhookId) { throw new Error( - `Test-Webhook can not be activated because another one with the same method "${webhookData.httpMethod}" and path "${webhookData.path}" is already active!`, + `The URL path that the "${webhookData.node}" node uses is already taken. Please change it to something else.`, ); } diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index 6f55523372..f40dcb4bf5 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -797,5 +797,6 @@ export function WorkflowExecuteAdditionalData( webhookBaseUrl: 'webhook', webhookWaitingBaseUrl: 'webhook-waiting', webhookTestBaseUrl: 'webhook-test', + userId: '123', }; } diff --git a/packages/design-system/.storybook/font-awesome-icons.js b/packages/design-system/.storybook/font-awesome-icons.js index d839486fc0..8713b3b661 100644 --- a/packages/design-system/.storybook/font-awesome-icons.js +++ b/packages/design-system/.storybook/font-awesome-icons.js @@ -3,171 +3,6 @@ * Editor icons are defined seperately */ import { library } from '@fortawesome/fontawesome-svg-core'; -import { - faAngleDoubleLeft, - faAngleDown, - faAngleRight, - faAngleUp, - faArrowLeft, - faArrowRight, - faAt, - faBook, - faBug, - faCalendar, - faCheck, - faChevronDown, - faChevronUp, - faChevronLeft, - faChevronRight, - faCode, - faCodeBranch, - faCog, - faCogs, - faClone, - faCloud, - faCloudDownloadAlt, - faCopy, - faCut, - faDotCircle, - faEdit, - faEnvelope, - faEye, - faExclamationTriangle, - faExpand, - faExternalLinkAlt, - faExchangeAlt, - faFile, - faFileArchive, - faFileCode, - faFileDownload, - faFileExport, - faFileImport, - faFilePdf, - faFolderOpen, - faGift, - faHdd, - faHome, - faHourglass, - faImage, - faInbox, - faInfo, - faInfoCircle, - faKey, - faMapSigns, - faNetworkWired, - faPause, - faPen, - faPlay, - faPlayCircle, - faPlus, - faPlusCircle, - faQuestion, - faQuestionCircle, - faRedo, - faRss, - faSave, - faSearch, - faSearchMinus, - faSearchPlus, - faServer, - faSignInAlt, - faSlidersH, - faSpinner, - faStop, - faSun, - faSync, - faSyncAlt, - faTable, - faTasks, - faTerminal, - faThLarge, - faTimes, - faTrash, - faUndo, - faUsers, - faClock, -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; +import { fas } from '@fortawesome/free-solid-svg-icons'; -library.add(faAngleDoubleLeft); -library.add(faAngleDown); -library.add(faAngleRight); -library.add(faAngleUp); -library.add(faArrowLeft); -library.add(faArrowRight); -library.add(faAt); -library.add(faBook); -library.add(faBug); -library.add(faCalendar); -library.add(faCheck); -library.add(faChevronDown); -library.add(faChevronUp); -library.add(faChevronLeft); -library.add(faChevronRight); -library.add(faCode); -library.add(faCodeBranch); -library.add(faCog); -library.add(faCogs); -library.add(faClone); -library.add(faCloud); -library.add(faCloudDownloadAlt); -library.add(faCopy); -library.add(faCut); -library.add(faDotCircle); -library.add(faEdit); -library.add(faEnvelope); -library.add(faEye); -library.add(faExclamationTriangle); -library.add(faExpand); -library.add(faExternalLinkAlt); -library.add(faExchangeAlt); -library.add(faFile); -library.add(faFileArchive); -library.add(faFileCode); -library.add(faFileDownload); -library.add(faFileExport); -library.add(faFileImport); -library.add(faFilePdf); -library.add(faFolderOpen); -library.add(faGift); -library.add(faHdd); -library.add(faHome); -library.add(faHourglass); -library.add(faImage); -library.add(faInbox); -library.add(faInfo); -library.add(faInfoCircle); -library.add(faKey); -library.add(faMapSigns); -library.add(faNetworkWired); -library.add(faPause); -library.add(faPen); -library.add(faPlay); -library.add(faPlayCircle); -library.add(faPlus); -library.add(faPlusCircle); -library.add(faQuestion); -library.add(faQuestionCircle); -library.add(faRedo); -library.add(faRss); -library.add(faSave); -library.add(faSearch); -library.add(faSearchMinus); -library.add(faSearchPlus); -library.add(faServer); -library.add(faSignInAlt); -library.add(faSlidersH); -library.add(faSpinner); -library.add(faStop); -library.add(faSun); -library.add(faSync); -library.add(faSyncAlt); -library.add(faTable); -library.add(faTasks); -library.add(faTerminal); -library.add(faThLarge); -library.add(faTimes); -library.add(faTrash); -library.add(faUndo); -library.add(faUsers); -library.add(faClock); +library.add(fas); diff --git a/packages/design-system/.storybook/main.js b/packages/design-system/.storybook/main.js index 9cd8b91c03..bc1d34f6b4 100644 --- a/packages/design-system/.storybook/main.js +++ b/packages/design-system/.storybook/main.js @@ -19,7 +19,9 @@ module.exports = { { loader: 'css-loader', options: { - modules: true, + modules: { + localIdentName: '[path][name]__[local]--[hash:base64:5]', + }, }, }, 'sass-loader', diff --git a/packages/design-system/.storybook/preview.js b/packages/design-system/.storybook/preview.js index e914e03921..3c85a33dba 100644 --- a/packages/design-system/.storybook/preview.js +++ b/packages/design-system/.storybook/preview.js @@ -59,7 +59,7 @@ export const parameters = { }, options: { storySort: { - order: ['Docs', 'Styleguide', 'Atoms'], + order: ['Docs', 'Styleguide', 'Atoms', 'Modules'], }, }, }; diff --git a/packages/design-system/package.json b/packages/design-system/package.json index addcc14f65..83a2b35f1e 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -32,21 +32,17 @@ "core-js": "3.x" }, "devDependencies": { + "@babel/core": "^7.14.6", "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/vue-fontawesome": "^2.0.2", "core-js": "^3.6.5", "element-ui": "~2.15.7", - "storybook-addon-themes": "^6.1.0", - "vue": "^2.6.11", - "vue-class-component": "^7.2.3", - "vue-property-decorator": "^9.1.2", - "@babel/core": "^7.14.6", "@storybook/addon-actions": "^6.3.6", "@storybook/addon-essentials": "^6.3.6", "@storybook/addon-links": "^6.3.6", "@storybook/vue": "^6.3.6", - "@types/jest": "^26.0.13", + "@types/jest": "^27.4.0", "@types/markdown-it": "^12.2.3", "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", @@ -63,6 +59,10 @@ "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-vue": "^7.16.0", "gulp": "^4.0.0", + "gulp-autoprefixer": "^4.0.0", + "gulp-clean-css": "^4.3.0", + "gulp-dart-sass": "^1.0.2", + "node-notifier": ">=8.0.1", "markdown-it": "^12.3.2", "markdown-it-emoji": "^2.0.0", "markdown-it-link-attributes": "^4.0.0", @@ -71,14 +71,15 @@ "sass": "^1.26.5", "sass-loader": "^8.0.2", "storybook-addon-designs": "^6.0.1", - "typescript": "~4.3.5", - "vue-loader": "^15.9.7", - "vue-template-compiler": "^2.6.11", - "gulp-autoprefixer": "^4.0.0", - "gulp-clean-css": "^4.3.0", - "gulp-dart-sass": "^1.0.2", - "node-notifier": ">=8.0.1", + "storybook-addon-themes": "^6.1.0", "trim": ">=0.0.3", + "typescript": "~4.3.5", + "vue": "^2.6.11", + "vue-class-component": "^7.2.3", + "vue-loader": "^15.9.7", + "vue-property-decorator": "^9.1.2", + "vue-template-compiler": "^2.6.11", + "vue2-boring-avatars": "0.3.4", "xss": "^1.0.10" } } diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js new file mode 100644 index 0000000000..fa59b17fb3 --- /dev/null +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js @@ -0,0 +1,33 @@ +import N8nActionBox from './ActionBox.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Atoms/ActionBox', + component: N8nActionBox, + argTypes: { + }, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const methods = { + onClick: action('click'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nActionBox, + }, + template: '', + methods, +}); + +export const ActionBox = Template.bind({}); +ActionBox.args = { + emoji: "😿", + heading: "Headline you need to know", + description: "Long description that you should know something is the way it is because of how it is. ", + buttonText: "Do something", +}; diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue new file mode 100644 index 0000000000..4e6c0a5441 --- /dev/null +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/design-system/src/components/N8nActionBox/index.js b/packages/design-system/src/components/N8nActionBox/index.js new file mode 100644 index 0000000000..0842e0d8f7 --- /dev/null +++ b/packages/design-system/src/components/N8nActionBox/index.js @@ -0,0 +1,3 @@ +import N8nActionBox from './ActionBox.vue'; + +export default N8nActionBox; diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js b/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js new file mode 100644 index 0000000000..62ea3289f8 --- /dev/null +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.js @@ -0,0 +1,43 @@ +import N8nActionToggle from './ActionToggle.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Atoms/ActionToggle', + component: N8nActionToggle, + argTypes: { + placement: { + type: 'select', + options: ['top', 'bottom'], + }, + }, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const methods = { + onAction: action('action'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nActionToggle, + }, + template: '
', + methods, +}); + +export const ActionToggle = Template.bind({}); +ActionToggle.args = { + actions: [ + { + label: 'Go', + value: 'go', + }, + { + label: 'Stop', + value: 'stop', + }, + ], +}; diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue new file mode 100644 index 0000000000..c73f324309 --- /dev/null +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/design-system/src/components/N8nActionToggle/index.js b/packages/design-system/src/components/N8nActionToggle/index.js new file mode 100644 index 0000000000..f4174607a0 --- /dev/null +++ b/packages/design-system/src/components/N8nActionToggle/index.js @@ -0,0 +1,3 @@ +import N8nActionToggle from './ActionToggle.vue'; + +export default N8nActionToggle; diff --git a/packages/design-system/src/components/N8nAvatar/Avatar.stories.js b/packages/design-system/src/components/N8nAvatar/Avatar.stories.js new file mode 100644 index 0000000000..a1d2201275 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/Avatar.stories.js @@ -0,0 +1,25 @@ +import N8nAvatar from './Avatar.vue'; + +export default { + title: 'Atoms/Avatar', + component: N8nAvatar, + argTypes: { + size: { + type: 'select', + options: ['small', 'medium', 'large'], + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nAvatar, + }, + template: '', +}); + +export const Avatar = Template.bind({}); +Avatar.args = { + name: 'Sunny Side', +}; diff --git a/packages/design-system/src/components/N8nAvatar/Avatar.vue b/packages/design-system/src/components/N8nAvatar/Avatar.vue new file mode 100644 index 0000000000..6666155df8 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/Avatar.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/design-system/src/components/N8nAvatar/index.js b/packages/design-system/src/components/N8nAvatar/index.js new file mode 100644 index 0000000000..416e198c54 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/index.js @@ -0,0 +1,3 @@ +import N8nAvatar from './Avatar.vue'; + +export default N8nAvatar; diff --git a/packages/design-system/src/components/N8nBadge/Badge.stories.js b/packages/design-system/src/components/N8nBadge/Badge.stories.js new file mode 100644 index 0000000000..dd73349200 --- /dev/null +++ b/packages/design-system/src/components/N8nBadge/Badge.stories.js @@ -0,0 +1,28 @@ +import N8nBadge from './Badge.vue'; + +export default { + title: 'Atoms/Badge', + component: N8nBadge, + argTypes: { + theme: { + type: 'text', + options: ['default', 'secondary'], + }, + size: { + type: 'select', + options: ['small', 'medium', 'large'], + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nBadge, + }, + template: + 'Badge', +}); + +export const Badge = Template.bind({}); +Badge.args = {}; diff --git a/packages/design-system/src/components/N8nBadge/Badge.vue b/packages/design-system/src/components/N8nBadge/Badge.vue new file mode 100644 index 0000000000..4b49cc65f4 --- /dev/null +++ b/packages/design-system/src/components/N8nBadge/Badge.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/packages/design-system/src/components/N8nBadge/index.js b/packages/design-system/src/components/N8nBadge/index.js new file mode 100644 index 0000000000..c3100fe17d --- /dev/null +++ b/packages/design-system/src/components/N8nBadge/index.js @@ -0,0 +1,3 @@ +import N8nBadge from './Badge.vue'; + +export default N8nBadge; diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index ff8d78b407..8fc7a2c3a8 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -47,18 +47,18 @@ export default { type: String, default: 'primary', validator: (value: string): boolean => - ['primary', 'outline', 'light', 'text'].indexOf(value) !== -1, + ['primary', 'outline', 'light', 'text'].includes(value), }, theme: { type: String, validator: (value: string): boolean => - ['success', 'warning', 'danger'].indexOf(value) !== -1, + ['success', 'warning', 'danger'].includes(value), }, size: { type: String, default: 'medium', validator: (value: string): boolean => - ['mini', 'small', 'medium', 'large', 'xlarge'].indexOf(value) !== -1, + ['mini', 'small', 'medium', 'large', 'xlarge'].includes(value), }, loading: { type: Boolean, @@ -82,7 +82,7 @@ export default { float: { type: String, validator: (value: string): boolean => - ['left', 'right'].indexOf(value) !== -1, + ['left', 'right'].includes(value), }, fullWidth: { type: Boolean, @@ -122,9 +122,7 @@ export default { diff --git a/packages/design-system/src/components/N8nFormBox/index.js b/packages/design-system/src/components/N8nFormBox/index.js new file mode 100644 index 0000000000..a033f23368 --- /dev/null +++ b/packages/design-system/src/components/N8nFormBox/index.js @@ -0,0 +1,3 @@ +import N8nFormBox from './FormBox.vue'; + +export default N8nFormBox; diff --git a/packages/design-system/src/components/N8nFormInput/FormInput.stories.js b/packages/design-system/src/components/N8nFormInput/FormInput.stories.js new file mode 100644 index 0000000000..376d761ca8 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/FormInput.stories.js @@ -0,0 +1,35 @@ +import N8nFormInput from './FormInput.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Modules/FormInput', + component: N8nFormInput, + argTypes: { + }, +}; + +const methods = { + onInput: action('input'), + onFocus: action('focus'), + onChange: action('change'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nFormInput, + }, + template: '', + methods, + data() { + return { + val: '', + }; + }, +}); + +export const FormInput = Template.bind({}); +FormInput.args = { + label: 'Label', + placeholder: 'placeholder', +}; diff --git a/packages/design-system/src/components/N8nFormInput/FormInput.vue b/packages/design-system/src/components/N8nFormInput/FormInput.vue new file mode 100644 index 0000000000..d9e6fefb20 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/FormInput.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/packages/design-system/src/components/N8nFormInput/index.js b/packages/design-system/src/components/N8nFormInput/index.js new file mode 100644 index 0000000000..c243c51f0d --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/index.js @@ -0,0 +1,3 @@ +import N8nFormInput from './FormInput.vue'; + +export default N8nFormInput; diff --git a/packages/design-system/src/components/N8nFormInput/validators.ts b/packages/design-system/src/components/N8nFormInput/validators.ts new file mode 100644 index 0000000000..963e533e55 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInput/validators.ts @@ -0,0 +1,158 @@ + +import { IValidator, RuleGroup } from "../../../../editor-ui/src/Interface"; + +export const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +export const VALIDATORS: { [key: string]: IValidator | RuleGroup } = { + REQUIRED: { + validate: (value: string | number | boolean | null | undefined) => { + if (typeof value === 'string' && !!value.trim()) { + return false; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return false; + } + + return { + messageKey: 'formInput.validator.fieldRequired', + }; + }, + }, + MIN_LENGTH: { + validate: (value: string | number | boolean | null | undefined, config: { minimum: number }) => { + if (typeof value === 'string' && value.length < config.minimum) { + return { + messageKey: 'formInput.validator.minCharactersRequired', + options: config, + }; + } + + return false; + }, + }, + MAX_LENGTH: { + validate: (value: string | number | boolean | null | undefined, config: { maximum: number }) => { + if (typeof value === 'string' && value.length > config.maximum) { + return { + messageKey: 'formInput.validator.maxCharactersRequired', + options: config, + }; + } + + return false; + }, + }, + CONTAINS_NUMBER: { + validate: (value: string | number | boolean | null | undefined, config: { minimum: number }) => { + if (typeof value !== 'string') { + return false; + } + + const numberCount = (value.match(/\d/g) || []).length; + if (numberCount < config.minimum) { + return { + messageKey: 'formInput.validator.numbersRequired', + options: config, + }; + } + + return false; + }, + }, + VALID_EMAIL: { + validate: (value: string | number | boolean | null | undefined) => { + if (!emailRegex.test(String(value).trim().toLowerCase())) { + return { + messageKey: 'formInput.validator.validEmailRequired', + }; + } + + return false; + }, + }, + CONTAINS_UPPERCASE: { + validate: (value: string | number | boolean | null | undefined, config: { minimum: number }) => { + if (typeof value !== 'string') { + return false; + } + + const uppercaseCount = (value.match(/[A-Z]/g) || []).length; + if (uppercaseCount < config.minimum) { + return { + messageKey: 'formInput.validator.uppercaseCharsRequired', + options: config, + }; + } + + return false; + }, + }, + DEFAULT_PASSWORD_RULES: { + rules: [ + { + rules: [ + { name: 'MIN_LENGTH', config: { minimum: 8 } }, + { name: 'CONTAINS_NUMBER', config: { minimum: 1 } }, + { name: 'CONTAINS_UPPERCASE', config: { minimum: 1 } }, + ], + defaultError: { + messageKey: 'formInput.validator.defaultPasswordRequirements', + }, + }, + { name: 'MAX_LENGTH', config: {maximum: 64} }, + ], + }, +}; + +export const getValidationError = ( + value: any, // tslint:disable-line:no-any + validators: { [key: string]: IValidator | RuleGroup }, + validator: IValidator | RuleGroup, + config?: any, // tslint:disable-line:no-any +): ReturnType => { + if (validator.hasOwnProperty('rules')) { + const rules = (validator as RuleGroup).rules; + for (let i = 0; i < rules.length; i++) { + if (rules[i].hasOwnProperty('rules')) { + const error = getValidationError( + value, + validators, + rules[i] as RuleGroup, + config, + ); + + if (error) { + return error; + } + } + + if (rules[i].hasOwnProperty('name') ) { + const rule = rules[i] as {name: string, config?: any}; // tslint:disable-line:no-any + if (!validators[rule.name]) { + continue; + } + + const error = getValidationError( + value, + validators, + validators[rule.name] as IValidator, + rule.config, + ); + if (error && (validator as RuleGroup).defaultError !== undefined) { + // @ts-ignore + return validator.defaultError; + } else if (error) { + return error; + } + } + } + } else if ( + validator.hasOwnProperty('validate') + ) { + return (validator as IValidator).validate(value, config); + } + + return false; +}; diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js new file mode 100644 index 0000000000..e736bec3f0 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.js @@ -0,0 +1,73 @@ +import N8nFormInputs from './FormInputs.vue'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Modules/FormInputs', + component: N8nFormInputs, + argTypes: {}, + parameters: { + backgrounds: { default: '--color-background-light' }, + }, +}; + +const methods = { + onInput: action('input'), + onSubmit: action('submit'), +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nFormInputs, + }, + template: '', + methods, +}); + +export const FormInputs = Template.bind({}); +FormInputs.args = { + inputs: [ + { + name: 'email', + properties: { + label: 'Your Email', + type: 'email', + required: true, + initialValue: 'test@test.com', + }, + }, + { + name: 'password', + properties: { + label: 'Your Password', + type: 'password', + required: true, + }, + }, + { + name: 'nickname', + properties: { + label: 'Your Nickname', + placeholder: 'Monty', + }, + }, + { + name: 'opts', + properties: { + type: 'select', + label: 'Opts', + options: [ + { + label: 'Opt1', + value: 'opt1', + }, + { + label: 'Opt2', + value: 'opt2', + }, + ], + }, + }, + ], +}; + diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue new file mode 100644 index 0000000000..881f76d041 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/packages/design-system/src/components/N8nFormInputs/index.js b/packages/design-system/src/components/N8nFormInputs/index.js new file mode 100644 index 0000000000..0ecba6da91 --- /dev/null +++ b/packages/design-system/src/components/N8nFormInputs/index.js @@ -0,0 +1,3 @@ +import N8nFormInputs from './FormInputs.vue'; + +export default N8nFormInputs; diff --git a/packages/design-system/src/components/N8nHeading/Heading.vue b/packages/design-system/src/components/N8nHeading/Heading.vue index 3a66b3be03..a608fa17d7 100644 --- a/packages/design-system/src/components/N8nHeading/Heading.vue +++ b/packages/design-system/src/components/N8nHeading/Heading.vue @@ -1,5 +1,5 @@ @@ -25,16 +25,23 @@ export default { type: String, validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value), }, + align: { + type: String, + validator: (value: string): boolean => ['right', 'left', 'center'].includes(value), + }, }, methods: { - getClass(props: {size: string, bold: boolean}) { - return `heading-${props.size}${props.bold ? '-bold' : '-regular'}`; + getClasses(props: {size: string, bold: boolean}, $style: any) { + return {[$style[`size-${props.size}`]]: true, [$style.bold]: props.bold, [$style.regular]: !props.bold}; }, getStyles(props: {color: string}) { const styles = {} as any; if (props.color) { styles.color = `var(--color-${props.color})`; } + if (props.align) { + styles['text-align'] = props.align; + } return styles; }, }, @@ -50,79 +57,29 @@ export default { font-weight: var(--font-weight-regular); } -.heading-2xlarge { +.size-2xlarge { font-size: var(--font-size-2xl); line-height: var(--font-line-height-compact); } -.heading-2xlarge-regular { - composes: regular; - composes: heading-2xlarge; -} - -.heading-2xlarge-bold { - composes: bold; - composes: heading-2xlarge; -} - -.heading-xlarge { +.size-xlarge { font-size: var(--font-size-xl); line-height: var(--font-line-height-compact); } -.heading-xlarge-regular { - composes: regular; - composes: heading-xlarge; -} - -.heading-xlarge-bold { - composes: bold; - composes: heading-xlarge; -} - -.heading-large { +.size-large { font-size: var(--font-size-l); line-height: var(--font-line-height-loose); } -.heading-large-regular { - composes: regular; - composes: heading-large; -} - -.heading-large-bold { - composes: bold; - composes: heading-large; -} - -.heading-medium { +.size-medium { font-size: var(--font-size-m); line-height: var(--font-line-height-loose); } -.heading-medium-regular { - composes: regular; - composes: heading-medium; -} - -.heading-medium-bold { - composes: bold; - composes: heading-medium; -} - -.heading-small { +.size-small { font-size: var(--font-size-s); line-height: var(--font-line-height-regular); } -.heading-small-regular { - composes: regular; - composes: heading-small; -} - -.heading-small-bold { - composes: bold; - composes: heading-small; -} - diff --git a/packages/design-system/src/components/N8nIcon/index.d.ts b/packages/design-system/src/components/N8nIcon/index.d.ts deleted file mode 100644 index 62181a33b6..0000000000 --- a/packages/design-system/src/components/N8nIcon/index.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { N8nComponent, N8nComponentSize } from '../component'; - -/** Button Component */ -export declare class N8nIcon extends N8nComponent { - /** icon name, accepts an icon name of font awesome icon component */ - icon: string; - - /** Size of icon */ - size: N8nComponentSize; - - /** Whether icon should be spinning */ - spin: boolean; -} diff --git a/packages/design-system/src/components/N8nIcon/index.js b/packages/design-system/src/components/N8nIcon/index.js index aeb12bb638..56e98507bf 100644 --- a/packages/design-system/src/components/N8nIcon/index.js +++ b/packages/design-system/src/components/N8nIcon/index.js @@ -1,3 +1,3 @@ -import Icon from './Icon.vue'; +import N8nIcon from './Icon.vue'; -export default Icon; +export default N8nIcon; diff --git a/packages/design-system/src/components/N8nInput/Input.stories.js b/packages/design-system/src/components/N8nInput/Input.stories.js index ee8c637f9a..d96e50964a 100644 --- a/packages/design-system/src/components/N8nInput/Input.stories.js +++ b/packages/design-system/src/components/N8nInput/Input.stories.js @@ -8,7 +8,7 @@ export default { argTypes: { type: { control: 'select', - options: ['text', 'textarea'], + options: ['text', 'textarea', 'number', 'password', 'email'], }, placeholder: { control: 'text', @@ -30,8 +30,8 @@ export default { const methods = { onInput: action('input'), - onFocus: action('input'), - onChange: action('input'), + onFocus: action('focus'), + onChange: action('change'), }; const Template = (args, { argTypes }) => ({ diff --git a/packages/design-system/src/components/N8nInput/Input.vue b/packages/design-system/src/components/N8nInput/Input.vue index 50734fcdee..4280bdbd98 100644 --- a/packages/design-system/src/components/N8nInput/Input.vue +++ b/packages/design-system/src/components/N8nInput/Input.vue @@ -5,6 +5,7 @@ :size="$options.methods.getSize(props.size)" :class="$style[$options.methods.getClass(props)]" :ref="data.ref" + :autoComplete="props.autocomplete" v-on="listeners" >