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) && (
<>