diff --git a/package-lock.json b/package-lock.json index 23dc8262..1ec9298d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,8 @@ "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", + "@types/qrcode": "^1.5.5", + "@types/speakeasy": "^2.0.10", "@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1", @@ -58,12 +60,14 @@ "nanoid": "^5.1.5", "next-themes": "^0.4.6", "node-fetch": "^3.3.2", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", + "speakeasy": "^2.0.0", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", @@ -3782,6 +3786,15 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3835,6 +3848,15 @@ "@types/send": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -4400,6 +4422,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4518,6 +4549,12 @@ "dev": true, "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4771,6 +4808,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001727", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", @@ -4829,6 +4875,17 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5062,6 +5119,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5136,6 +5202,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.2.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", @@ -5309,6 +5381,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -5994,6 +6072,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6270,6 +6357,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -7195,6 +7291,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7221,7 +7326,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7265,6 +7369,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7393,6 +7506,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -7616,6 +7746,21 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7797,6 +7942,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7962,6 +8113,18 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/ssh2": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", @@ -8005,6 +8168,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8611,6 +8800,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8621,6 +8816,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8657,6 +8866,12 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -8666,6 +8881,93 @@ "node": ">=18" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index b016dcb0..bfb57933 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", + "@types/qrcode": "^1.5.5", + "@types/speakeasy": "^2.0.10", "@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1", @@ -62,12 +64,14 @@ "nanoid": "^5.1.5", "next-themes": "^0.4.6", "node-fetch": "^3.3.2", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", + "speakeasy": "^2.0.0", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", diff --git a/src/App.tsx b/src/App.tsx index fb3e1525..4dccd4df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx" import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx" import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; import { AdminSettings } from "@/ui/Admin/AdminSettings"; +import { UserProfile } from "@/ui/UserProfile"; import { Toaster } from "@/components/ui/sonner"; import { getUserInfo } from "@/ui/main-axios.ts"; @@ -86,6 +87,7 @@ function AppContent() { const showHome = currentTabData?.type === 'home'; const showSshManager = currentTabData?.type === 'ssh_manager'; const showAdmin = currentTabData?.type === 'admin'; + const showProfile = currentTabData?.type === 'profile'; return (
@@ -187,6 +189,20 @@ function AppContent() {
+
+ +
+ )} diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 186b8ff4..71ed1e22 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -411,6 +411,11 @@ const migrateSchema = () => { addColumnIfNotExists('users', 'identifier_path', 'TEXT'); addColumnIfNotExists('users', 'name_path', 'TEXT'); addColumnIfNotExists('users', 'scopes', 'TEXT'); + + // Add TOTP columns + addColumnIfNotExists('users', 'totp_secret', 'TEXT'); + addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0'); + addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT'); addColumnIfNotExists('ssh_data', 'name', 'TEXT'); addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 8d358993..81300eea 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -17,6 +17,10 @@ export const users = sqliteTable('users', { identifier_path: text('identifier_path'), name_path: text('name_path'), scopes: text().default("openid email profile"), + + totp_secret: text('totp_secret'), + totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false), + totp_backup_codes: text('totp_backup_codes'), }); export const settings = sqliteTable('settings', { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d43aa630..065ebc0b 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -6,6 +6,8 @@ import chalk from 'chalk'; import bcrypt from 'bcryptjs'; import {nanoid} from 'nanoid'; import jwt from 'jsonwebtoken'; +import speakeasy from 'speakeasy'; +import QRCode from 'qrcode'; import type {Request, Response, NextFunction} from 'express'; async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise { @@ -206,6 +208,9 @@ router.post('/create', async (req, res) => { identifier_path: '', name_path: '', scopes: 'openid email profile', + totp_secret: null, + totp_enabled: false, + totp_backup_codes: null, }); logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`); @@ -546,6 +551,17 @@ router.post('/login', async (req, res) => { expiresIn: '50d', }); + if (userRecord.totp_enabled) { + return res.json({ + requires_totp: true, + temp_token: jwt.sign( + {userId: userRecord.id, pending_totp: true}, + jwtSecret, + {expiresIn: '10m'} + ) + }); + } + return res.json({ token, is_admin: !!userRecord.is_admin, @@ -579,7 +595,8 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, - is_oidc: !!user[0].is_oidc + is_oidc: !!user[0].is_oidc, + totp_enabled: !!user[0].totp_enabled }); } catch (err) { logger.error('Failed to get username', err); @@ -929,6 +946,285 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => { } }); +// Route: Verify TOTP during login +// POST /users/totp/verify-login +router.post('/totp/verify-login', async (req, res) => { + const {temp_token, totp_code} = req.body; + + if (!temp_token || !totp_code) { + return res.status(400).json({error: 'Token and TOTP code are required'}); + } + + const jwtSecret = process.env.JWT_SECRET || 'secret'; + + try { + const decoded = jwt.verify(temp_token, jwtSecret) as any; + if (!decoded.pending_totp) { + return res.status(401).json({error: 'Invalid temporary token'}); + } + + const user = await db.select().from(users).where(eq(users.id, decoded.userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled || !userRecord.totp_secret) { + return res.status(400).json({error: 'TOTP not enabled for this user'}); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : []; + const backupIndex = backupCodes.indexOf(totp_code); + + if (backupIndex === -1) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + + backupCodes.splice(backupIndex, 1); + await db.update(users) + .set({totp_backup_codes: JSON.stringify(backupCodes)}) + .where(eq(users.id, userRecord.id)); + } + + const token = jwt.sign({userId: userRecord.id}, jwtSecret, { + expiresIn: '50d', + }); + + return res.json({ + token, + is_admin: !!userRecord.is_admin, + username: userRecord.username + }); + + } catch (err) { + logger.error('TOTP verification failed', err); + return res.status(500).json({error: 'TOTP verification failed'}); + } +}); + +// Route: Setup TOTP +// POST /users/totp/setup +router.post('/totp/setup', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is already enabled'}); + } + + const secret = speakeasy.generateSecret({ + name: `Termix (${userRecord.username})`, + length: 32 + }); + + await db.update(users) + .set({totp_secret: secret.base32}) + .where(eq(users.id, userId)); + + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ''); + + res.json({ + secret: secret.base32, + qr_code: qrCodeUrl + }); + + } catch (err) { + logger.error('Failed to setup TOTP', err); + res.status(500).json({error: 'Failed to setup TOTP'}); + } +}); + +// Route: Enable TOTP +// POST /users/totp/enable +router.post('/totp/enable', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {totp_code} = req.body; + + if (!totp_code) { + return res.status(400).json({error: 'TOTP code is required'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is already enabled'}); + } + + if (!userRecord.totp_secret) { + return res.status(400).json({error: 'TOTP setup not initiated'}); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + + const backupCodes = Array.from({length: 8}, () => + Math.random().toString(36).substring(2, 10).toUpperCase() + ); + + await db.update(users) + .set({ + totp_enabled: true, + totp_backup_codes: JSON.stringify(backupCodes) + }) + .where(eq(users.id, userId)); + + res.json({ + message: 'TOTP enabled successfully', + backup_codes: backupCodes + }); + + } catch (err) { + logger.error('Failed to enable TOTP', err); + res.status(500).json({error: 'Failed to enable TOTP'}); + } +}); + +// Route: Disable TOTP +// POST /users/totp/disable +router.post('/totp/disable', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {password, totp_code} = req.body; + + if (!password && !totp_code) { + return res.status(400).json({error: 'Password or TOTP code is required'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is not enabled'}); + } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({error: 'Incorrect password'}); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + } else { + return res.status(400).json({error: 'Authentication required'}); + } + + await db.update(users) + .set({ + totp_enabled: false, + totp_secret: null, + totp_backup_codes: null + }) + .where(eq(users.id, userId)); + + res.json({message: 'TOTP disabled successfully'}); + + } catch (err) { + logger.error('Failed to disable TOTP', err); + res.status(500).json({error: 'Failed to disable TOTP'}); + } +}); + +// Route: Generate new backup codes +// POST /users/totp/backup-codes +router.post('/totp/backup-codes', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {password, totp_code} = req.body; + + if (!password && !totp_code) { + return res.status(400).json({error: 'Password or TOTP code is required'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is not enabled'}); + } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({error: 'Incorrect password'}); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + } else { + return res.status(400).json({error: 'Authentication required'}); + } + + const backupCodes = Array.from({length: 8}, () => + Math.random().toString(36).substring(2, 10).toUpperCase() + ); + + await db.update(users) + .set({totp_backup_codes: JSON.stringify(backupCodes)}) + .where(eq(users.id, userId)); + + res.json({backup_codes: backupCodes}); + + } catch (err) { + logger.error('Failed to generate backup codes', err); + res.status(500).json({error: 'Failed to generate backup codes'}); + } +}); + // Route: Delete user (admin only) // DELETE /users/delete-user router.delete('/delete-user', authenticateJWT, async (req, res) => { diff --git a/src/ui/Homepage/HomepageAuth.tsx b/src/ui/Homepage/HomepageAuth.tsx index 8504e434..d47d194e 100644 --- a/src/ui/Homepage/HomepageAuth.tsx +++ b/src/ui/Homepage/HomepageAuth.tsx @@ -14,7 +14,8 @@ import { initiatePasswordReset, verifyPasswordResetCode, completePasswordReset, - getOIDCAuthorizeUrl + getOIDCAuthorizeUrl, + verifyTOTPLogin } from "../main-axios.ts"; function setCookie(name: string, value: string, days = 7) { @@ -75,6 +76,11 @@ export function HomepageAuth({ const [tempToken, setTempToken] = useState(""); const [resetLoading, setResetLoading] = useState(false); const [resetSuccess, setResetSuccess] = useState(false); + + const [totpRequired, setTotpRequired] = useState(false); + const [totpCode, setTotpCode] = useState(""); + const [totpTempToken, setTotpTempToken] = useState(""); + const [totpLoading, setTotpLoading] = useState(false); useEffect(() => { setInternalLoggedIn(loggedIn); @@ -147,6 +153,13 @@ export function HomepageAuth({ res = await loginUser(localUsername, password); } + if (res.requires_totp) { + setTotpRequired(true); + setTotpTempToken(res.temp_token); + setLoading(false); + return; + } + if (!res || !res.token) { throw new Error('No token received from login'); } @@ -171,6 +184,9 @@ export function HomepageAuth({ if (tab === "signup") { setSignupConfirmPassword(""); } + setTotpRequired(false); + setTotpCode(""); + setTotpTempToken(""); } catch (err: any) { setError(err?.response?.data?.error || err?.message || "Unknown error"); setInternalLoggedIn(false); @@ -269,6 +285,47 @@ export function HomepageAuth({ setError(null); } + async function handleTOTPVerification() { + if (totpCode.length !== 6) { + setError("Please enter a 6-digit code"); + return; + } + + setError(null); + setTotpLoading(true); + + try { + const res = await verifyTOTPLogin(totpTempToken, totpCode); + + if (!res || !res.token) { + throw new Error('No token received from TOTP verification'); + } + + setCookie("jwt", res.token); + const meRes = await getUserInfo(); + + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null + }); + setInternalLoggedIn(true); + setTotpRequired(false); + setTotpCode(""); + setTotpTempToken(""); + } catch (err: any) { + setError(err?.response?.data?.error || err?.message || "Invalid TOTP code"); + } finally { + setTotpLoading(false); + } + } + async function handleOIDCLogin() { setError(null); setOidcLoading(true); @@ -381,7 +438,65 @@ export function HomepageAuth({ )} - {(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && ( + {totpRequired && ( +
+
+

Two-Factor Authentication

+

Enter the 6-digit code from your authenticator app

+
+ +
+ + setTotpCode(e.target.value.replace(/\D/g, ''))} + disabled={totpLoading} + className="text-center text-2xl tracking-widest font-mono" + autoComplete="one-time-code" + /> +

+ Or enter a backup code if you don't have access to your authenticator +

+
+ + + + + + {error && ( + + Error + {error} + + )} +
+ )} + + {(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && ( <>
+ + + +

+ Generate new backup codes if you've lost your existing ones +

+ +
+ + setPassword(e.target.value)} + /> +

Or

+ setDisableCode(e.target.value.replace(/\D/g, ''))} + /> +
+ + + + {backupCodes.length > 0 && ( +
+
+ + +
+
+ {backupCodes.map((code, i) => ( +
{code}
+ ))} +
+
+ )} +
+ + + {error && ( + + + Error + {error} + + )} + + + ); + } + + if (setupStep === "qr") { + return ( + + + Set Up Two-Factor Authentication + + Step 1: Scan the QR code with your authenticator app + + + +
+ TOTP QR Code +
+ +
+ +
+ + +
+

+ If you can't scan the QR code, enter this code manually in your authenticator app +

+
+ + +
+
+ ); + } + + if (setupStep === "verify") { + return ( + + + Verify Your Authenticator + + Step 2: Enter the 6-digit code from your authenticator app + + + +
+ + setVerificationCode(e.target.value.replace(/\D/g, ''))} + className="text-center text-2xl tracking-widest font-mono" + /> +
+ + {error && ( + + + Error + {error} + + )} + +
+ + +
+
+
+ ); + } + + if (setupStep === "backup") { + return ( + + + Save Your Backup Codes + + Step 3: Store these codes in a safe place + + + + + + Important + + Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device. + + + +
+
+ + +
+
+ {backupCodes.map((code, i) => ( +
+ {i + 1}. + {code} +
+ ))} +
+
+ + +
+
+ ); + } + + return ( + + + + + Two-Factor Authentication + + + Add an extra layer of security to your account + + + + + + Not Enabled + + Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in. + + + + + + {error && ( + + + Error + {error} + + )} + + + ); +} \ No newline at end of file diff --git a/src/ui/UserProfile.tsx b/src/ui/UserProfile.tsx new file mode 100644 index 00000000..6132e11f --- /dev/null +++ b/src/ui/UserProfile.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { User, Shield, Key, AlertCircle } from "lucide-react"; +import { TOTPSetup } from "@/ui/TOTPSetup"; +import { getUserInfo } from "@/ui/main-axios"; +import { toast } from "sonner"; + +interface UserProfileProps { + isTopbarOpen?: boolean; +} + +export function UserProfile({ isTopbarOpen = true }: UserProfileProps) { + const [userInfo, setUserInfo] = useState<{ + username: string; + is_admin: boolean; + is_oidc: boolean; + totp_enabled: boolean; + } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchUserInfo(); + }, []); + + const fetchUserInfo = async () => { + setLoading(true); + setError(null); + try { + const info = await getUserInfo(); + setUserInfo({ + username: info.username, + is_admin: info.is_admin, + is_oidc: info.is_oidc, + totp_enabled: info.totp_enabled || false + }); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to load user information"); + } finally { + setLoading(false); + } + }; + + const handleTOTPStatusChange = (enabled: boolean) => { + if (userInfo) { + setUserInfo({ ...userInfo, totp_enabled: enabled }); + } + }; + + if (loading) { + return ( +
+ + +
Loading user profile...
+
+
+
+ ); + } + + if (error || !userInfo) { + return ( +
+ + + Error + {error || "Failed to load user profile"} + +
+ ); + } + + return ( +
+
+

User Profile

+

Manage your account settings and security

+
+ + + + + + Profile + + + + Security + + + + + + + Account Information + Your account details and settings + + +
+
+ +

{userInfo.username}

+
+
+ +

+ {userInfo.is_admin ? "Administrator" : "User"} +

+
+
+ +

+ {userInfo.is_oidc ? "External (OIDC)" : "Local"} +

+
+
+ +

+ {userInfo.totp_enabled ? ( + + + Enabled + + ) : ( + Disabled + )} +

+
+
+
+
+
+ + + + + {!userInfo.is_oidc && ( + + + + + Password + + + Change your account password + + + +

+ Password change functionality can be implemented here +

+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index fc3e3744..99627071 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -892,6 +892,56 @@ export async function updateOIDCConfig(config: any): Promise { // ALERTS // ============================================================================ +export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> { + try { + const response = await authApi.post('/users/totp/setup'); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function enableTOTP(totp_code: string): Promise<{ message: string; backup_codes: string[] }> { + try { + const response = await authApi.post('/users/totp/enable', { totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function disableTOTP(password?: string, totp_code?: string): Promise<{ message: string }> { + try { + const response = await authApi.post('/users/totp/disable', { password, totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function verifyTOTPLogin(temp_token: string, totp_code: string): Promise { + try { + const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function generateBackupCodes(password?: string, totp_code?: string): Promise<{ backup_codes: string[] }> { + try { + const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> { try { const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');