diff --git a/backend/package-lock.json b/backend/package-lock.json index c743979e6..9ae4b0d08 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -50,6 +50,7 @@ "nanoid": "^3.3.6", "node-cache": "^5.1.2", "nodemailer": "^6.8.0", + "ora": "^5.4.1", "passport": "^0.6.0", "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", @@ -6574,7 +6575,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -7010,6 +7010,39 @@ "node": ">=8" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -7274,7 +7307,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -7380,6 +7412,28 @@ "node": ">=6" } }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", + "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -7430,7 +7484,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -7441,8 +7494,7 @@ "node_modules/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==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-support": { "version": "1.1.3", @@ -7703,6 +7755,25 @@ "node": ">=0.10.0" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8950,7 +9021,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -9400,6 +9470,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9468,6 +9546,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -10410,6 +10499,21 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -10600,7 +10704,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "engines": { "node": ">=6" } @@ -13835,7 +13938,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -13863,6 +13965,28 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -15441,6 +15565,18 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -16088,7 +16224,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -16705,6 +16840,14 @@ "makeerror": "1.0.12" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -22216,7 +22359,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -22549,6 +22691,27 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -22741,7 +22904,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -22811,6 +22973,19 @@ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==" }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", + "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==" + }, "cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -22848,7 +23023,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -22856,8 +23030,7 @@ "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==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "color-support": { "version": "1.1.3", @@ -23057,6 +23230,21 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, + "defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "requires": { + "clone": "^1.0.2" + }, + "dependencies": { + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" + } + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -23984,8 +24172,7 @@ "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "has-proto": { "version": "1.0.1", @@ -24308,6 +24495,11 @@ "is-extglob": "^2.1.1" } }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -24349,6 +24541,11 @@ "which-typed-array": "^1.1.11" } }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==" + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -25091,6 +25288,15 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, "long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -25236,8 +25442,7 @@ "mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, "minimatch": { "version": "3.1.2", @@ -27519,7 +27724,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "requires": { "mimic-fn": "^2.1.0" } @@ -27538,6 +27742,22 @@ "type-check": "^0.4.0" } }, + "ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "requires": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + } + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -28751,6 +28971,15 @@ "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", "dev": true }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -29249,7 +29478,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -29698,6 +29926,14 @@ "makeerror": "1.0.12" } }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "requires": { + "defaults": "^1.0.3" + } + }, "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 9768762e8..0d681a99c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,13 +41,14 @@ "nanoid": "^3.3.6", "node-cache": "^5.1.2", "nodemailer": "^6.8.0", + "ora": "^5.4.1", "passport": "^0.6.0", "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "pg": "^8.11.3", "pino": "^8.16.1", "pino-http": "^8.5.1", - "pg": "^8.11.3", "posthog-node": "^2.6.0", "probot": "^12.3.1", "query-string": "^7.1.3", diff --git a/backend/src/bootstrap.ts b/backend/src/bootstrap.ts new file mode 100644 index 000000000..a1ea148c2 --- /dev/null +++ b/backend/src/bootstrap.ts @@ -0,0 +1,43 @@ +import ora from "ora"; +import nodemailer from "nodemailer"; +import { getSmtpHost, getSmtpPort } from "./config"; +import { logger } from "./utils/logging"; +import mongoose from "mongoose"; +import { redisClient } from "./services/RedisService"; + +type BootstrapOpt = { + transporter: nodemailer.Transporter; +}; + +export const bootstrap = async ({ transporter }: BootstrapOpt) => { + const spinner = ora().start(); + spinner.info("Checking configurations..."); + spinner.info("Testing smtp connection"); + + await transporter + .verify() + .then(async () => { + spinner.succeed("SMTP successfully connected"); + }) + .catch(async (err) => { + spinner.fail(`SMTP - Failed to connect to ${await getSmtpHost()}:${await getSmtpPort()}`); + logger.error(err); + }); + + spinner.info("Testing mongodb connection"); + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + spinner.fail("Mongo DB - Failed to connect"); + } else { + spinner.succeed("Mongodb successfully connected"); + } + + spinner.info("Testing redis connection"); + const redisPing = await redisClient?.ping(); + if (!redisPing) { + spinner.fail("Redis - Failed to connect"); + } else { + spinner.succeed("Redis successfully connected"); + } + + spinner.stop(); +}; diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index c5cb52663..9d6978e9c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -3,99 +3,156 @@ import { GITLAB_URL } from "../variables"; import InfisicalClient from "infisical-node"; export const client = new InfisicalClient({ - token: process.env.INFISICAL_TOKEN!, + token: process.env.INFISICAL_TOKEN! }); export const getPort = async () => (await client.getSecret("PORT")).secretValue || 4000; export const getEncryptionKey = async () => { const secretValue = (await client.getSecret("ENCRYPTION_KEY")).secretValue; return secretValue === "" ? undefined : secretValue; -} +}; export const getRootEncryptionKey = async () => { const secretValue = (await client.getSecret("ROOT_ENCRYPTION_KEY")).secretValue; return secretValue === "" ? undefined : secretValue; -} -export const getInviteOnlySignup = async () => (await client.getSecret("INVITE_ONLY_SIGNUP")).secretValue === "true" -export const getSaltRounds = async () => parseInt((await client.getSecret("SALT_ROUNDS")).secretValue) || 10; -export const getAuthSecret = async () => (await client.getSecret("JWT_AUTH_SECRET")).secretValue ?? (await client.getSecret("AUTH_SECRET")).secretValue; -export const getJwtAuthLifetime = async () => (await client.getSecret("JWT_AUTH_LIFETIME")).secretValue || "10d"; -export const getJwtMfaLifetime = async () => (await client.getSecret("JWT_MFA_LIFETIME")).secretValue || "5m"; -export const getJwtRefreshLifetime = async () => (await client.getSecret("JWT_REFRESH_LIFETIME")).secretValue || "90d"; -export const getJwtServiceSecret = async () => (await client.getSecret("JWT_SERVICE_SECRET")).secretValue; // TODO: deprecate (related to ST V1) -export const getJwtSignupLifetime = async () => (await client.getSecret("JWT_SIGNUP_LIFETIME")).secretValue || "15m"; -export const getJwtProviderAuthLifetime = async () => (await client.getSecret("JWT_PROVIDER_AUTH_LIFETIME")).secretValue || "15m"; +}; +export const getInviteOnlySignup = async () => + (await client.getSecret("INVITE_ONLY_SIGNUP")).secretValue === "true"; +export const getSaltRounds = async () => + parseInt((await client.getSecret("SALT_ROUNDS")).secretValue) || 10; +export const getAuthSecret = async () => + (await client.getSecret("JWT_AUTH_SECRET")).secretValue ?? + (await client.getSecret("AUTH_SECRET")).secretValue; +export const getJwtAuthLifetime = async () => + (await client.getSecret("JWT_AUTH_LIFETIME")).secretValue || "10d"; +export const getJwtMfaLifetime = async () => + (await client.getSecret("JWT_MFA_LIFETIME")).secretValue || "5m"; +export const getJwtRefreshLifetime = async () => + (await client.getSecret("JWT_REFRESH_LIFETIME")).secretValue || "90d"; +export const getJwtServiceSecret = async () => + (await client.getSecret("JWT_SERVICE_SECRET")).secretValue; // TODO: deprecate (related to ST V1) +export const getJwtSignupLifetime = async () => + (await client.getSecret("JWT_SIGNUP_LIFETIME")).secretValue || "15m"; +export const getJwtProviderAuthLifetime = async () => + (await client.getSecret("JWT_PROVIDER_AUTH_LIFETIME")).secretValue || "15m"; export const getMongoURL = async () => (await client.getSecret("MONGO_URL")).secretValue; -export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production"; -export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true; +export const getNodeEnv = async () => + (await client.getSecret("NODE_ENV")).secretValue || "production"; +export const getVerboseErrorOutput = async () => + (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true; export const getLokiHost = async () => (await client.getSecret("LOKI_HOST")).secretValue; export const getClientIdAzure = async () => (await client.getSecret("CLIENT_ID_AZURE")).secretValue; -export const getClientIdHeroku = async () => (await client.getSecret("CLIENT_ID_HEROKU")).secretValue; -export const getClientIdVercel = async () => (await client.getSecret("CLIENT_ID_VERCEL")).secretValue; -export const getClientIdNetlify = async () => (await client.getSecret("CLIENT_ID_NETLIFY")).secretValue; -export const getClientIdGitHub = async () => (await client.getSecret("CLIENT_ID_GITHUB")).secretValue; -export const getClientIdGitLab = async () => (await client.getSecret("CLIENT_ID_GITLAB")).secretValue; -export const getClientIdBitBucket = async () => (await client.getSecret("CLIENT_ID_BITBUCKET")).secretValue; -export const getClientIdGCPSecretManager = async () => (await client.getSecret("CLIENT_ID_GCP_SECRET_MANAGER")).secretValue; -export const getClientSecretAzure = async () => (await client.getSecret("CLIENT_SECRET_AZURE")).secretValue; -export const getClientSecretHeroku = async () => (await client.getSecret("CLIENT_SECRET_HEROKU")).secretValue; -export const getClientSecretVercel = async () => (await client.getSecret("CLIENT_SECRET_VERCEL")).secretValue; -export const getClientSecretNetlify = async () => (await client.getSecret("CLIENT_SECRET_NETLIFY")).secretValue; -export const getClientSecretGitHub = async () => (await client.getSecret("CLIENT_SECRET_GITHUB")).secretValue; -export const getClientSecretGitLab = async () => (await client.getSecret("CLIENT_SECRET_GITLAB")).secretValue; -export const getClientSecretBitBucket = async () => (await client.getSecret("CLIENT_SECRET_BITBUCKET")).secretValue; -export const getClientSecretGCPSecretManager = async () => (await client.getSecret("CLIENT_SECRET_GCP_SECRET_MANAGER")).secretValue; -export const getClientSlugVercel = async () => (await client.getSecret("CLIENT_SLUG_VERCEL")).secretValue; +export const getClientIdHeroku = async () => + (await client.getSecret("CLIENT_ID_HEROKU")).secretValue; +export const getClientIdVercel = async () => + (await client.getSecret("CLIENT_ID_VERCEL")).secretValue; +export const getClientIdNetlify = async () => + (await client.getSecret("CLIENT_ID_NETLIFY")).secretValue; +export const getClientIdGitHub = async () => + (await client.getSecret("CLIENT_ID_GITHUB")).secretValue; +export const getClientIdGitLab = async () => + (await client.getSecret("CLIENT_ID_GITLAB")).secretValue; +export const getClientIdBitBucket = async () => + (await client.getSecret("CLIENT_ID_BITBUCKET")).secretValue; +export const getClientIdGCPSecretManager = async () => + (await client.getSecret("CLIENT_ID_GCP_SECRET_MANAGER")).secretValue; +export const getClientSecretAzure = async () => + (await client.getSecret("CLIENT_SECRET_AZURE")).secretValue; +export const getClientSecretHeroku = async () => + (await client.getSecret("CLIENT_SECRET_HEROKU")).secretValue; +export const getClientSecretVercel = async () => + (await client.getSecret("CLIENT_SECRET_VERCEL")).secretValue; +export const getClientSecretNetlify = async () => + (await client.getSecret("CLIENT_SECRET_NETLIFY")).secretValue; +export const getClientSecretGitHub = async () => + (await client.getSecret("CLIENT_SECRET_GITHUB")).secretValue; +export const getClientSecretGitLab = async () => + (await client.getSecret("CLIENT_SECRET_GITLAB")).secretValue; +export const getClientSecretBitBucket = async () => + (await client.getSecret("CLIENT_SECRET_BITBUCKET")).secretValue; +export const getClientSecretGCPSecretManager = async () => + (await client.getSecret("CLIENT_SECRET_GCP_SECRET_MANAGER")).secretValue; +export const getClientSlugVercel = async () => + (await client.getSecret("CLIENT_SLUG_VERCEL")).secretValue; -export const getClientIdGoogleLogin = async () => (await client.getSecret("CLIENT_ID_GOOGLE_LOGIN")).secretValue; -export const getClientSecretGoogleLogin = async () => (await client.getSecret("CLIENT_SECRET_GOOGLE_LOGIN")).secretValue; -export const getClientIdGitHubLogin = async () => (await client.getSecret("CLIENT_ID_GITHUB_LOGIN")).secretValue; -export const getClientSecretGitHubLogin = async () => (await client.getSecret("CLIENT_SECRET_GITHUB_LOGIN")).secretValue; -export const getClientIdGitLabLogin = async () => (await client.getSecret("CLIENT_ID_GITLAB_LOGIN")).secretValue; -export const getClientSecretGitLabLogin = async () => (await client.getSecret("CLIENT_SECRET_GITLAB_LOGIN")).secretValue; -export const getUrlGitLabLogin = async () => (await client.getSecret("URL_GITLAB_LOGIN")).secretValue || GITLAB_URL; +export const getClientIdGoogleLogin = async () => + (await client.getSecret("CLIENT_ID_GOOGLE_LOGIN")).secretValue; +export const getClientSecretGoogleLogin = async () => + (await client.getSecret("CLIENT_SECRET_GOOGLE_LOGIN")).secretValue; +export const getClientIdGitHubLogin = async () => + (await client.getSecret("CLIENT_ID_GITHUB_LOGIN")).secretValue; +export const getClientSecretGitHubLogin = async () => + (await client.getSecret("CLIENT_SECRET_GITHUB_LOGIN")).secretValue; +export const getClientIdGitLabLogin = async () => + (await client.getSecret("CLIENT_ID_GITLAB_LOGIN")).secretValue; +export const getClientSecretGitLabLogin = async () => + (await client.getSecret("CLIENT_SECRET_GITLAB_LOGIN")).secretValue; +export const getUrlGitLabLogin = async () => + (await client.getSecret("URL_GITLAB_LOGIN")).secretValue || GITLAB_URL; -export const getPostHogHost = async () => (await client.getSecret("POSTHOG_HOST")).secretValue || "https://app.posthog.com"; -export const getPostHogProjectApiKey = async () => (await client.getSecret("POSTHOG_PROJECT_API_KEY")).secretValue || "phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE"; +export const getPostHogHost = async () => + (await client.getSecret("POSTHOG_HOST")).secretValue || "https://app.posthog.com"; +export const getPostHogProjectApiKey = async () => + (await client.getSecret("POSTHOG_PROJECT_API_KEY")).secretValue || + "phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE"; export const getSentryDSN = async () => (await client.getSecret("SENTRY_DSN")).secretValue; export const getSiteURL = async () => (await client.getSecret("SITE_URL")).secretValue; export const getSmtpHost = async () => (await client.getSecret("SMTP_HOST")).secretValue; -export const getSmtpSecure = async () => (await client.getSecret("SMTP_SECURE")).secretValue === "true" || false; -export const getSmtpPort = async () => parseInt((await client.getSecret("SMTP_PORT")).secretValue) || 587; +export const getSmtpSecure = async () => + (await client.getSecret("SMTP_SECURE")).secretValue === "true" || false; +export const getSmtpPort = async () => + parseInt((await client.getSecret("SMTP_PORT")).secretValue) || 587; export const getSmtpUsername = async () => (await client.getSecret("SMTP_USERNAME")).secretValue; export const getSmtpPassword = async () => (await client.getSecret("SMTP_PASSWORD")).secretValue; -export const getSmtpFromAddress = async () => (await client.getSecret("SMTP_FROM_ADDRESS")).secretValue; -export const getSmtpFromName = async () => (await client.getSecret("SMTP_FROM_NAME")).secretValue || "Infisical"; +export const getSmtpFromAddress = async () => + (await client.getSecret("SMTP_FROM_ADDRESS")).secretValue; +export const getSmtpFromName = async () => + (await client.getSecret("SMTP_FROM_NAME")).secretValue || "Infisical"; -export const getSecretScanningWebhookProxy = async () => (await client.getSecret("SECRET_SCANNING_WEBHOOK_PROXY")).secretValue; -export const getSecretScanningWebhookSecret = async () => (await client.getSecret("SECRET_SCANNING_WEBHOOK_SECRET")).secretValue; -export const getSecretScanningGitAppId = async () => (await client.getSecret("SECRET_SCANNING_GIT_APP_ID")).secretValue; -export const getSecretScanningPrivateKey = async () => (await client.getSecret("SECRET_SCANNING_PRIVATE_KEY")).secretValue; +export const getSecretScanningWebhookProxy = async () => + (await client.getSecret("SECRET_SCANNING_WEBHOOK_PROXY")).secretValue; +export const getSecretScanningWebhookSecret = async () => + (await client.getSecret("SECRET_SCANNING_WEBHOOK_SECRET")).secretValue; +export const getSecretScanningGitAppId = async () => + (await client.getSecret("SECRET_SCANNING_GIT_APP_ID")).secretValue; +export const getSecretScanningPrivateKey = async () => + (await client.getSecret("SECRET_SCANNING_PRIVATE_KEY")).secretValue; export const getRedisUrl = async () => (await client.getSecret("REDIS_URL")).secretValue; +export const getIsInfisicalCloud = async () => + (await client.getSecret("INFISICAL_CLOUD")).secretValue === "true"; export const getLicenseKey = async () => { const secretValue = (await client.getSecret("LICENSE_KEY")).secretValue; return secretValue === "" ? undefined : secretValue; -} +}; export const getLicenseServerKey = async () => { const secretValue = (await client.getSecret("LICENSE_SERVER_KEY")).secretValue; return secretValue === "" ? undefined : secretValue; -} -export const getLicenseServerUrl = async () => (await client.getSecret("LICENSE_SERVER_URL")).secretValue || "https://portal.infisical.com"; +}; +export const getLicenseServerUrl = async () => + (await client.getSecret("LICENSE_SERVER_URL")).secretValue || "https://portal.infisical.com"; -export const getTelemetryEnabled = async () => (await client.getSecret("TELEMETRY_ENABLED")).secretValue !== "false" && true; +export const getTelemetryEnabled = async () => + (await client.getSecret("TELEMETRY_ENABLED")).secretValue !== "false" && true; export const getLoopsApiKey = async () => (await client.getSecret("LOOPS_API_KEY")).secretValue; -export const getSmtpConfigured = async () => (await client.getSecret("SMTP_HOST")).secretValue == "" || (await client.getSecret("SMTP_HOST")).secretValue == undefined ? false : true +export const getSmtpConfigured = async () => + (await client.getSecret("SMTP_HOST")).secretValue == "" || + (await client.getSecret("SMTP_HOST")).secretValue == undefined + ? false + : true; export const getHttpsEnabled = async () => { if ((await getNodeEnv()) != "production") { // no https for anything other than prod - return false + return false; } - if ((await client.getSecret("HTTPS_ENABLED")).secretValue == undefined || (await client.getSecret("HTTPS_ENABLED")).secretValue == "") { + if ( + (await client.getSecret("HTTPS_ENABLED")).secretValue == undefined || + (await client.getSecret("HTTPS_ENABLED")).secretValue == "" + ) { // default when no value present - return true + return true; } - return (await client.getSecret("HTTPS_ENABLED")).secretValue === "true" && true -} + return (await client.getSecret("HTTPS_ENABLED")).secretValue === "true" && true; +}; diff --git a/backend/src/config/serverConfig.ts b/backend/src/config/serverConfig.ts new file mode 100644 index 000000000..7f1867d15 --- /dev/null +++ b/backend/src/config/serverConfig.ts @@ -0,0 +1,24 @@ +import { IServerConfig, ServerConfig } from "../models/serverConfig"; + +let serverConfig: IServerConfig; + +export const serverConfigInit = async () => { + const cfg = await ServerConfig.findOne({}); + if (!cfg) { + const cfg = new ServerConfig(); + await cfg.save(); + serverConfig = cfg; + } else { + serverConfig = cfg; + } + return serverConfig; +}; + +export const getServerConfig = () => serverConfig; + +export const updateServerConfig = async (data: Partial) => { + const cfg = await ServerConfig.findByIdAndUpdate(serverConfig._id, data, { new: true }); + if (!cfg) throw new Error("Failed to update server config"); + serverConfig = cfg; + return serverConfig; +}; diff --git a/backend/src/controllers/v1/adminController.ts b/backend/src/controllers/v1/adminController.ts new file mode 100644 index 000000000..004a33a68 --- /dev/null +++ b/backend/src/controllers/v1/adminController.ts @@ -0,0 +1,100 @@ +import { Request, Response } from "express"; +import { getHttpsEnabled } from "../../config"; +import { getServerConfig, updateServerConfig as setServerConfig } from "../../config/serverConfig"; +import { initializeDefaultOrg, issueAuthTokens } from "../../helpers"; +import { validateRequest } from "../../helpers/validation"; +import { User } from "../../models"; +import { TelemetryService } from "../../services"; +import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import * as reqValidator from "../../validation/admin"; + +export const getServerConfigInfo = (_req: Request, res: Response) => { + const config = getServerConfig(); + return res.send({ config }); +}; + +export const updateServerConfig = async (req: Request, res: Response) => { + const { + body: { allowSignUp } + } = await validateRequest(reqValidator.UpdateServerConfigV1, req); + const config = await setServerConfig({ allowSignUp }); + return res.send({ config }); +}; + +export const adminSignUp = async (req: Request, res: Response) => { + const cfg = getServerConfig(); + if (cfg.initialized) throw UnauthorizedRequestError({ message: "Admin has been created" }); + const { + body: { + email, + publicKey, + salt, + lastName, + verifier, + firstName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag + } + } = await validateRequest(reqValidator.SignupV1, req); + let user = await User.findOne({ email }); + if (user) throw BadRequestError({ message: "User already exist" }); + user = new User({ + email, + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + salt, + verifier, + superAdmin: true + }); + await user.save(); + await initializeDefaultOrg({ organizationName: "Admin Org", user }); + + await setServerConfig({ initialized: true }); + + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.realIP, + userAgent: req.headers["user-agent"] ?? "" + }); + + const token = tokens.token; + + const postHogClient = await TelemetryService.getPostHogClient(); + if (postHogClient) { + postHogClient.capture({ + event: "admin initialization", + properties: { + email: user.email, + lastName, + firstName + } + }); + } + + // store (refresh) token in httpOnly cookie + res.cookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: await getHttpsEnabled() + }); + + return res.status(200).send({ + message: "Successfully set up admin account", + user, + token + }); +}; diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index b32debeca..fe171ae86 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -16,6 +16,8 @@ import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImpsController from "./secretImpsController"; +import * as adminController from "./adminController"; + export { authController, botController, @@ -34,5 +36,6 @@ export { workspaceController, secretScanningController, webhookController, - secretImpsController + secretImpsController, + adminController }; diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 4dceb2783..065e0c9c5 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -5,7 +5,6 @@ import { createToken } from "../../helpers/auth"; import { BadRequestError } from "../../utils/errors"; import { getAuthSecret, - getInviteOnlySignup, getJwtSignupLifetime, getSmtpConfigured } from "../../config"; @@ -68,14 +67,12 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { }); } - if (await getInviteOnlySignup()) { - // Only one user can create an account without being invited. The rest need to be invited in order to make an account - const userCount = await User.countDocuments({}); - if (userCount != 0) { - throw BadRequestError({ - message: "New user sign ups are not allowed at this time. You must be invited to sign up." - }); - } + // Only one user can create an account without being invited. The rest need to be invited in order to make an account + const userCount = await User.countDocuments({}); + if (userCount != 0) { + throw BadRequestError({ + message: "New user sign ups are not allowed at this time. You must be invited to sign up." + }); } // verify email diff --git a/backend/src/index.ts b/backend/src/index.ts index df3abb0e3..a9861f1a6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import { import { apiKeyData as v3apiKeyDataRouter } from "./ee/routes/v3"; import { serviceTokenData as v3ServiceTokenDataRouter } from "./ee/routes/v3"; import { + admin as v1AdminRouter, auth as v1AuthRouter, bot as v1BotRouter, integrationAuth as v1IntegrationAuthRouter, @@ -81,6 +82,7 @@ import { healthCheck } from "./routes/status"; import { RouteNotFoundError } from "./utils/errors"; import { requestErrorHandler } from "./middleware/requestErrorHandler"; import { + getMongoURL, getNodeEnv, getPort, getSecretScanningGitAppId, @@ -94,12 +96,16 @@ import { syncSecretsToThirdPartyServices } from "./queues/integrations/syncSecre import { githubPushEventSecretScan } from "./queues/secret-scanning/githubScanPushEvent"; const SmeeClient = require("smee-client"); // eslint-disable-line import path from "path"; +import { serverConfigInit } from "./config/serverConfig"; let handler: null | any = null; const main = async () => { const port = await getPort(); + // initializing the database connection + await DatabaseService.initDatabase(await getMongoURL()); + const serverCfg = await serverConfigInit(); await setup(); await EELicenseService.initGlobalFeatureSet(); @@ -203,6 +209,7 @@ const main = async () => { // v1 routes app.use("/api/v1/signup", v1SignupRouter); app.use("/api/v1/auth", v1AuthRouter); + app.use("/api/v1/admin", v1AdminRouter); app.use("/api/v1/bot", v1BotRouter); app.use("/api/v1/user", v1UserRouter); app.use("/api/v1/user-action", v1UserActionRouter); @@ -271,7 +278,22 @@ const main = async () => { app.use(requestErrorHandler); const server = app.listen(port, async () => { - logger.info(`Server started listening at port ${port}`); + if (!serverCfg.initialized) { + logger.info(`Welcome to Infisical + +Create your Infisical administrator account at: +http://localhost:${port}/admin/signup +`); + } else { + logger.info(`Welcome back! + +To access Infisical Administrator Panel open +http://localhost:${port}/admin + +To access Infisical server +http://localhost:${port} +`); + } }); // await createTestUserForDevelopment(); diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index ded40da81..347519b6e 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -7,17 +7,21 @@ import requireSecretAuth from "./requireSecretAuth"; import requireSecretsAuth from "./requireSecretsAuth"; import requireBlindIndicesEnabled from "./requireBlindIndicesEnabled"; import requireE2EEOff from "./requireE2EEOff"; +import { requireSuperAdminAccess } from "./requireSuperAdminAccess"; import validateRequest from "./validateRequest"; +import { disableSignUpByServerCfg } from "./serverAdmin"; export { - requireAuth, - requireMfaAuth, - requireSignupAuth, - requireWorkspaceAuth, - requireServiceTokenAuth, - requireSecretAuth, - requireSecretsAuth, - requireBlindIndicesEnabled, - requireE2EEOff, - validateRequest, + requireAuth, + requireMfaAuth, + requireSignupAuth, + requireWorkspaceAuth, + requireServiceTokenAuth, + requireSecretAuth, + requireSecretsAuth, + requireBlindIndicesEnabled, + requireE2EEOff, + validateRequest, + requireSuperAdminAccess, + disableSignUpByServerCfg }; diff --git a/backend/src/middleware/requireSuperAdminAccess.ts b/backend/src/middleware/requireSuperAdminAccess.ts new file mode 100644 index 000000000..4445433ff --- /dev/null +++ b/backend/src/middleware/requireSuperAdminAccess.ts @@ -0,0 +1,8 @@ +import { NextFunction, Request, Response } from "express"; +import { UnauthorizedRequestError } from "../utils/errors"; + +export const requireSuperAdminAccess = (req: Request, _res: Response, next: NextFunction) => { + const isSuperAdmin = req.user.superAdmin; + if (!isSuperAdmin) throw UnauthorizedRequestError({ message: "Requires superadmin access" }); + return next(); +}; diff --git a/backend/src/middleware/serverAdmin.ts b/backend/src/middleware/serverAdmin.ts new file mode 100644 index 000000000..d57dbf714 --- /dev/null +++ b/backend/src/middleware/serverAdmin.ts @@ -0,0 +1,9 @@ +import { NextFunction, Request, Response } from "express"; +import { getServerConfig } from "../config/serverConfig"; +import { BadRequestError } from "../utils/errors"; + +export const disableSignUpByServerCfg = (_req: Request, _res: Response, next: NextFunction) => { + const cfg = getServerConfig(); + if (!cfg.allowSignUp) throw BadRequestError({ message: "Signup are disabled" }); + return next(); +}; diff --git a/backend/src/models/serverConfig.ts b/backend/src/models/serverConfig.ts new file mode 100644 index 000000000..13e469bd5 --- /dev/null +++ b/backend/src/models/serverConfig.ts @@ -0,0 +1,25 @@ +import { Schema, Types, model } from "mongoose"; + +export interface IServerConfig { + _id: Types.ObjectId; + initialized: boolean; + allowSignUp: boolean; +} + +const serverConfigSchema = new Schema( + { + initialized: { + type: Boolean, + default: false + }, + allowSignUp: { + type: Boolean, + default: true + } + }, + { + timestamps: true + } +); + +export const ServerConfig = model("ServerConfig", serverConfigSchema); diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 911776a63..a3d73b8a2 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,125 +1,141 @@ import { Document, Schema, Types, model } from "mongoose"; export enum AuthMethod { - EMAIL = "email", - GOOGLE = "google", - GITHUB = "github", - GITLAB = "gitlab", - OKTA_SAML = "okta-saml", - AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml", + EMAIL = "email", + GOOGLE = "google", + GITHUB = "github", + GITLAB = "gitlab", + OKTA_SAML = "okta-saml", + AZURE_SAML = "azure-saml", + JUMPCLOUD_SAML = "jumpcloud-saml" } export interface IUser extends Document { - _id: Types.ObjectId; - authProvider?: AuthMethod; - authMethods: AuthMethod[]; - email: string; - firstName?: string; - lastName?: string; - encryptionVersion: number; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey?: string; - encryptedPrivateKey?: string; - iv?: string; - tag?: string; - salt?: string; - verifier?: string; - isMfaEnabled: boolean; - mfaMethods: boolean; - devices: { - ip: string; - userAgent: string; - }[]; + _id: Types.ObjectId; + authProvider?: AuthMethod; + authMethods: AuthMethod[]; + email: string; + superAdmin?: boolean; + firstName?: string; + lastName?: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey?: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; + salt?: string; + verifier?: string; + isMfaEnabled: boolean; + mfaMethods: boolean; + devices: { + ip: string; + userAgent: string; + }[]; } const userSchema = new Schema( - { - authProvider: { // TODO field: deprecate - type: String, - enum: AuthMethod, - }, - authMethods: { - type: [{ - type: String, - enum: AuthMethod, - }], - default: [AuthMethod.EMAIL], - required: true - }, - email: { - type: String, - required: true, - unique: true, - }, - firstName: { - type: String, - }, - lastName: { - type: String, - }, - encryptionVersion: { - type: Number, - select: false, - default: 1, // to resolve backward-compatibility issues - }, - protectedKey: { // introduced as part of encryption version 2 - type: String, - select: false, - }, - protectedKeyIV: { // introduced as part of encryption version 2 - type: String, - select: false, - }, - protectedKeyTag: { // introduced as part of encryption version 2 - type: String, - select: false, - }, - publicKey: { - type: String, - select: false, - }, - encryptedPrivateKey: { - type: String, - select: false, - }, - iv: { // iv of [encryptedPrivateKey] - type: String, - select: false, - }, - tag: { // tag of [encryptedPrivateKey] - type: String, - select: false, - }, - salt: { - type: String, - select: false, - }, - verifier: { - type: String, - select: false, - }, - isMfaEnabled: { - type: Boolean, - default: false, - }, - mfaMethods: [{ - type: String, - }], - devices: { - type: [{ - ip: String, - userAgent: String, - }], - default: [], - select: false, - }, - }, - { - timestamps: true, - } + { + authProvider: { + // TODO field: deprecate + type: String, + enum: AuthMethod + }, + authMethods: { + type: [ + { + type: String, + enum: AuthMethod + } + ], + default: [AuthMethod.EMAIL], + required: true + }, + email: { + type: String, + required: true, + unique: true + }, + firstName: { + type: String + }, + lastName: { + type: String + }, + encryptionVersion: { + type: Number, + select: false, + default: 1 // to resolve backward-compatibility issues + }, + protectedKey: { + // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyIV: { + // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyTag: { + // introduced as part of encryption version 2 + type: String, + select: false + }, + publicKey: { + type: String, + select: false + }, + encryptedPrivateKey: { + type: String, + select: false + }, + superAdmin: { + type: Boolean + }, + iv: { + // iv of [encryptedPrivateKey] + type: String, + select: false + }, + tag: { + // tag of [encryptedPrivateKey] + type: String, + select: false + }, + salt: { + type: String, + select: false + }, + verifier: { + type: String, + select: false + }, + isMfaEnabled: { + type: Boolean, + default: false + }, + mfaMethods: [ + { + type: String + } + ], + devices: { + type: [ + { + ip: String, + userAgent: String + } + ], + default: [], + select: false + } + }, + { + timestamps: true + } ); -export const User = model("User", userSchema); \ No newline at end of file +export const User = model("User", userSchema); diff --git a/backend/src/routes/v1/admin.ts b/backend/src/routes/v1/admin.ts new file mode 100644 index 000000000..5e7989ce0 --- /dev/null +++ b/backend/src/routes/v1/admin.ts @@ -0,0 +1,20 @@ +import express from "express"; +import { adminController } from "../../controllers/v1"; +const router = express.Router(); +import { requireAuth, requireSuperAdminAccess } from "../../middleware"; +import { AuthMode } from "../../variables"; + +router.get("/config", adminController.getServerConfigInfo); + +router.post("/signup", adminController.adminSignUp); + +router.patch( + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + requireSuperAdminAccess, + adminController.updateServerConfig +); + +export default router; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 2d7bf27ef..c19ead569 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -18,6 +18,7 @@ import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; import secretImps from "./secretImps"; +import admin from "./admin"; export { signup, @@ -39,5 +40,6 @@ export { secretsFolder, webhooks, secretImps, - sso + sso, + admin }; diff --git a/backend/src/routes/v1/signup.ts b/backend/src/routes/v1/signup.ts index f3b5d3adf..e7b92f643 100644 --- a/backend/src/routes/v1/signup.ts +++ b/backend/src/routes/v1/signup.ts @@ -2,18 +2,21 @@ import express from "express"; const router = express.Router(); import { signupController } from "../../controllers/v1"; import { authLimiter } from "../../helpers/rateLimiter"; +import { disableSignUpByServerCfg } from "../../middleware"; // TODO: consider moving to users/v3/signup router.post( // TODO endpoint: consider moving to v3/users/signup/mail "/email/signup", + disableSignUpByServerCfg, authLimiter, signupController.beginEmailSignup ); router.post( "/email/verify", // TODO endpoint: consider moving to v3/users/signup/verify + disableSignUpByServerCfg, authLimiter, signupController.verifyEmailSignup ); diff --git a/backend/src/routes/v2/signup.ts b/backend/src/routes/v2/signup.ts index eb376f56c..8a404cfc2 100644 --- a/backend/src/routes/v2/signup.ts +++ b/backend/src/routes/v2/signup.ts @@ -1,49 +1,51 @@ import express from "express"; const router = express.Router(); import { body } from "express-validator"; -import { requireSignupAuth, validateRequest } from "../../middleware"; +import { disableSignUpByServerCfg, requireSignupAuth, validateRequest } from "../../middleware"; import { signupController } from "../../controllers/v2"; import { authLimiter } from "../../helpers/rateLimiter"; router.post( - "/complete-account/signup", // TODO endpoint: deprecate (moved to v3/signup/complete/account-signup) - authLimiter, - requireSignupAuth, - body("email").exists().isString().trim().notEmpty().isEmail(), - body("firstName").exists().isString().trim().notEmpty(), - body("lastName").exists().isString().trim().notEmpty(), - body("protectedKey").exists().isString().trim().notEmpty(), - body("protectedKeyIV").exists().isString().trim().notEmpty(), - body("protectedKeyTag").exists().isString().trim().notEmpty(), - body("publicKey").exists().isString().trim().notEmpty(), - body("encryptedPrivateKey").exists().isString().trim().notEmpty(), - body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), - body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), - body("salt").exists().isString().trim().notEmpty(), - body("verifier").exists().isString().trim().notEmpty(), - body("organizationName").exists().isString().trim().notEmpty(), - validateRequest, - signupController.completeAccountSignup + "/complete-account/signup", // TODO endpoint: deprecate (moved to v3/signup/complete/account-signup), + disableSignUpByServerCfg, + authLimiter, + requireSignupAuth, + body("email").exists().isString().trim().notEmpty().isEmail(), + body("firstName").exists().isString().trim().notEmpty(), + body("lastName").exists().isString().trim().notEmpty(), + body("protectedKey").exists().isString().trim().notEmpty(), + body("protectedKeyIV").exists().isString().trim().notEmpty(), + body("protectedKeyTag").exists().isString().trim().notEmpty(), + body("publicKey").exists().isString().trim().notEmpty(), + body("encryptedPrivateKey").exists().isString().trim().notEmpty(), + body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), + body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), + body("salt").exists().isString().trim().notEmpty(), + body("verifier").exists().isString().trim().notEmpty(), + body("organizationName").exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountSignup ); router.post( - "/complete-account/invite", // TODO: consider moving to v3/users/new/complete-account/invite - authLimiter, - requireSignupAuth, - body("email").exists().isString().trim().notEmpty().isEmail(), - body("firstName").exists().isString().trim().notEmpty(), - body("lastName").exists().isString().trim().notEmpty(), - body("protectedKey").exists().isString().trim().notEmpty(), - body("protectedKeyIV").exists().isString().trim().notEmpty(), - body("protectedKeyTag").exists().isString().trim().notEmpty(), - body("publicKey").exists().trim().notEmpty(), - body("encryptedPrivateKey").exists().isString().trim().notEmpty(), - body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), - body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), - body("salt").exists().isString().trim().notEmpty(), - body("verifier").exists().isString().trim().notEmpty(), - validateRequest, - signupController.completeAccountInvite + "/complete-account/invite", // TODO: consider moving to v3/users/new/complete-account/invite + disableSignUpByServerCfg, + authLimiter, + requireSignupAuth, + body("email").exists().isString().trim().notEmpty().isEmail(), + body("firstName").exists().isString().trim().notEmpty(), + body("lastName").exists().isString().trim().notEmpty(), + body("protectedKey").exists().isString().trim().notEmpty(), + body("protectedKeyIV").exists().isString().trim().notEmpty(), + body("protectedKeyTag").exists().isString().trim().notEmpty(), + body("publicKey").exists().trim().notEmpty(), + body("encryptedPrivateKey").exists().isString().trim().notEmpty(), + body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), + body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), + body("salt").exists().isString().trim().notEmpty(), + body("verifier").exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountInvite ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v3/signup.ts b/backend/src/routes/v3/signup.ts index 241f5d04c..e2b13a0a2 100644 --- a/backend/src/routes/v3/signup.ts +++ b/backend/src/routes/v3/signup.ts @@ -2,10 +2,11 @@ import express from "express"; const router = express.Router(); import { signupController } from "../../controllers/v3"; import { authLimiter } from "../../helpers/rateLimiter"; -import { validateRequest } from "../../middleware"; +import { disableSignUpByServerCfg, validateRequest } from "../../middleware"; router.post( "/complete-account/signup", // TODO: consider moving endpoint to v3/users/new/complete-account/signup + disableSignUpByServerCfg, authLimiter, validateRequest, signupController.completeAccountSignup diff --git a/backend/src/services/smtp.ts b/backend/src/services/smtp.ts index cf688f801..027b81295 100644 --- a/backend/src/services/smtp.ts +++ b/backend/src/services/smtp.ts @@ -8,30 +8,28 @@ import { SMTP_HOST_ZOHOMAIL } from "../variables"; import SMTPConnection from "nodemailer/lib/smtp-connection"; -import * as Sentry from "@sentry/node"; import { getSmtpHost, getSmtpPassword, getSmtpPort, getSmtpSecure, - getSmtpUsername, + getSmtpUsername } from "../config"; -import { logger } from "../utils/logging"; export const initSmtp = async () => { const mailOpts: SMTPConnection.Options = { host: await getSmtpHost(), - port: await getSmtpPort(), + port: await getSmtpPort() }; if ((await getSmtpUsername()) && (await getSmtpPassword())) { mailOpts.auth = { user: await getSmtpUsername(), - pass: await getSmtpPassword(), + pass: await getSmtpPassword() }; } - if ((await getSmtpSecure()) ? (await getSmtpSecure()) : false) { + if ((await getSmtpSecure()) ? await getSmtpSecure() : false) { switch (await getSmtpHost()) { case SMTP_HOST_SENDGRID: mailOpts.requireTLS = true; @@ -39,38 +37,38 @@ export const initSmtp = async () => { case SMTP_HOST_MAILGUN: mailOpts.requireTLS = true; mailOpts.tls = { - ciphers: "TLSv1.2", - } + ciphers: "TLSv1.2" + }; break; case SMTP_HOST_SOCKETLABS: mailOpts.requireTLS = true; mailOpts.tls = { - ciphers: "TLSv1.2", - } + ciphers: "TLSv1.2" + }; break; case SMTP_HOST_ZOHOMAIL: mailOpts.requireTLS = true; mailOpts.tls = { - ciphers: "TLSv1.2", - } + ciphers: "TLSv1.2" + }; break; case SMTP_HOST_GMAIL: mailOpts.requireTLS = true; mailOpts.tls = { - ciphers: "TLSv1.2", - } + ciphers: "TLSv1.2" + }; break; case SMTP_HOST_OFFICE365: mailOpts.requireTLS = true; mailOpts.tls = { ciphers: "TLSv1.2" - } + }; break; default: if ((await getSmtpHost()).includes("amazonaws.com")) { mailOpts.tls = { - ciphers: "TLSv1.2", - } + ciphers: "TLSv1.2" + }; } else { mailOpts.secure = true; } @@ -79,20 +77,6 @@ export const initSmtp = async () => { } const transporter = nodemailer.createTransport(mailOpts); - transporter - .verify() - .then(async () => { - Sentry.setUser(null); - Sentry.captureMessage("SMTP - Successfully connected"); - logger.info("SMTP - Successfully connected"); - }) - .catch(async (err) => { - Sentry.setUser(null); - Sentry.captureException( - `SMTP - Failed to connect to ${await getSmtpHost()}:${await getSmtpPort()} \n\t${err}` - ); - logger.error(err, `SMTP - Failed to connect to ${await getSmtpHost()}:${await getSmtpPort()}`); - }); return transporter; }; diff --git a/backend/src/utils/authn/passport/helpers.ts b/backend/src/utils/authn/passport/helpers.ts index e8a21c37d..b6e672caa 100644 --- a/backend/src/utils/authn/passport/helpers.ts +++ b/backend/src/utils/authn/passport/helpers.ts @@ -1,62 +1,65 @@ -import { - AuthMethod, - User -} from "../../../models"; +import { AuthMethod, User } from "../../../models"; import { createToken } from "../../../helpers/auth"; import { AuthTokenType } from "../../../variables"; -import { getAuthSecret, getJwtProviderAuthLifetime} from "../../../config"; +import { getAuthSecret, getJwtProviderAuthLifetime } from "../../../config"; +import { getServerConfig } from "../../../config/serverConfig"; interface SSOUserTokenFlowParams { - email: string; - firstName: string; - lastName: string; - authMethod: AuthMethod; - callbackPort?: string; + email: string; + firstName: string; + lastName: string; + authMethod: AuthMethod; + callbackPort?: string; } export const handleSSOUserTokenFlow = async ({ - email, - firstName, - lastName, - authMethod, - callbackPort + email, + firstName, + lastName, + authMethod, + callbackPort }: SSOUserTokenFlowParams) => { - let user = await User.findOne({ - email - }).select("+publicKey"); - - if (!user) { - user = await new User({ - email, - authMethods: [authMethod], - firstName, - lastName - }).save(); - } + let user = await User.findOne({ + email + }).select("+publicKey"); - let isLinkingRequired = false; - if (!user.authMethods.includes(authMethod)) { + const serverCfg = getServerConfig(); + if (!user && !serverCfg.allowSignUp) throw new Error("User signup disabled"); + + if (!user) { + user = await new User({ + email, + authMethods: [authMethod], + firstName, + lastName + }).save(); + } + + let isLinkingRequired = false; + if (!user.authMethods.includes(authMethod)) { isLinkingRequired = true; - } + } - const isUserCompleted = !!user.publicKey; - const providerAuthToken = createToken({ + const isUserCompleted = !!user.publicKey; + const providerAuthToken = createToken({ payload: { - authTokenType: AuthTokenType.PROVIDER_TOKEN, - userId: user._id.toString(), - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - authMethod, - isUserCompleted, - isLinkingRequired, - ...(callbackPort ? { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user._id.toString(), + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + authMethod, + isUserCompleted, + isLinkingRequired, + ...(callbackPort + ? { callbackPort - } : {}) + } + : {}) }, expiresIn: await getJwtProviderAuthLifetime(), - secret: await getAuthSecret(), - }); - - return { isUserCompleted, providerAuthToken }; -} \ No newline at end of file + secret: await getAuthSecret() + }); + + return { isUserCompleted, providerAuthToken }; +}; diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index 319c2605b..21b2a95df 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -2,12 +2,12 @@ import crypto from "crypto"; import { Types } from "mongoose"; import { encryptSymmetric128BitHexKeyUTF8 } from "../crypto"; import { EESecretService } from "../../ee/services"; -import { redisClient } from "../../services/RedisService" -import { - IPType, - ISecretVersion, - Role, - SecretSnapshot, +import { redisClient } from "../../services/RedisService"; +import { + IPType, + ISecretVersion, + Role, + SecretSnapshot, SecretVersion, TrustedIP } from "../../ee/models"; @@ -30,7 +30,7 @@ import { Workspace } from "../../models"; import { generateKeyPair } from "../../utils/crypto"; -import { client, getEncryptionKey, getRootEncryptionKey } from "../../config"; +import { client, getEncryptionKey, getIsInfisicalCloud, getRootEncryptionKey } from "../../config"; import { ADMIN, ALGORITHM_AES_256_GCM, @@ -47,6 +47,7 @@ import { memberProjectPermissions } from "../../ee/services/ProjectRoleService"; import { logger } from "../logging"; +import { getServerConfig, updateServerConfig } from "../../config/serverConfig"; /** * Backfill secrets to ensure that they're all versioned and have @@ -693,7 +694,7 @@ export const backfillUserAuthMethods = async () => { export const backfillPermission = async () => { const lockKey = "backfill_permission_lock"; - const timeout = 900000; // 15 min lock timeout in milliseconds + const timeout = 900000; // 15 min lock timeout in milliseconds const lock = await redisClient?.set(lockKey, 1, "PX", timeout, "NX"); if (lock) { @@ -705,13 +706,16 @@ export const backfillPermission = async () => { $exists: true, $ne: [] }, - role: MEMBER, + role: MEMBER }) .populate<{ workspace: IWorkspace }>("workspace") .lean(); // group memberships that need the same permission set - const roleMap = new Map(); + const roleMap = new Map< + string, + { membershipIds: string[]; permissions: any[]; organizationId: string; workspaceId: string } + >(); for (const membership of memberships) { // get permissions of members except secret permission @@ -729,11 +733,11 @@ export const backfillPermission = async () => { }); // environments that are not listed in deniedPermissions should be set to allowed for both read & and write - membership.workspace.environments.forEach(env => { + membership.workspace.environments.forEach((env) => { if (!secretAccessRule?.[env.slug]) { secretAccessRule[env.slug] = { read: true, write: true }; } - }) + }); const secretPermissions: any = []; Object.entries(secretAccessRule).forEach(([envSlug, { read, write }]) => { @@ -769,21 +773,27 @@ export const backfillPermission = async () => { const value = roleMap.get(key); if (value) { value.membershipIds.push(membership._id.toString()); - value.organizationId = membership.workspace.organization.toString() - value.workspaceId = membership.workspace._id.toString() + value.organizationId = membership.workspace.organization.toString(); + value.workspaceId = membership.workspace._id.toString(); } else { - roleMap.set(key, { membershipIds: [membership._id.toString()], permissions: [...customPermissions, ...secretPermissions], organizationId: membership.workspace.organization.toString(), workspaceId: membership.workspace._id.toString() }); + roleMap.set(key, { + membershipIds: [membership._id.toString()], + permissions: [...customPermissions, ...secretPermissions], + organizationId: membership.workspace.organization.toString(), + workspaceId: membership.workspace._id.toString() + }); } } for (const [key, value] of roleMap.entries()) { - const { membershipIds, permissions, workspaceId, organizationId } = value - const membership_identity = crypto.randomBytes(3).toString("hex") + const { membershipIds, permissions, workspaceId, organizationId } = value; + const membership_identity = crypto.randomBytes(3).toString("hex"); const role = new Role({ name: `Limited [${membership_identity.toUpperCase()}]`, organization: organizationId, workspace: workspaceId, - description: "This role was auto generated by Infisical in effort to migrate your project members to our new permission system", + description: + "This role was auto generated by Infisical in effort to migrate your project members to our new permission system", isOrgRole: false, slug: `custom-role-${membership_identity}`, permissions: permissions @@ -792,7 +802,8 @@ export const backfillPermission = async () => { await role.save(); for (const id of membershipIds) { - await Membership.findByIdAndUpdate(id, { // document db doesn't support update many so we must loop + await Membership.findByIdAndUpdate(id, { + // document db doesn't support update many so we must loop $set: { role: CUSTOM, customRole: role @@ -815,11 +826,9 @@ export const backfillPermission = async () => { ); logger.info("Backfill: Finished converting owner role to member"); - } catch (error) { logger.error(error, "An error occurred when running script [backfillPermission]"); } - } else { logger.info("Could not acquire lock for script [backfillPermission], skipping"); } @@ -838,4 +847,33 @@ export const migrateRoleFromOwnerToAdmin = async () => { ); logger.info("Backfill: Finished converting owner role to member"); -} \ No newline at end of file +}; + +export const migrationAssignSuperadmin = async () => { + const users = await User.find({}).sort({ createdAt: 1 }).limit(2); + const serverCfg = getServerConfig(); + if (serverCfg.initialized) return; + + if (await getIsInfisicalCloud()) { + await updateServerConfig({ initialized: true }); + logger.info("Backfill: Infisical Cloud(initialized)"); + return; + } + + if (users.length) { + let superAdminUserId = ""; + const firstAccount = users?.[0]; + if (firstAccount.email === "test@localhost.local" && users.length === 2) { + superAdminUserId = users?.[1]?._id.toString(); + } else { + superAdminUserId = firstAccount._id.toString(); + } + + if (superAdminUserId) { + const user = await User.findByIdAndUpdate(superAdminUserId, { superAdmin: true }); + await updateServerConfig({ initialized: true }); + logger.info(`Migrated ${user?.email} to superuser`); + } + logger.info("Backfill: Migrated first infisical user to super admin"); + } +}; diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index b9fe39992..e6bdd88a0 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -1,9 +1,8 @@ import * as Sentry from "@sentry/node"; -import { DatabaseService, TelemetryService } from "../../services"; +import { TelemetryService } from "../../services"; import { setTransporter } from "../../helpers/nodemailer"; import { EELicenseService } from "../../ee/services"; import { initSmtp } from "../../services/smtp"; -import { createTestUserForDevelopment } from "../addDevelopmentUser"; // eslint-disable-next-line @typescript-eslint/no-var-requires import { validateEncryptionKeysConfig } from "./validateConfig"; import { @@ -18,14 +17,15 @@ import { backfillServiceTokenMultiScope, backfillTrustedIps, backfillUserAuthMethods, - migrateRoleFromOwnerToAdmin + migrateRoleFromOwnerToAdmin, + migrationAssignSuperadmin } from "./backfillData"; import { reencryptBotOrgKeys, reencryptBotPrivateKeys, reencryptSecretBlindIndexDataSalts } from "./reencryptData"; -import { getMongoURL, getNodeEnv, getRedisUrl, getSentryDSN } from "../../config"; +import { getNodeEnv, getRedisUrl, getSentryDSN } from "../../config"; import { initializeGitHubStrategy, initializeGitLabStrategy, @@ -33,6 +33,7 @@ import { initializeSamlStrategy } from "../authn/passport"; import { logger } from "../logging"; +import { bootstrap } from "../../bootstrap"; /** * Prepare Infisical upon startup. This includes tasks like: @@ -55,14 +56,15 @@ export const setup = async () => { await TelemetryService.logTelemetryMessage(); // initializing SMTP configuration - setTransporter(await initSmtp()); + const transporter = await initSmtp(); + setTransporter(transporter); // initializing global feature set await EELicenseService.initGlobalFeatureSet(); // initializing auth strategies await initializeGoogleStrategy(); - await initializeGitHubStrategy() + await initializeGitHubStrategy(); await initializeGitLabStrategy(); await initializeSamlStrategy(); @@ -71,8 +73,7 @@ export const setup = async () => { // await reencryptBotPrivateKeys(); // await reencryptSecretBlindIndexDataSalts(); - // initializing the database connection - await DatabaseService.initDatabase(await getMongoURL()); + await bootstrap({ transporter }); /** * NOTE: the order in this setup function is critical. @@ -92,7 +93,8 @@ export const setup = async () => { await backfillTrustedIps(); await backfillUserAuthMethods(); // await backfillPermission(); - await migrateRoleFromOwnerToAdmin() + await migrateRoleFromOwnerToAdmin(); + await migrationAssignSuperadmin(); // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY @@ -108,5 +110,7 @@ export const setup = async () => { environment: await getNodeEnv() }); - await createTestUserForDevelopment(); + // akhilmhdh: removed dev account as we have now admin account onboarding flow + // That will be user's first account going forward + // await createTestUserForDevelopment(); }; diff --git a/backend/src/validation/admin.ts b/backend/src/validation/admin.ts new file mode 100644 index 000000000..aee6c88cc --- /dev/null +++ b/backend/src/validation/admin.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const UpdateServerConfigV1 = z.object({ + body: z.object({ + allowSignUp: z.boolean().optional() + }) +}); + +export const SignupV1 = z.object({ + body: z.object({ + email: z.string().email().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + publicKey: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim() + }) +}); diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 9900176e1..8b0487292 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -336,5 +336,9 @@ "step5-invite-team": "Invite your team", "step5-subtitle": "Infisical is meant to be used with your teammates. Invite them to test it out.", "step5-skip": "Skip" + }, + "admin": { + "signup-title": "Admin Sign Up", + "dashboard": "Admin Dashboard" } } diff --git a/frontend/src/components/utilities/checks/OnboardingCheck.ts b/frontend/src/components/utilities/checks/OnboardingCheck.ts index 01d7a2d55..b3abc9b85 100644 --- a/frontend/src/components/utilities/checks/OnboardingCheck.ts +++ b/frontend/src/components/utilities/checks/OnboardingCheck.ts @@ -1,6 +1,7 @@ -import { fetchOrgUsers,fetchUserAction } from "@app/hooks/api/users/queries"; +import { fetchOrgUsers, fetchUserAction } from "@app/hooks/api/users/queries"; interface OnboardingCheckProps { + orgId: string; setTotalOnboardingActionsDone?: (value: number) => void; setHasUserClickedSlack?: (value: boolean) => void; setHasUserClickedIntro?: (value: boolean) => void; @@ -12,6 +13,7 @@ interface OnboardingCheckProps { * This function checks which onboarding steps a user has already finished. */ const onboardingCheck = async ({ + orgId, setTotalOnboardingActionsDone, setHasUserClickedSlack, setHasUserClickedIntro, @@ -19,9 +21,7 @@ const onboardingCheck = async ({ setUsersInOrg }: OnboardingCheckProps) => { let countActions = 0; - const userActionSlack = await fetchUserAction( - "slack_cta_clicked" - ); + const userActionSlack = await fetchUserAction("slack_cta_clicked"); if (userActionSlack) { countActions += 1; @@ -41,9 +41,8 @@ const onboardingCheck = async ({ } if (setHasUserClickedIntro) setHasUserClickedIntro(!!userActionIntro); - const orgId = localStorage.getItem("orgData.id"); const orgUsers = await fetchOrgUsers(orgId || ""); - + if (orgUsers.length > 1) { countActions += 1; } diff --git a/frontend/src/components/utilities/generateBackupPDF.ts b/frontend/src/components/utilities/generateBackupPDF.ts index 3dace44da..bf3b430c6 100644 --- a/frontend/src/components/utilities/generateBackupPDF.ts +++ b/frontend/src/components/utilities/generateBackupPDF.ts @@ -16,7 +16,6 @@ const yyyy = today.getFullYear(); const todayFormatted = `${mm}/${dd}/${yyyy}`; - function createPdfHeader(doc: jsPDF, personalName: string) { doc.setFillColor(255, 255, 255); doc.rect(0, 0, 600, 900, "F"); @@ -92,5 +91,15 @@ function generateBackupPDF({ personalName, personalEmail, generatedKey }: PDFPro doc.save("Infisical Emergency Kit.pdf"); } -export default generateBackupPDF; +/** + * This function generate a pdf with a secret key for a user. + */ +export function generateBackupPDFAsync({ personalName, personalEmail, generatedKey }: PDFProps) { + // eslint-disable-next-line new-cap + const doc = new jsPDF("p", "pt", "a4", true); + createPdfHeader(doc, personalName); + createPdfContent(doc, personalEmail, generatedKey); + return doc.save("Infisical Emergency Kit.pdf", { returnPromise: true }); +} +export default generateBackupPDF; diff --git a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx index f43ee923c..c4759ae01 100644 --- a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx +++ b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx @@ -3,13 +3,15 @@ import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; type Props = { text?: string | string[]; frequency?: number; + className?: string; }; -export const ContentLoader = ({ text, frequency = 2000 }: Props) => { +export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { const [pos, setPos] = useState(0); const isTextArray = Array.isArray(text); useEffect(() => { @@ -23,7 +25,12 @@ export const ContentLoader = ({ text, frequency = 2000 }: Props) => { }, []); return ( -
+
loading animation {text && isTextArray && ( diff --git a/frontend/src/components/v2/Switch/Switch.tsx b/frontend/src/components/v2/Switch/Switch.tsx index c66da2204..a54657fc6 100644 --- a/frontend/src/components/v2/Switch/Switch.tsx +++ b/frontend/src/components/v2/Switch/Switch.tsx @@ -3,7 +3,7 @@ import * as SwitchPrimitive from "@radix-ui/react-switch"; import { twMerge } from "tailwind-merge"; export type SwitchProps = Omit & { - children: ReactNode; + children?: ReactNode; id: string; isChecked?: boolean; isRequired?: boolean; diff --git a/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx b/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx index fb1a21d40..1c1d732e1 100644 --- a/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx +++ b/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx @@ -1,7 +1,5 @@ import { useOrganization, useSubscription } from "@app/context"; -import { - useGetOrgTrialUrl -} from "@app/hooks/api"; +import { useGetOrgTrialUrl } from "@app/hooks/api"; import { Button } from "../Button"; import { Modal, ModalContent } from "../Modal"; @@ -16,38 +14,36 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const { mutateAsync, isLoading } = useGetOrgTrialUrl(); - const link = (subscription && subscription.slug !== null) - ? `/org/${currentOrg?._id}/billing` - : "https://infisical.com/scheduledemo"; - + const link = + subscription && subscription.slug !== null + ? `/org/${currentOrg?._id}/billing` + : "https://infisical.com/scheduledemo"; + const handleUpgradeBtnClick = async () => { try { if (!subscription || !currentOrg) return; - + if (!subscription.has_used_trial) { // direct user to start pro trial - + const url = await mutateAsync({ orgId: currentOrg._id, success_url: window.location.href }); - + window.location.href = url; } else { // direct user to upgrade their plan window.location.href = link; } - } catch (err) { console.error(err); } - } - + }; + return ( - +

{text}

Upgrade and get access to this, as well as to other powerful enhancements. @@ -59,10 +55,10 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele onClick={handleUpgradeBtnClick} className="mr-4" > - {(subscription && !subscription.has_used_trial) ? "Start Pro Free Trial" : "Upgrade Plan"} + {subscription && !subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"} -

- ) -} \ No newline at end of file + ); +}; diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 267d80ccd..67340780d 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -21,7 +21,8 @@ export const publicPaths = [ "/saml-sso", "/login/provider/success", // TODO: change "/login/provider/error", // TODO: change - "/login/sso" + "/login/sso", + "/admin/signup" ]; export const languageMap = { @@ -50,7 +51,8 @@ const plansProd: Mapping = { export const plans = plansProd || plansDev; -export const leaveConfirmDefaultMessage = "Your changes will be lost if you leave the page. Are you sure you want to continue?"; +export const leaveConfirmDefaultMessage = + "Your changes will be lost if you leave the page. Are you sure you want to continue?"; export const secretTagsColors = [ { @@ -115,5 +117,5 @@ export const secretTagsColors = [ rgba: "rgb(255,0,0, 0.8)", name: "Red", selected: false - }, -] \ No newline at end of file + } +]; diff --git a/frontend/src/context/AuthContext/AuthContext.tsx b/frontend/src/context/AuthContext/AuthContext.tsx index 7515e459c..3c615485b 100644 --- a/frontend/src/context/AuthContext/AuthContext.tsx +++ b/frontend/src/context/AuthContext/AuthContext.tsx @@ -18,7 +18,7 @@ type Props = { // Provide a context for whole app to notify user is authorized or not export const AuthProvider = ({ children }: Props): JSX.Element => { const { isLoading } = useGetAuthToken(); - const { pathname, push } = useRouter(); + const { pathname, push, asPath } = useRouter(); const [isReady, setIsReady] = useToggle(false); useEffect(() => { @@ -26,7 +26,7 @@ export const AuthProvider = ({ children }: Props): JSX.Element => { if (!isLoading) { // not a public path and not authenticated kick to login page if (!publicPaths.includes(pathname) && !isLoggedIn()) { - push("/login").then(() => { + push({ pathname: "/login", query: { redirect: asPath } }).then(() => { setIsReady.on(); }); } else { @@ -40,7 +40,12 @@ export const AuthProvider = ({ children }: Props): JSX.Element => { if (isLoading || !isReady) { return (
- infisical loading indicator + infisical loading indicator
); } diff --git a/frontend/src/context/ServerConfigContext/ServerConfigContext.tsx b/frontend/src/context/ServerConfigContext/ServerConfigContext.tsx new file mode 100644 index 000000000..fba1600cc --- /dev/null +++ b/frontend/src/context/ServerConfigContext/ServerConfigContext.tsx @@ -0,0 +1,53 @@ +import { createContext, ReactNode, useContext, useEffect, useMemo } from "react"; +import { useRouter } from "next/router"; + +import { ContentLoader } from "@app/components/v2/ContentLoader"; +import { useGetServerConfig } from "@app/hooks/api"; +import { TServerConfig } from "@app/hooks/api/admin/types"; + +type TServerConfigContext = { + config: TServerConfig; +}; + +const ServerConfigContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const ServerConfigProvider = ({ children }: Props): JSX.Element => { + const router = useRouter(); + const { data, isLoading } = useGetServerConfig(); + + // memorize the workspace details for the context + const value = useMemo(() => { + return { + config: data! + }; + }, [data]); + + useEffect(() => { + if (!isLoading && data && !data.initialized) { + router.push("/admin/signup"); + } + }, [isLoading, data]); + + if (isLoading || (!data?.initialized && router.pathname !== "/admin/signup")) { + return ( +
+ +
+ ); + } + + return {children}; +}; + +export const useServerConfig = () => { + const ctx = useContext(ServerConfigContext); + if (!ctx) { + throw new Error("useServerConfig has to be used within "); + } + + return ctx; +}; diff --git a/frontend/src/context/ServerConfigContext/index.tsx b/frontend/src/context/ServerConfigContext/index.tsx new file mode 100644 index 000000000..aa7e7a4d0 --- /dev/null +++ b/frontend/src/context/ServerConfigContext/index.tsx @@ -0,0 +1 @@ +export { ServerConfigProvider,useServerConfig } from "./ServerConfigContext"; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 35c39f533..11c729080 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -14,6 +14,7 @@ export { ProjectPermissionSub, useProjectPermission } from "./ProjectPermissionContext"; +export { ServerConfigProvider,useServerConfig } from "./ServerConfigContext"; export { SubscriptionProvider, useSubscription } from "./SubscriptionContext"; export { UserProvider, useUser } from "./UserContext"; export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext"; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts new file mode 100644 index 000000000..e1c4301d4 --- /dev/null +++ b/frontend/src/hooks/api/admin/index.ts @@ -0,0 +1,2 @@ +export { useCreateAdminUser, useUpdateServerConfig } from "./mutation"; +export { useGetServerConfig } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts new file mode 100644 index 000000000..ea53ff1db --- /dev/null +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { User } from "../users/types"; +import { adminQueryKeys } from "./queries"; +import { TCreateAdminUserDTO, TServerConfig } from "./types"; + +export const useCreateAdminUser = () => { + const queryClient = useQueryClient(); + + return useMutation<{ user: User; token: string }, {}, TCreateAdminUserDTO>({ + mutationFn: async (opt) => { + const { data } = await apiRequest.post("/api/v1/admin/signup", opt); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(adminQueryKeys.serverConfig()); + } + }); +}; + +export const useUpdateServerConfig = () => { + const queryClient = useQueryClient(); + + return useMutation>({ + mutationFn: async (opt) => { + const { data } = await apiRequest.patch<{ config: TServerConfig }>( + "/api/v1/admin/config", + opt + ); + return data.config; + }, + onSuccess: (data) => { + queryClient.setQueryData(adminQueryKeys.serverConfig(), data); + queryClient.invalidateQueries(adminQueryKeys.serverConfig()); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts new file mode 100644 index 000000000..f64c8bfa9 --- /dev/null +++ b/frontend/src/hooks/api/admin/queries.ts @@ -0,0 +1,34 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TServerConfig } from "./types"; + +export const adminQueryKeys = { + serverConfig: () => ["server-config"] as const +}; + +const fetchServerConfig = async () => { + const { data } = await apiRequest.get<{ config: TServerConfig }>("/api/v1/admin/config"); + return data.config; +}; + +export const useGetServerConfig = ({ + options = {} +}: { + options?: Omit< + UseQueryOptions< + TServerConfig, + unknown, + TServerConfig, + ReturnType + >, + "queryKey" | "queryFn" + >; +} = {}) => + useQuery({ + queryKey: adminQueryKeys.serverConfig(), + queryFn: fetchServerConfig, + ...options, + enabled: options?.enabled ?? true + }); diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts new file mode 100644 index 000000000..72453ad44 --- /dev/null +++ b/frontend/src/hooks/api/admin/types.ts @@ -0,0 +1,19 @@ +export type TServerConfig = { + initialized: boolean; + allowSignUp: boolean; +}; + +export type TCreateAdminUserDTO = { + email: string; + firstName: string; + lastName?: string; + protectedKey: string; + protectedKeyTag: string; + protectedKeyIV: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + publicKey: string; + verifier: string; + salt: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index ea5bb658d..c5e719218 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,4 +1,5 @@ export * from "./apiKeys"; +export * from "./admin" export * from "./auditLogs"; export * from "./auth"; export * from "./bots"; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index c6b790592..3b57e8953 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -14,6 +14,7 @@ export type User = { createdAt: Date; updatedAt: Date; email: string; + superAdmin: boolean; firstName?: string; lastName?: string; authProvider?: AuthMethod; diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx new file mode 100644 index 000000000..ad156d748 --- /dev/null +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -0,0 +1,306 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable no-unexpected-multiline */ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable vars-on-top */ +/* eslint-disable no-var */ +/* eslint-disable func-names */ +// @ts-nocheck + +import { useTranslation } from "react-i18next"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; +import { + faArrowLeft, + faArrowUpRightFromSquare, + faBook, + faEnvelope, + faInfinity, + faInfo, + faMobile, + faPlus, + faQuestion +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; + +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@app/components/v2"; +import { useOrganization, useSubscription, useUser } from "@app/context"; +import { + useGetOrgTrialUrl, + useGetUserAction, + useLogoutUser, + useRegisterUserAction +} from "@app/hooks/api"; + +interface LayoutProps { + children: React.ReactNode; +} + +const supportOptions = [ + [ + , + "Support Forum", + "https://infisical.com/slack" + ], + [ + , + "Read Docs", + "https://infisical.com/docs/documentation/getting-started/introduction" + ], + [ + , + "GitHub Issues", + "https://github.com/Infisical/infisical/issues" + ], + [ + , + "Email Support", + "mailto:support@infisical.com" + ] +]; + +export const AdminLayout = ({ children }: LayoutProps) => { + const router = useRouter(); + const { mutateAsync } = useGetOrgTrialUrl(); + + // eslint-disable-next-line prefer-const + const { currentOrg } = useOrganization(); + + const { user } = useUser(); + const { subscription } = useSubscription(); + const { data: updateClosed } = useGetUserAction("september_update_closed"); + const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION; + + const { t } = useTranslation(); + + const registerUserAction = useRegisterUserAction(); + + const closeUpdate = async () => { + await registerUserAction.mutateAsync("september_update_closed"); + }; + + const logout = useLogoutUser(); + const logOutUser = async () => { + try { + console.log("Logging out..."); + await logout.mutateAsync(); + router.push("/login"); + } catch (error) { + console.error(error); + } + }; + + return ( + <> +
+
+ +
+ {children} +
+
+
+
+ +

+ {` ${t("common.no-mobile")} `} +

+
+ + ); +}; diff --git a/frontend/src/layouts/AdminLayout/index.tsx b/frontend/src/layouts/AdminLayout/index.tsx new file mode 100644 index 000000000..1a136d62f --- /dev/null +++ b/frontend/src/layouts/AdminLayout/index.tsx @@ -0,0 +1 @@ +export { AdminLayout } from "./AdminLayout"; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 0757196c9..5b19b657c 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -399,6 +399,13 @@ export const AppLayout = ({ children }: LayoutProps) => { /> + {user?.superAdmin && ( + + + Admin Panel + + + )}
{!isLoading && loginError && } - {!serverDetails?.inviteOnlySignup ? ( + {config.allowSignUp ? (
@@ -236,7 +236,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: ) : (
)} -
+
Forgot password? Recover your account diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx new file mode 100644 index 000000000..6c7e18740 --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -0,0 +1,64 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; + +import { ContentLoader, Switch, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useOrganization, useServerConfig, useUser } from "@app/context"; +import { useUpdateServerConfig } from "@app/hooks/api"; + +enum TabSections { + Settings = "settings" +} + +export const AdminDashboardPage = () => { + const router = useRouter(); + const data = useServerConfig(); + const { config } = data; + const { user, isLoading: isUserLoading } = useUser(); + const { orgs } = useOrganization(); + const { mutate: updateServerConfig } = useUpdateServerConfig(); + + const isNotAllowed = !user?.superAdmin; + + useEffect(() => { + if (isNotAllowed && !isUserLoading) { + if (orgs?.length) { + localStorage.setItem("orgData.id", orgs?.[0]?._id); + router.push(`/org/${orgs?.[0]?._id}/overview`); + } + } + }, [isNotAllowed, isUserLoading]); + + return ( +
+
+
+

Admin Dashboard

+

Manage your Infisical

+
+
+ {isUserLoading || isNotAllowed ? ( + + ) : ( +
+ + +
+ General +
+
+ +
+ updateServerConfig({ allowSignUp: isChecked })} + /> +
Enable signup or invite
+
+
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/views/admin/DashboardPage/index.tsx b/frontend/src/views/admin/DashboardPage/index.tsx new file mode 100644 index 000000000..1d5f062e6 --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/index.tsx @@ -0,0 +1 @@ +export { AdminDashboardPage } from "./DashboardPage"; diff --git a/frontend/src/views/admin/SignUpPage/SignUpPage.tsx b/frontend/src/views/admin/SignUpPage/SignUpPage.tsx new file mode 100644 index 000000000..d3c83fc17 --- /dev/null +++ b/frontend/src/views/admin/SignUpPage/SignUpPage.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence, motion } from "framer-motion"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF"; +// TODO(akhilmhdh): rewrite this into module functions in lib +import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; +import { useServerConfig } from "@app/context"; +import { useCreateAdminUser } from "@app/hooks/api"; +import { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto"; +import { isLoggedIn } from "@app/reactQuery"; + +import { DownloadBackupKeys } from "./components/DownloadBackupKeys"; + +const formSchema = z + .object({ + email: z.string().email().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + password: z.string().trim().min(14).max(100), + confirmPassword: z.string().trim() + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Password don't match", + path: ["confirmPassword"] + }); + +type TFormSchema = z.infer; + +enum SignupSteps { + DetailsForm = "details-form", + BackupKey = "backup-key" +} + +export const SignUpPage = () => { + const router = useRouter(); + const { + control, + handleSubmit, + getValues, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema) + }); + const { createNotification } = useNotificationContext(); + const [step, setStep] = useState(SignupSteps.DetailsForm); + + const { config } = useServerConfig(); + + useEffect(() => { + if (config?.initialized) { + if (isLoggedIn()) { + router.push("/admin"); + } else { + router.push("/login"); + } + } + }, []); + + const { mutateAsync: createAdminUser } = useCreateAdminUser(); + + const handleFormSubmit = async ({ email, password, firstName, lastName }: TFormSchema) => { + // avoid multi submission + if (isSubmitting) return; + try { + const { privateKey, ...userPass } = await generateUserPassKey(email, password); + const res = await createAdminUser({ + email, + firstName, + lastName, + ...userPass + }); + SecurityClient.setToken(res.token); + saveTokenToLocalStorage({ + publicKey: userPass.publicKey, + encryptedPrivateKey: userPass.encryptedPrivateKey, + iv: userPass.encryptedPrivateKeyIV, + tag: userPass.encryptedPrivateKeyTag, + privateKey + }); + setStep(SignupSteps.BackupKey); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Faield to create admin" + }); + } + }; + + const handleBackupKeyGenerate = async () => { + try { + const { email, password, firstName, lastName } = getValues(); + const generatedKey = await generateUserBackupKey(email, password); + await generateBackupPDFAsync({ + generatedKey, + personalEmail: email, + personalName: `${firstName} ${lastName}` + }); + router.push("/admin"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Faield to generate backup" + }); + } + }; + + if (config?.initialized && step === SignupSteps.DetailsForm) + return ; + + return ( +
+ + {step === SignupSteps.DetailsForm && ( + +
+ Infisical logo +
Welcome to Infisical
+
Create your first Admin Account
+
+
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ +
+
+ )} + {step === SignupSteps.BackupKey && ( + + + + )} +
+
+ ); +}; diff --git a/frontend/src/views/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx b/frontend/src/views/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx new file mode 100644 index 000000000..d431aae44 --- /dev/null +++ b/frontend/src/views/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +type Props = { + onGenerate: () => Promise; +}; + +export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useToggle(); + + return ( +
+

+ + {t("signup.step4-message")} +

+
+
+ + {t("signup.step4-description1")} {t("signup.step4-description3")} + +
+
+
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/views/admin/SignUpPage/components/DownloadBackupKeys/index.tsx b/frontend/src/views/admin/SignUpPage/components/DownloadBackupKeys/index.tsx new file mode 100644 index 000000000..bbbd9aad9 --- /dev/null +++ b/frontend/src/views/admin/SignUpPage/components/DownloadBackupKeys/index.tsx @@ -0,0 +1 @@ +export { DownloadBackupKeys } from "./DownloadBackupKeys"; diff --git a/frontend/src/views/admin/SignUpPage/index.tsx b/frontend/src/views/admin/SignUpPage/index.tsx new file mode 100644 index 000000000..aed0b2d78 --- /dev/null +++ b/frontend/src/views/admin/SignUpPage/index.tsx @@ -0,0 +1 @@ +export { SignUpPage } from "./SignUpPage";