Started config editor, migrated to one ssh manager for adding hosts.
This commit is contained in:
168
package-lock.json
generated
168
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -26,6 +27,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -48,6 +50,7 @@
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -57,6 +60,7 @@
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"validator": "^13.15.15",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
@@ -3423,7 +3427,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -3434,7 +3437,6 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -3460,7 +3462,6 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
|
||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
@@ -3472,7 +3473,6 @@
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
|
||||
"integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -3485,7 +3485,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -3510,7 +3509,6 @@
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
@@ -3520,11 +3518,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
|
||||
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
@@ -3534,14 +3540,12 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -3568,7 +3572,6 @@
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -3579,7 +3582,6 @@
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -4149,6 +4151,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
@@ -4425,6 +4433,12 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
|
||||
@@ -4434,6 +4448,17 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -4612,6 +4637,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||
@@ -6549,6 +6589,79 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
@@ -7500,6 +7613,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -7798,6 +7919,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
@@ -7840,7 +7967,6 @@
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -7958,6 +8084,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.15",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -8126,6 +8261,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -30,6 +31,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -52,6 +54,7 @@
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -61,6 +64,7 @@
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"validator": "^13.15.15",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
|
||||
17
src/App.tsx
17
src/App.tsx
@@ -1,10 +1,11 @@
|
||||
import React from "react"
|
||||
import React, {useEffect} from "react"
|
||||
|
||||
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
|
||||
import {SSH} from "@/apps/SSH/SSH.tsx"
|
||||
import {SSHTunnel} from "@/apps/SSH Tunnel/SSHTunnel.tsx";
|
||||
import {ConfigEditor} from "@/apps/Config Editor/ConfigEditor.tsx";
|
||||
import {SSH} from "@/apps/SSH/Terminal/SSH.tsx"
|
||||
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
|
||||
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
|
||||
import {Tools} from "@/apps/Tools/Tools.tsx";
|
||||
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
|
||||
|
||||
function App() {
|
||||
const [view, setView] = React.useState<string>("homepage")
|
||||
@@ -15,11 +16,15 @@ function App() {
|
||||
return <Homepage
|
||||
onSelectView={setView}
|
||||
/>
|
||||
case "ssh":
|
||||
case "ssh_manager":
|
||||
return <SSHManager
|
||||
onSelectView={setView}
|
||||
/>
|
||||
case "terminal":
|
||||
return <SSH
|
||||
onSelectView={setView}
|
||||
/>
|
||||
case "ssh_tunnel":
|
||||
case "tunnel":
|
||||
return <SSHTunnel
|
||||
onSelectView={setView}
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { HomepageSidebar } from "@/apps/Homepage/HomepageSidebar.tsx";
|
||||
import React, { useState } from "react";
|
||||
import { HomepageAuth } from "@/apps/Homepage/HomepageAuth.tsx";
|
||||
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement {
|
||||
export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<HomepageSidebar onSelectView={onSelectView} disabled={!loggedIn} isAdmin={isAdmin} username={loggedIn ? username : null} />
|
||||
<div className="flex-1 bg-background" />
|
||||
<HomepageSidebar
|
||||
onSelectView={onSelectView}
|
||||
disabled={!loggedIn}
|
||||
isAdmin={isAdmin}
|
||||
username={loggedIn ? username : null}
|
||||
/>
|
||||
<div className="flex-1 bg-background"/>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 flex justify-center items-center z-50"
|
||||
style={{ left: 256 }}
|
||||
style={{left: 256}}
|
||||
>
|
||||
<HomepageAuth setLoggedIn={setLoggedIn} setIsAdmin={setIsAdmin} setUsername={setUsername} />
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,8 +58,6 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
|
||||
}
|
||||
setDbError(null);
|
||||
}).catch(() => {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Computer,
|
||||
Server,
|
||||
File,
|
||||
Hammer, ChevronUp, User2
|
||||
Hammer, ChevronUp, User2, HardDrive
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -16,16 +16,30 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
import Icon from "/public/icon.svg";
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose
|
||||
} from "@/components/ui/sheet";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
import axios from "axios";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Homepage} from "@/apps/Homepage/Homepage.tsx";
|
||||
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx";
|
||||
import {SSH} from "@/apps/SSH/Terminal/SSH.tsx";
|
||||
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
|
||||
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
|
||||
import {Tools} from "@/apps/Tools/Tools.tsx";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -40,22 +54,22 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase =
|
||||
typeof window !== "undefined" && window.location.hostname === "localhost"
|
||||
? "http://localhost:8081/users"
|
||||
: "/users";
|
||||
typeof window !== "undefined" && window.location.hostname === "localhost"
|
||||
? "http://localhost:8081/users"
|
||||
: "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: SidebarProps): React.ReactElement {
|
||||
export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, username}: SidebarProps): React.ReactElement {
|
||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
@@ -72,8 +86,8 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
|
||||
try {
|
||||
await API.patch(
|
||||
"/registration-allowed",
|
||||
{ allowed: checked },
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||
{allowed: checked},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
setAllowRegistration(checked);
|
||||
} catch (e) {
|
||||
@@ -81,103 +95,120 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem key={"SSH"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh")} disabled={disabled}>
|
||||
<Computer />
|
||||
<span>SSH</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"SSH Tunnel"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh_tunnel")} disabled={disabled}>
|
||||
<Server />
|
||||
<span>SSH Tunnel</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Config Editor"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("config_editor")} disabled={disabled}>
|
||||
<File />
|
||||
<span>Config Editor</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Tools"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
|
||||
<Hammer />
|
||||
<span>Tools</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:opacity-90 w-full"
|
||||
style={{ width: '100%' }}
|
||||
disabled={disabled}
|
||||
<div>
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem key={"SSH Manager"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}>
|
||||
<HardDrive/>
|
||||
<span>SSH Manager</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<div className="ml-5">
|
||||
<SidebarMenuItem key={"Terminal"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}>
|
||||
<Computer/>
|
||||
<span>Terminal</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Tunnel"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
|
||||
disabled={disabled}>
|
||||
<Server/>
|
||||
<span>Tunnel</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Config Editor"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
|
||||
disabled={disabled}>
|
||||
<File/>
|
||||
<span>Config Editor</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
<SidebarMenuItem key={"Tools"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
|
||||
<Hammer/>
|
||||
<span>Tools</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:opacity-90 w-full"
|
||||
style={{width: '100%'}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<User2/> {username ? username : 'Signed out'}
|
||||
<ChevronUp className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||
>
|
||||
<User2 /> {username ? username : 'Signed out'}
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||
>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" onSelect={() => setAdminSheetOpen(true)}>
|
||||
<span>Admin Settings</span>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={() => setAdminSheetOpen(true)}>
|
||||
<span>Admin Settings</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={handleLogout}>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" onSelect={handleLogout}>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
||||
{isAdmin && (
|
||||
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
||||
<SheetContent side="left" className="w-[320px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Admin Settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="pt-1 pb-4 px-4 flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle} disabled={regLoading} />
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
<SheetFooter className="px-4 pt-1 pb-4">
|
||||
<Separator className="p-0.25 mt-2 mb-2" />
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
||||
{isAdmin && (
|
||||
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
||||
<SheetContent side="left" className="w-[320px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Admin Settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="pt-1 pb-4 px-4 flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
<SheetFooter className="px-4 pt-1 pb-4">
|
||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar";
|
||||
import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList";
|
||||
import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView";
|
||||
import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor";
|
||||
import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
|
||||
import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
||||
import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
||||
import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
||||
import axios from 'axios';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
|
||||
function getJWT() {
|
||||
return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
@@ -216,11 +216,11 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ConfigCodeEditor
|
||||
<ConfigCodeEditor
|
||||
content={tab.content}
|
||||
fileNameOld={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Card } from '@/components/ui/card.tsx';
|
||||
import { Separator } from '@/components/ui/separator.tsx';
|
||||
import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } from 'lucide-react';
|
||||
|
||||
interface SSHConnection {
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card.tsx';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Star, Trash2, Folder, File, Plus } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
|
||||
import { Input } from '@/components/ui/input.tsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FileItem {
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { X, Home } from 'lucide-react';
|
||||
|
||||
interface ConfigTab {
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { ConfigTabList } from "./ConfigTabList";
|
||||
import { ConfigTabList } from "./ConfigTabList.tsx";
|
||||
|
||||
export function ConfigTopbar(props: any): React.ReactElement {
|
||||
return (
|
||||
92
src/apps/SSH/Manager/SSHManager.tsx
Normal file
92
src/apps/SSH/Manager/SSHManager.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from "react";
|
||||
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
|
||||
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset editingHost when switching to host_viewer
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SSHManagerSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
|
||||
<div className="flex w-screen h-screen overflow-hidden">
|
||||
<div className="w-[256px]" />
|
||||
|
||||
<div className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? "Edit Host" : "Add Host"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SSHManagerHostViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<SSHManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1023
src/apps/SSH/Manager/SSHManagerHostEditor.tsx
Normal file
1023
src/apps/SSH/Manager/SSHManagerHostEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
320
src/apps/SSH/Manager/SSHManagerHostViewer.tsx
Normal file
320
src/apps/SSH/Manager/SSHManagerHostViewer.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios";
|
||||
import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SSHManagerHostViewerProps {
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
}, []);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getSSHHosts();
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hosts:', err);
|
||||
setError('Failed to load hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts(); // Refresh the list
|
||||
} catch (err) {
|
||||
console.error('Failed to delete host:', err);
|
||||
alert('Failed to delete host');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (host: SSHHost) => {
|
||||
if (onEditHost) {
|
||||
onEditHost(host);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort hosts
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = hosts.filter(host => {
|
||||
const searchableText = [
|
||||
host.name || '',
|
||||
host.username,
|
||||
host.ip,
|
||||
host.folder || '',
|
||||
...(host.tags || []),
|
||||
host.authType,
|
||||
host.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: pinned first, then alphabetical by name/username
|
||||
return filtered.sort((a, b) => {
|
||||
// First, sort by pin status (pinned hosts first)
|
||||
if (a.pin && !b.pin) return -1;
|
||||
if (!a.pin && b.pin) return 1;
|
||||
|
||||
// Then sort alphabetically by name or username
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [hosts, searchQuery]);
|
||||
|
||||
// Group hosts by folder
|
||||
const hostsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: SSHHost[] } = {};
|
||||
|
||||
filteredAndSortedHosts.forEach(host => {
|
||||
const folder = host.folder || 'Uncategorized';
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(host);
|
||||
});
|
||||
|
||||
// Sort folders to ensure "Uncategorized" is always first
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Create a new object with sorted folders
|
||||
const sortedGrouped: { [key: string]: SSHHost[] } = {};
|
||||
sortedFolders.forEach(folder => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedHosts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading hosts...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchHosts} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">SSH Hosts</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span className="font-medium">{folder}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.pin && <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{/* Tags */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 6 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5" />
|
||||
Terminal
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5" />
|
||||
Tunnel
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableConfigEditor && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5" />
|
||||
Config
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/apps/SSH/Manager/SSHManagerSidebar.tsx
Normal file
58
src/apps/SSH/Manager/SSHManagerSidebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@/components/ui/button.tsx"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / SSH Manager
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
{/* Sidebar Items */}
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx";
|
||||
import { SSHSidebar } from "@/apps/SSH/Terminal/SSHSidebar.tsx";
|
||||
import { SSHTerminal } from "./SSHTerminal.tsx";
|
||||
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
@@ -52,15 +52,13 @@ import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import Icon from "../../../public/icon.svg";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
|
||||
} from "@/components/ui/popover.tsx";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -637,8 +635,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / SSH
|
||||
Termix / Terminal
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
@@ -1,4 +1,4 @@
|
||||
import {SSHTabList} from "@/apps/SSH/SSHTabList.tsx";
|
||||
import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx";
|
||||
import React from "react";
|
||||
|
||||
interface TerminalTab {
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { SSHTunnelSidebar } from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
|
||||
import { SSHTunnelViewer } from "@/apps/SSH Tunnel/SSHTunnelViewer.tsx";
|
||||
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import axios from "axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Loader2, Edit, Trash2 } from "lucide-react";
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
@@ -48,8 +48,7 @@ import { Input } from "@/components/ui/input.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import axios from "axios";
|
||||
import Icon from "../../../public/icon.svg";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -478,8 +477,7 @@ export const SSHTunnelSidebar = React.forwardRef<{ openEditSheet: (tunnel: any)
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / SSH Tunnel
|
||||
Termix / Tunnel
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { SSHTunnelObject } from "./SSHTunnelObject";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
tunnels: Array<{
|
||||
229
src/apps/SSH/ssh-axios.ts
Normal file
229
src/apps/SSH/ssh-axios.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// SSH Host Management API functions
|
||||
import axios from 'axios';
|
||||
|
||||
interface SSHHostData {
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: 'password' | 'key';
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableConfigEditor?: boolean;
|
||||
defaultPath?: string;
|
||||
tunnelConnections?: any[];
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Determine the base URL based on environment
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
|
||||
|
||||
// Create axios instance with base configuration
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
}
|
||||
|
||||
// Add request interceptor to include JWT token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt'); // Adjust based on your token storage
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Get all SSH hosts
|
||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||
try {
|
||||
const response = await api.get('/ssh/host');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SSH hosts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new SSH host
|
||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
// Prepare the data according to your backend schema
|
||||
const submitData = {
|
||||
name: hostData.name || '',
|
||||
ip: hostData.ip,
|
||||
port: parseInt(hostData.port.toString()) || 22,
|
||||
username: hostData.username,
|
||||
folder: hostData.folder || '',
|
||||
tags: hostData.tags || [], // Array of strings
|
||||
pin: hostData.pin || false,
|
||||
authMethod: hostData.authType, // Backend expects 'authMethod'
|
||||
password: hostData.authType === 'password' ? hostData.password : '',
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
enableTerminal: hostData.enableTerminal !== false, // Default to true
|
||||
enableTunnel: hostData.enableTunnel !== false, // Default to true
|
||||
enableConfigEditor: hostData.enableConfigEditor !== false, // Default to true
|
||||
defaultPath: hostData.defaultPath || '/',
|
||||
tunnelConnections: hostData.tunnelConnections || [], // Array of tunnel objects
|
||||
};
|
||||
|
||||
// If tunnel is disabled, clear tunnel data
|
||||
if (!submitData.enableTunnel) {
|
||||
submitData.tunnelConnections = [];
|
||||
}
|
||||
|
||||
// If config editor is disabled, clear config data
|
||||
if (!submitData.enableConfigEditor) {
|
||||
submitData.defaultPath = '';
|
||||
}
|
||||
|
||||
// Handle file upload for SSH key
|
||||
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add the file
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
// Add all other data as JSON string
|
||||
const dataWithoutFile = { ...submitData };
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
// Submit with FormData
|
||||
const response = await api.post('/ssh/host', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} else {
|
||||
// Submit with JSON
|
||||
const response = await api.post('/ssh/host', submitData);
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing SSH host
|
||||
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
const submitData = {
|
||||
name: hostData.name || '',
|
||||
ip: hostData.ip,
|
||||
port: parseInt(hostData.port.toString()) || 22,
|
||||
username: hostData.username,
|
||||
folder: hostData.folder || '',
|
||||
tags: hostData.tags || [],
|
||||
pin: hostData.pin || false,
|
||||
authMethod: hostData.authType,
|
||||
password: hostData.authType === 'password' ? hostData.password : '',
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableConfigEditor: hostData.enableConfigEditor !== false,
|
||||
defaultPath: hostData.defaultPath || '/',
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
};
|
||||
|
||||
// Handle disabled features
|
||||
if (!submitData.enableTunnel) {
|
||||
submitData.tunnelConnections = [];
|
||||
}
|
||||
if (!submitData.enableConfigEditor) {
|
||||
submitData.defaultPath = '';
|
||||
}
|
||||
|
||||
// Handle file upload for SSH key
|
||||
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = { ...submitData };
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
const response = await api.put(`/ssh/host/${hostId}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} else {
|
||||
const response = await api.put(`/ssh/host/${hostId}`, submitData);
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete SSH host
|
||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await api.delete(`/ssh/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get SSH host by ID
|
||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await api.get(`/ssh/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { api };
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import Icon from "../../../public/icon.svg";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -34,8 +33,7 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / Template
|
||||
Termix / Template
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import Icon from "../../../public/icon.svg";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -34,8 +33,7 @@ export function ToolsSidebar({ onSelectView }: SidebarProps): React.ReactElement
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<img src={Icon} alt="Icon" className="w-6 h-6" />
|
||||
- Termix / Tools
|
||||
Termix / Tools
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
|
||||
@@ -2,21 +2,16 @@ import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import sshTunnelRoutes from './routes/ssh_tunnel.js';
|
||||
import configEditorRoutes from './routes/config_editor.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
|
||||
// CORS for local dev
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// Custom logger (adapted from starter.ts, with a database icon)
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
@@ -51,14 +46,16 @@ app.get('/health', (req, res) => {
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
app.use('/ssh_tunnel', sshTunnelRoutes);
|
||||
app.use('/config_editor', configEditorRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = 8081;
|
||||
app.listen(PORT);
|
||||
app.listen(PORT, () => {
|
||||
logger.success(`Database server started on port ${PORT}`);
|
||||
}).on('error', (err) => {
|
||||
logger.error(`Failed to start database server:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -38,6 +38,7 @@ if (!fs.existsSync(dbDir)) {
|
||||
|
||||
const sqlite = new Database('./db/data/db.sqlite');
|
||||
|
||||
// Create tables using Drizzle schema
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -45,102 +46,92 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
auth_method TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
save_auth_method INTEGER,
|
||||
is_pinned INTEGER,
|
||||
default_path TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
auth_method TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
save_auth_method INTEGER,
|
||||
is_pinned INTEGER,
|
||||
default_path TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_tunnel_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
source_port INTEGER NOT NULL,
|
||||
endpoint_port INTEGER NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
source_ssh_port INTEGER NOT NULL,
|
||||
source_username TEXT,
|
||||
source_password TEXT,
|
||||
source_auth_method TEXT,
|
||||
source_ssh_key TEXT,
|
||||
source_key_password TEXT,
|
||||
source_key_type TEXT,
|
||||
endpoint_ip TEXT NOT NULL,
|
||||
endpoint_ssh_port INTEGER NOT NULL,
|
||||
endpoint_username TEXT,
|
||||
endpoint_password TEXT,
|
||||
endpoint_auth_method TEXT,
|
||||
endpoint_ssh_key TEXT,
|
||||
endpoint_key_password TEXT,
|
||||
endpoint_key_type TEXT,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
retry_interval INTEGER NOT NULL DEFAULT 5000,
|
||||
connection_state TEXT NOT NULL DEFAULT 'DISCONNECTED',
|
||||
auto_start INTEGER NOT NULL DEFAULT 0,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_data (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
path TEXT NOT NULL,
|
||||
server TEXT,
|
||||
last_opened TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_config_editor INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
try {
|
||||
sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get();
|
||||
} catch (e) {
|
||||
sqlite.exec('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;');
|
||||
}
|
||||
|
||||
// Function to safely add a column if it doesn't exist
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
try {
|
||||
// Try to select the column to see if it exists
|
||||
sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
// Column doesn't exist, add it
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-migrate: Add any missing columns based on current schema
|
||||
const migrateSchema = () => {
|
||||
logger.info('Checking for schema updates...');
|
||||
|
||||
// Add missing columns to users table
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Add missing columns to ssh_data table
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_config_editor', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
|
||||
logger.success('Schema migration completed');
|
||||
};
|
||||
|
||||
// Run auto-migration
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(), // Unique user ID (nanoid)
|
||||
@@ -7,87 +8,31 @@ export const users = sqliteTable('users', {
|
||||
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username'),
|
||||
password: text('password'),
|
||||
authMethod: text('auth_method'),
|
||||
key: text('key', { length: 8192 }), // Increased for larger keys
|
||||
keyPassword: text('key_password'), // Password for protected keys
|
||||
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
|
||||
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||
defaultPath: text('default_path'), // Default path for SSH connection
|
||||
});
|
||||
|
||||
export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
folder: text('folder'),
|
||||
sourcePort: integer('source_port').notNull(),
|
||||
endpointPort: integer('endpoint_port').notNull(),
|
||||
sourceIP: text('source_ip').notNull(),
|
||||
sourceSSHPort: integer('source_ssh_port').notNull(),
|
||||
sourceUsername: text('source_username'),
|
||||
sourcePassword: text('source_password'),
|
||||
sourceAuthMethod: text('source_auth_method'),
|
||||
sourceSSHKey: text('source_ssh_key', { length: 8192 }),
|
||||
sourceKeyPassword: text('source_key_password'),
|
||||
sourceKeyType: text('source_key_type'),
|
||||
endpointIP: text('endpoint_ip').notNull(),
|
||||
endpointSSHPort: integer('endpoint_ssh_port').notNull(),
|
||||
endpointUsername: text('endpoint_username'),
|
||||
endpointPassword: text('endpoint_password'),
|
||||
endpointAuthMethod: text('endpoint_auth_method'),
|
||||
endpointSSHKey: text('endpoint_ssh_key', { length: 8192 }),
|
||||
endpointKeyPassword: text('endpoint_key_password'),
|
||||
endpointKeyType: text('endpoint_key_type'),
|
||||
maxRetries: integer('max_retries').notNull().default(3),
|
||||
retryInterval: integer('retry_interval').notNull().default(5000),
|
||||
connectionState: text('connection_state').notNull().default('DISCONNECTED'),
|
||||
autoStart: integer('auto_start', { mode: 'boolean' }).notNull().default(false),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const configEditorData = sqliteTable('config_editor_data', {
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut'
|
||||
name: text('name'),
|
||||
path: text('path').notNull(),
|
||||
server: text('server', { length: 2048 }), // JSON stringified server info (if SSH)
|
||||
lastOpened: text('last_opened'), // ISO string (for recent)
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const configSshData = sqliteTable('config_ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
name: text('name'), // Host name
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username'),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'), // JSON stringified array
|
||||
pin: integer('pin', { mode: 'boolean' }).notNull().default(false),
|
||||
authType: text('auth_type').notNull(), // 'password' | 'key'
|
||||
password: text('password'),
|
||||
authMethod: text('auth_method'),
|
||||
key: text('key', { length: 8192 }),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||
defaultPath: text('default_path'),
|
||||
key: text('key', { length: 8192 }), // Increased for larger keys
|
||||
keyPassword: text('key_password'), // Password for protected keys
|
||||
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
|
||||
enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections
|
||||
enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true),
|
||||
defaultPath: text('default_path'), // Default path for SSH connection
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { configEditorData, configSshData } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- JWT Auth Middleware ---
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config Data Endpoints (DB-backed, per user) ---
|
||||
router.get('/recent', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'recent')));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch recent files' });
|
||||
}
|
||||
});
|
||||
router.post('/recent', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path: filePath, server, lastOpened } = req.body;
|
||||
if (!filePath) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'recent',
|
||||
name,
|
||||
path: filePath,
|
||||
server: server ? JSON.stringify(server) : null,
|
||||
lastOpened: lastOpened || now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Added to recent' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to add to recent' });
|
||||
}
|
||||
});
|
||||
router.get('/pinned', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'pinned')));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch pinned files' });
|
||||
}
|
||||
});
|
||||
router.post('/pinned', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path: filePath, server } = req.body;
|
||||
if (!filePath) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'pinned',
|
||||
name,
|
||||
path: filePath,
|
||||
server: server ? JSON.stringify(server) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Added to pinned' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to add to pinned' });
|
||||
}
|
||||
});
|
||||
router.get('/shortcuts', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut')));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch shortcuts' });
|
||||
}
|
||||
});
|
||||
router.post('/shortcuts', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path: folderPath, server } = req.body;
|
||||
if (!folderPath) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'shortcut',
|
||||
name,
|
||||
path: folderPath,
|
||||
server: server ? JSON.stringify(server) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Added to shortcuts' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to add to shortcuts' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /config_editor/shortcuts
|
||||
router.delete('/shortcuts', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { path } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
await db.delete(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
|
||||
res.json({ message: 'Shortcut removed' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove shortcut' });
|
||||
}
|
||||
});
|
||||
// POST /config_editor/shortcuts/delete (for compatibility)
|
||||
router.post('/shortcuts/delete', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { path } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
await db.delete(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
|
||||
res.json({ message: 'Shortcut removed' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove shortcut' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Local Default Path Endpoints ---
|
||||
// GET /config_editor/local_default_path
|
||||
router.get('/local_default_path', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const row = await db.select().from(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')))
|
||||
.then(rows => rows[0]);
|
||||
res.json({ defaultPath: row?.path || '/' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch local default path' });
|
||||
}
|
||||
});
|
||||
// POST /config_editor/local_default_path
|
||||
router.post('/local_default_path', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { defaultPath } = req.body;
|
||||
if (!defaultPath) return res.status(400).json({ error: 'Missing defaultPath' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
// Upsert: delete old, insert new
|
||||
await db.delete(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')));
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'local_default_path',
|
||||
name: 'Local Files',
|
||||
path: defaultPath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Local default path saved' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to save local default path' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSH Connection CRUD for Config Editor ---
|
||||
// GET /config_editor/ssh/host
|
||||
router.get('/ssh/host', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db.select().from(configSshData).where(eq(configSshData.userId, userId));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch SSH hosts' });
|
||||
}
|
||||
});
|
||||
// POST /config_editor/ssh/host
|
||||
router.post('/ssh/host', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
|
||||
if (!userId || !ip || !port) {
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
}
|
||||
const sshDataObj: any = {
|
||||
userId,
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = sshKey;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
try {
|
||||
await db.insert(configSshData).values(sshDataObj);
|
||||
res.json({ message: 'SSH host created' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to create SSH host' });
|
||||
}
|
||||
});
|
||||
// PUT /config_editor/ssh/host/:id
|
||||
router.put('/ssh/host/:id', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
|
||||
if (!userId || !ip || !port || !id) {
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
}
|
||||
const sshDataObj: any = {
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = sshKey;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
try {
|
||||
await db.update(configSshData)
|
||||
.set(sshDataObj)
|
||||
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
|
||||
res.json({ message: 'SSH host updated' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to update SSH host' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSH Connection CRUD (reuse /ssh/host endpoints, or proxy) ---
|
||||
router.delete('/ssh/host/:id', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
if (!userId || !id) {
|
||||
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||
}
|
||||
try {
|
||||
await db.delete(configSshData)
|
||||
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
|
||||
res.json({ message: 'SSH host deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to delete SSH host' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /config_editor/ssh/folders
|
||||
router.get('/ssh/folders', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select({ folder: configSshData.folder })
|
||||
.from(configSshData)
|
||||
.where(eq(configSshData.userId, userId));
|
||||
const folderCounts: Record<string, number> = {};
|
||||
data.forEach(d => {
|
||||
if (d.folder && d.folder.trim() !== '') {
|
||||
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch SSH folders' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,7 @@ import { sshData } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import multer from 'multer';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
@@ -47,6 +48,22 @@ interface JWTPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Only allow specific file types for SSH keys
|
||||
if (file.fieldname === 'key') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
@@ -68,8 +85,34 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
// Route: Create SSH data (requires JWT)
|
||||
// POST /ssh/host
|
||||
router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
|
||||
router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||
let hostData: any;
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
if (req.body.data) {
|
||||
try {
|
||||
hostData = JSON.parse(req.body.data);
|
||||
} catch (err) {
|
||||
logger.warn('Invalid JSON data in multipart request');
|
||||
return res.status(400).json({ error: 'Invalid JSON data' });
|
||||
}
|
||||
} else {
|
||||
logger.warn('Missing data field in multipart request');
|
||||
return res.status(400).json({ error: 'Missing data field' });
|
||||
}
|
||||
|
||||
// Add the file data if present
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
}
|
||||
} else {
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||
logger.warn('Invalid SSH data input');
|
||||
@@ -80,33 +123,30 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId: userId,
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
authType: authMethod,
|
||||
pin: !!pin ? 1 : 0,
|
||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
||||
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
if (saveAuthMethod) {
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
// Handle authentication data based on authMethod
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -120,11 +160,36 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
// Route: Update SSH data (requires JWT)
|
||||
// PUT /ssh/host/:id
|
||||
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
|
||||
router.put('/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||
let hostData: any;
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
if (req.body.data) {
|
||||
try {
|
||||
hostData = JSON.parse(req.body.data);
|
||||
} catch (err) {
|
||||
logger.warn('Invalid JSON data in multipart request');
|
||||
return res.status(400).json({ error: 'Invalid JSON data' });
|
||||
}
|
||||
} else {
|
||||
logger.warn('Missing data field in multipart request');
|
||||
return res.status(400).json({ error: 'Missing data field' });
|
||||
}
|
||||
|
||||
// Add the file data if present
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
}
|
||||
} else {
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
|
||||
const { id } = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
|
||||
logger.warn('Invalid SSH data input for update');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
@@ -133,37 +198,34 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
|
||||
const sshDataObj: any = {
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
authType: authMethod,
|
||||
pin: !!pin ? 1 : 0,
|
||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
|
||||
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
if (saveAuthMethod) {
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
// Handle authentication data based on authMethod
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = key;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.update(sshData)
|
||||
await db.update(sshData)
|
||||
.set(sshDataObj)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH data updated' });
|
||||
@@ -186,13 +248,62 @@ router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
res.json(data);
|
||||
// Convert tags to array, booleans to bool, tunnelConnections to array
|
||||
const result = data.map((row: any) => ({
|
||||
...row,
|
||||
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
|
||||
pin: !!row.pin,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
|
||||
enableConfigEditor: !!row.enableConfigEditor,
|
||||
}));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH host by ID (requires JWT)
|
||||
// GET /ssh/host/:id
|
||||
router.get('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for SSH host fetch');
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
|
||||
if (data.length === 0) {
|
||||
return res.status(404).json({ error: 'SSH host not found' });
|
||||
}
|
||||
|
||||
const host = data[0];
|
||||
const result = {
|
||||
...host,
|
||||
tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [],
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||
enableConfigEditor: !!host.enableConfigEditor,
|
||||
};
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH host', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get all unique folders for the authenticated user (requires JWT)
|
||||
// GET /ssh/folders
|
||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { sshTunnelData } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
function isValidPort(val: any): val is number {
|
||||
return typeof val === 'number' && val > 0 && val < 65536;
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
// Only allow bypass if X-Internal-Request header is set
|
||||
if (req.headers['x-internal-request'] === '1') {
|
||||
(req as any).userId = 'internal_service';
|
||||
return next();
|
||||
}
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
// Route: Create SSH tunnel data (requires JWT)
|
||||
// POST /ssh_tunnel/tunnel
|
||||
router.post('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {
|
||||
name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
|
||||
sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
|
||||
endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
|
||||
endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
|
||||
} = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
|
||||
!isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
|
||||
!isValidPort(endpointSSHPort)) {
|
||||
logger.warn('Invalid SSH tunnel data input');
|
||||
return res.status(400).json({ error: 'Invalid SSH tunnel data' });
|
||||
}
|
||||
|
||||
const sshTunnelDataObj: any = {
|
||||
userId: userId,
|
||||
name,
|
||||
folder,
|
||||
sourcePort,
|
||||
endpointPort,
|
||||
sourceIP,
|
||||
sourceSSHPort,
|
||||
sourceUsername,
|
||||
sourceAuthMethod,
|
||||
endpointIP,
|
||||
endpointSSHPort,
|
||||
endpointUsername,
|
||||
endpointAuthMethod,
|
||||
maxRetries: maxRetries || 3,
|
||||
retryInterval: retryInterval || 5000,
|
||||
connectionState: 'DISCONNECTED',
|
||||
autoStart: autoStart || false,
|
||||
isPinned: isPinned || false
|
||||
};
|
||||
|
||||
// Handle source authentication
|
||||
if (sourceAuthMethod === 'password') {
|
||||
sshTunnelDataObj.sourcePassword = sourcePassword;
|
||||
sshTunnelDataObj.sourceSSHKey = null;
|
||||
sshTunnelDataObj.sourceKeyPassword = null;
|
||||
sshTunnelDataObj.sourceKeyType = null;
|
||||
} else if (sourceAuthMethod === 'key') {
|
||||
sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
|
||||
sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
|
||||
sshTunnelDataObj.sourceKeyType = sourceKeyType;
|
||||
sshTunnelDataObj.sourcePassword = null;
|
||||
}
|
||||
|
||||
// Handle endpoint authentication
|
||||
if (endpointAuthMethod === 'password') {
|
||||
sshTunnelDataObj.endpointPassword = endpointPassword;
|
||||
sshTunnelDataObj.endpointSSHKey = null;
|
||||
sshTunnelDataObj.endpointKeyPassword = null;
|
||||
sshTunnelDataObj.endpointKeyType = null;
|
||||
} else if (endpointAuthMethod === 'key') {
|
||||
sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
|
||||
sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
|
||||
sshTunnelDataObj.endpointKeyType = endpointKeyType;
|
||||
sshTunnelDataObj.endpointPassword = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(sshTunnelData).values(sshTunnelDataObj);
|
||||
res.json({ message: 'SSH tunnel data created' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to save SSH tunnel data', err);
|
||||
res.status(500).json({ error: 'Failed to save SSH tunnel data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Update SSH tunnel data (requires JWT)
|
||||
// PUT /ssh_tunnel/tunnel/:id
|
||||
router.put('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {
|
||||
name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
|
||||
sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
|
||||
endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
|
||||
endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
|
||||
} = req.body;
|
||||
const { id } = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
|
||||
!isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
|
||||
!isValidPort(endpointSSHPort) || !id) {
|
||||
logger.warn('Invalid SSH tunnel data input for update');
|
||||
return res.status(400).json({ error: 'Invalid SSH tunnel data' });
|
||||
}
|
||||
|
||||
const sshTunnelDataObj: any = {
|
||||
name,
|
||||
folder,
|
||||
sourcePort,
|
||||
endpointPort,
|
||||
sourceIP,
|
||||
sourceSSHPort,
|
||||
sourceUsername,
|
||||
sourceAuthMethod,
|
||||
endpointIP,
|
||||
endpointSSHPort,
|
||||
endpointUsername,
|
||||
endpointAuthMethod,
|
||||
maxRetries: maxRetries || 3,
|
||||
retryInterval: retryInterval || 5000,
|
||||
autoStart: autoStart || false,
|
||||
isPinned: isPinned || false
|
||||
};
|
||||
|
||||
// Handle source authentication
|
||||
if (sourceAuthMethod === 'password') {
|
||||
sshTunnelDataObj.sourcePassword = sourcePassword;
|
||||
sshTunnelDataObj.sourceSSHKey = null;
|
||||
sshTunnelDataObj.sourceKeyPassword = null;
|
||||
sshTunnelDataObj.sourceKeyType = null;
|
||||
} else if (sourceAuthMethod === 'key') {
|
||||
sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
|
||||
sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
|
||||
sshTunnelDataObj.sourceKeyType = sourceKeyType;
|
||||
sshTunnelDataObj.sourcePassword = null;
|
||||
}
|
||||
|
||||
// Handle endpoint authentication
|
||||
if (endpointAuthMethod === 'password') {
|
||||
sshTunnelDataObj.endpointPassword = endpointPassword;
|
||||
sshTunnelDataObj.endpointSSHKey = null;
|
||||
sshTunnelDataObj.endpointKeyPassword = null;
|
||||
sshTunnelDataObj.endpointKeyType = null;
|
||||
} else if (endpointAuthMethod === 'key') {
|
||||
sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
|
||||
sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
|
||||
sshTunnelDataObj.endpointKeyType = endpointKeyType;
|
||||
sshTunnelDataObj.endpointPassword = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.update(sshTunnelData)
|
||||
.set(sshTunnelDataObj)
|
||||
.where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
|
||||
res.json({ message: 'SSH tunnel data updated' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to update SSH tunnel data', err);
|
||||
res.status(500).json({ error: 'Failed to update SSH tunnel data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH tunnel data for the authenticated user (requires JWT)
|
||||
// GET /ssh_tunnel/tunnel
|
||||
router.get('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// If internal request and allAutoStart=1, return all autoStart tunnels
|
||||
if (req.headers['x-internal-request'] === '1' && req.query.allAutoStart === '1') {
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshTunnelData)
|
||||
.where(eq(sshTunnelData.autoStart, true));
|
||||
return res.json(data);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch all auto-start SSH tunnel data', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch auto-start SSH tunnel data' });
|
||||
}
|
||||
}
|
||||
// Default: filter by userId
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH tunnel data fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshTunnelData)
|
||||
.where(eq(sshTunnelData.userId, userId));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH tunnel data', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH tunnel data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get all unique folders for the authenticated user (requires JWT)
|
||||
// GET /ssh_tunnel/folders
|
||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH tunnel folder fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select({ folder: sshTunnelData.folder })
|
||||
.from(sshTunnelData)
|
||||
.where(eq(sshTunnelData.userId, userId));
|
||||
|
||||
const folderCounts: Record<string, number> = {};
|
||||
data.forEach(d => {
|
||||
if (d.folder && d.folder.trim() !== '') {
|
||||
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
|
||||
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH tunnel folders', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH tunnel folders' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Delete SSH tunnel by id (requires JWT)
|
||||
// DELETE /ssh_tunnel/tunnel/:id
|
||||
router.delete('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid userId or id for SSH tunnel delete');
|
||||
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||
}
|
||||
try {
|
||||
const result = await db.delete(sshTunnelData)
|
||||
.where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
|
||||
res.json({ message: 'SSH tunnel deleted' });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete SSH tunnel', err);
|
||||
res.status(500).json({ error: 'Failed to delete SSH tunnel' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
Reference in New Issue
Block a user