Started config editor, migrated to one ssh manager for adding hosts.

This commit is contained in:
LukeGus
2025-07-26 15:42:15 -05:00
parent 608111c37b
commit 2e62dee798
36 changed files with 3064 additions and 1240 deletions

168
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
@@ -26,6 +27,7 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/multer": "^2.0.0",
"@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-hyper-link": "^4.24.1",
"@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/codemirror-themes": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1",
@@ -48,6 +50,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"multer": "^2.0.2",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -57,6 +60,7 @@
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"validator": "^13.15.15",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^4.0.5" "zod": "^4.0.5"
}, },
@@ -3423,7 +3427,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
@@ -3434,7 +3437,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -3460,7 +3462,6 @@
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
@@ -3472,7 +3473,6 @@
"version": "5.0.7", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
"integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@@ -3485,7 +3485,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
@@ -3510,7 +3509,6 @@
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
@@ -3520,11 +3518,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.0.13", "version": "24.0.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==", "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.8.0" "undici-types": "~7.8.0"
@@ -3534,14 +3540,12 @@
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
@@ -3568,7 +3572,6 @@
"version": "0.17.5", "version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
@@ -3579,7 +3582,6 @@
"version": "1.15.8", "version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
@@ -4149,6 +4151,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -4425,6 +4433,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "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": { "node_modules/buildcheck": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
@@ -4434,6 +4448,17 @@
"node": ">=10.0.0" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -4612,6 +4637,21 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/content-disposition": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@@ -6549,6 +6589,79 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/nan": {
"version": "2.23.0", "version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
@@ -7500,6 +7613,14 @@
"node": ">= 0.8" "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": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -7798,6 +7919,12 @@
"node": ">= 0.6" "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": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -7840,7 +7967,6 @@
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@@ -7958,6 +8084,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "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": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
@@ -30,6 +31,7 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/multer": "^2.0.0",
"@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-hyper-link": "^4.24.1",
"@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/codemirror-themes": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1",
@@ -52,6 +54,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"multer": "^2.0.2",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -61,6 +64,7 @@
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"validator": "^13.15.15",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^4.0.5" "zod": "^4.0.5"
}, },

View File

@@ -1,10 +1,11 @@
import React from "react" import React, {useEffect} from "react"
import {Homepage} from "@/apps/Homepage/Homepage.tsx" import {Homepage} from "@/apps/Homepage/Homepage.tsx"
import {SSH} from "@/apps/SSH/SSH.tsx" import {SSH} from "@/apps/SSH/Terminal/SSH.tsx"
import {SSHTunnel} from "@/apps/SSH Tunnel/SSHTunnel.tsx"; import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
import {ConfigEditor} from "@/apps/Config Editor/ConfigEditor.tsx"; import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
import {Tools} from "@/apps/Tools/Tools.tsx"; import {Tools} from "@/apps/Tools/Tools.tsx";
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
function App() { function App() {
const [view, setView] = React.useState<string>("homepage") const [view, setView] = React.useState<string>("homepage")
@@ -15,11 +16,15 @@ function App() {
return <Homepage return <Homepage
onSelectView={setView} onSelectView={setView}
/> />
case "ssh": case "ssh_manager":
return <SSHManager
onSelectView={setView}
/>
case "terminal":
return <SSH return <SSH
onSelectView={setView} onSelectView={setView}
/> />
case "ssh_tunnel": case "tunnel":
return <SSHTunnel return <SSHTunnel
onSelectView={setView} onSelectView={setView}
/> />

View File

@@ -1,24 +1,34 @@
import { HomepageSidebar } from "@/apps/Homepage/HomepageSidebar.tsx"; import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
import React, { useState } from "react"; import React, {useEffect, useState} from "react";
import { HomepageAuth } from "@/apps/Homepage/HomepageAuth.tsx"; import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
interface HomepageProps { interface HomepageProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
} }
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement { export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<HomepageSidebar onSelectView={onSelectView} disabled={!loggedIn} isAdmin={isAdmin} username={loggedIn ? username : null} /> <HomepageSidebar
<div className="flex-1 bg-background" /> onSelectView={onSelectView}
disabled={!loggedIn}
isAdmin={isAdmin}
username={loggedIn ? username : null}
/>
<div className="flex-1 bg-background"/>
<div <div
className="fixed inset-y-0 right-0 flex justify-center items-center z-50" 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>
</div> </div>
); );

View File

@@ -58,8 +58,6 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
} }
setDbError(null); setDbError(null);
}).catch(() => { }).catch(() => {
setFirstUser(true);
setTab("signup");
setDbError("Could not connect to the database. Please try again later."); setDbError("Could not connect to the database. Please try again later.");
}); });
}, []); }, []);

View File

@@ -3,7 +3,7 @@ import {
Computer, Computer,
Server, Server,
File, File,
Hammer, ChevronUp, User2 Hammer, ChevronUp, User2, HardDrive
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -16,16 +16,30 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarProvider, SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx" } from "@/components/ui/sidebar.tsx"
import Icon from "/public/icon.svg";
import { import {
Separator, Separator,
} from "@/components/ui/separator.tsx" } from "@/components/ui/separator.tsx"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu"; 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 {Checkbox} from "@/components/ui/checkbox.tsx";
import axios from "axios"; import axios from "axios";
import {Button} from "@/components/ui/button.tsx"; 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 { interface SidebarProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -55,7 +69,7 @@ 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 [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false); const [regLoading, setRegLoading] = React.useState(false);
@@ -72,8 +86,8 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
try { try {
await API.patch( await API.patch(
"/registration-allowed", "/registration-allowed",
{ allowed: checked }, {allowed: checked},
{ headers: { Authorization: `Bearer ${jwt}` } } {headers: {Authorization: `Bearer ${jwt}`}}
); );
setAllowRegistration(checked); setAllowRegistration(checked);
} catch (e) { } catch (e) {
@@ -81,39 +95,50 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
setRegLoading(false); setRegLoading(false);
} }
}; };
return ( return (
<div>
<SidebarProvider> <SidebarProvider>
<Sidebar> <Sidebar>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" /> Termix
- Termix
</SidebarGroupLabel> </SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem key={"SSH"}> <SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}>
<Computer /> <HardDrive/>
<span>SSH</span> <span>SSH Manager</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem key={"SSH Tunnel"}> <div className="ml-5">
<SidebarMenuButton onClick={() => onSelectView("ssh_tunnel")} disabled={disabled}> <SidebarMenuItem key={"Terminal"}>
<Server /> <SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}>
<span>SSH Tunnel</span> <Computer/>
<span>Terminal</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
disabled={disabled}>
<Server/>
<span>Tunnel</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}> <SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("config_editor")}
<File /> disabled={disabled}>
<File/>
<span>Config Editor</span> <span>Config Editor</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</div>
<SidebarMenuItem key={"Tools"}> <SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
<Hammer /> <Hammer/>
<span>Tools</span> <span>Tools</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -121,7 +146,7 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1"/>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -129,11 +154,11 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
className="data-[state=open]:opacity-90 w-full" className="data-[state=open]:opacity-90 w-full"
style={{ width: '100%' }} style={{width: '100%'}}
disabled={disabled} disabled={disabled}
> >
<User2 /> {username ? username : 'Signed out'} <User2/> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto" /> <ChevronUp className="ml-auto"/>
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -143,11 +168,15 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1" 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 && ( {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)}> <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> <span>Admin Settings</span>
</DropdownMenuItem> </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}> <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> <span>Sign out</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -164,12 +193,13 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
</SheetHeader> </SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-4"> <div className="pt-1 pb-4 px-4 flex flex-col gap-4">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle} disabled={regLoading} /> <Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration Allow new account registration
</label> </label>
</div> </div>
<SheetFooter className="px-4 pt-1 pb-4"> <SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2" /> <Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild> <SheetClose asChild>
<Button variant="outline">Close</Button> <Button variant="outline">Close</Button>
</SheetClose> </SheetClose>
@@ -179,5 +209,6 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
)} )}
</Sidebar> </Sidebar>
</SidebarProvider> </SidebarProvider>
</div>
) )
} }

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar"; import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList"; import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView"; import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor"; import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
import axios from 'axios'; import axios from 'axios';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button.tsx';
import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar"; import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils.ts';
function getJWT() { function getJWT() {
return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];

View File

@@ -6,26 +6,24 @@ import {
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
SidebarProvider SidebarProvider
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar.tsx';
import {Separator} from '@/components/ui/separator'; import {Separator} from '@/components/ui/separator.tsx';
import Icon from '../../../public/icon.svg'; import { Plus, CornerDownLeft, Folder, File, Star, Trash2, Edit, Link2, Server, ArrowUp, MoreVertical } from 'lucide-react';
import {Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose} from '@/components/ui/sheet'; import {Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose} from '@/components/ui/sheet.tsx';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button.tsx';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input.tsx';
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, ArrowUp, CornerDownLeft} from 'lucide-react'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs.tsx';
import axios from 'axios'; import {Switch} from '@/components/ui/switch.tsx';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'; import {SheetDescription} from '@/components/ui/sheet.tsx';
import {Switch} from '@/components/ui/switch'; import {Form, FormField, FormItem, FormLabel, FormControl, FormMessage} from '@/components/ui/form.tsx';
import {SheetDescription} from '@/components/ui/sheet';
import {Form, FormField, FormItem, FormLabel, FormControl, FormMessage} from '@/components/ui/form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
import {useForm, FormProvider} from 'react-hook-form'; import {useForm, Controller} from 'react-hook-form';
import {z} from 'zod'; import {z} from 'zod';
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover.tsx';
import {MoreVertical} from 'lucide-react'; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion'; import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {ScrollArea} from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils.ts';
import { cn } from '@/lib/utils'; import axios from 'axios';
function getJWT() { function getJWT() {
return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
@@ -55,6 +53,8 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
defaultPath: '/', defaultPath: '/',
folder: '', folder: '',
authMethod: 'password', authMethod: 'password',
tags: [] as string[],
tagsInput: '',
} }
}); });
React.useEffect(() => { React.useEffect(() => {
@@ -66,7 +66,9 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
const handleAddSSH = () => { const handleAddSSH = () => {
setAddSheetOpen(true); setAddSheetOpen(true);
}; };
// Update onAddSSHSubmit to only close the modal after a successful request, and show errors otherwise
const onAddSSHSubmit = async (values: any) => { const onAddSSHSubmit = async (values: any) => {
console.log('onAddSSHSubmit called', values);
setAddSubmitError(null); setAddSubmitError(null);
setAddSubmitting(true); setAddSubmitting(true);
try { try {
@@ -75,26 +77,44 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
if (values.sshKeyFile instanceof File) { if (values.sshKeyFile instanceof File) {
sshKeyContent = await values.sshKeyFile.text(); sshKeyContent = await values.sshKeyFile.text();
} }
const payload = { // Always send tags as a comma string
const tags = Array.isArray(values.tags) ? values.tags.join(',') : (values.tags || '');
// Build payload according to backend expectations
let payload: any = {
name: values.name, name: values.name,
folder: values.folder,
tags,
ip: values.ip, ip: values.ip,
port: values.port, port: values.port,
username: values.username, username: values.username,
password: values.password,
sshKey: sshKeyContent,
keyPassword: values.keyPassword,
keyType: values.keyType,
isPinned: values.isPinned,
defaultPath: values.defaultPath,
folder: values.folder,
authMethod: values.authMethod, authMethod: values.authMethod,
isPinned: values.isPinned ? 1 : 0,
defaultPath: values.defaultPath || null,
}; };
if (values.authMethod === 'password') {
payload.password = values.password;
payload.sshKey = null;
payload.keyPassword = null;
payload.keyType = null;
} else if (values.authMethod === 'key') {
payload.password = null;
payload.sshKey = sshKeyContent;
payload.keyPassword = values.keyPassword || null;
payload.keyType = values.keyType || null;
}
// Remove unused fields
// (do not send sshKeyFile, tagsInput, etc.)
console.log('Submitting payload to /config_editor/ssh/host:', payload);
await axios.post(`${API_BASE_DB}/config_editor/ssh/host`, payload, {headers: {Authorization: `Bearer ${jwt}`}}); await axios.post(`${API_BASE_DB}/config_editor/ssh/host`, payload, {headers: {Authorization: `Bearer ${jwt}`}});
await fetchSSH(); await fetchSSH();
setAddSheetOpen(false); setAddSheetOpen(false);
addSSHForm.reset(); setTimeout(() => addSSHForm.reset(), 100); // reset after closing
} catch (err: any) { } catch (err: any) {
setAddSubmitError(err?.response?.data?.error || 'Failed to add SSH connection'); let errorMsg = err?.response?.data?.error || err?.message || 'Failed to add SSH connection';
if (typeof errorMsg !== 'string') {
errorMsg = 'An unknown error occurred. Please check the backend logs.';
}
setAddSubmitError(errorMsg);
} finally { } finally {
setAddSubmitting(false); setAddSubmitting(false);
} }
@@ -421,7 +441,11 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
await fetchSSH(); await fetchSSH();
// setShowAddSSH(false); // No longer used // setShowAddSSH(false); // No longer used
} catch (err: any) { } catch (err: any) {
setSSHFormError(err?.response?.data?.error || 'Failed to save SSH connection'); let errorMsg = err?.response?.data?.error || 'Failed to save SSH connection';
if (typeof errorMsg !== 'string') {
errorMsg = 'An unknown error occurred. Please check the backend logs.';
}
setSSHFormError(errorMsg);
} finally { } finally {
setSSHFormLoading(false); setSSHFormLoading(false);
} }
@@ -474,6 +498,13 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}; };
}, [folderDropdownOpen]); }, [folderDropdownOpen]);
// Before rendering the form, define filteredFolders:
const folderValue = addSSHForm.watch('folder');
const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders;
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
}, [folderValue, folders]);
// --- Render --- // --- Render ---
// Expect a prop: tabs: Tab[] // Expect a prop: tabs: Tab[]
// Use: props.tabs // Use: props.tabs
@@ -484,8 +515,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
<SidebarContent style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}> <SidebarContent style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}>
<SidebarGroup 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"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6"/> Termix / Config
- Termix / Config
</SidebarGroupLabel> </SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/> <Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow min-h-0"> <SidebarGroupContent className="flex flex-col flex-grow min-h-0">
@@ -498,12 +528,15 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</Button> </Button>
<Separator className="p-0.25 mt-1 mb-1"/> <Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem> </SidebarMenuItem>
{/* Add SSH button and modal here, as siblings */}
<SidebarMenuItem key={"AddSSH"}> <SidebarMenuItem key={"AddSSH"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={handleAddSSH} variant="outline"> <Sheet open={addSheetOpen} onOpenChange={setAddSheetOpen}>
<Plus/> <SheetTrigger asChild>
<Button className="w-full mt-2 mb-2 h-8" variant="outline" onClick={handleAddSSH}>
<Plus />
Add SSH Add SSH
</Button> </Button>
<Sheet open={addSheetOpen} onOpenChange={setAddSheetOpen}> </SheetTrigger>
<SheetContent side="left" className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col"> <SheetContent side="left" className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5"> <SheetHeader className="pb-0.5">
<SheetTitle>Add SSH</SheetTitle> <SheetTitle>Add SSH</SheetTitle>
@@ -515,25 +548,27 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
{addSubmitError && ( {addSubmitError && (
<div className="text-red-500 text-sm mb-2">{addSubmitError}</div> <div className="text-red-500 text-sm mb-2">{addSubmitError}</div>
)} )}
<FormProvider {...addSSHForm}> <Form {...addSSHForm}>
<form id="add-host-form" onSubmit={addSSHForm.handleSubmit(onAddSSHSubmit)} className="space-y-4"> <form id="add-host-form" onSubmit={addSSHForm.handleSubmit(onAddSSHSubmit)} className="space-y-4">
{/* Name */}
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="name" name="name"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Name" {...field} /> <Input placeholder="SSH #1" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Folder */}
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="folder" name="folder"
render={({field}) => ( render={({ field }) => (
<FormItem className="relative"> <FormItem className="relative">
<FormLabel>Folder</FormLabel> <FormLabel>Folder</FormLabel>
<FormControl> <FormControl>
@@ -553,13 +588,13 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
disabled={foldersLoading} disabled={foldersLoading}
/> />
</FormControl> </FormControl>
{folderDropdownOpen && folders.length > 0 && ( {folderDropdownOpen && filteredFolders.length > 0 && (
<div <div
ref={folderDropdownRef} ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1" className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
> >
<div className="grid grid-cols-1 gap-1 p-0"> <div className="grid grid-cols-1 gap-1 p-0">
{folders.map(folder => ( {filteredFolders.map((folder) => (
<Button <Button
key={folder} key={folder}
type="button" type="button"
@@ -578,64 +613,113 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</div> </div>
</div> </div>
)} )}
{foldersLoading && {foldersLoading && <div className="text-xs text-muted-foreground mt-1">Loading folders...</div>}
<div className="text-xs text-muted-foreground mt-1">Loading folders...</div>} {foldersError && <div className="text-xs text-red-500 mt-1">{foldersError}</div>}
{foldersError && <FormMessage />
<div className="text-xs text-red-500 mt-1">{foldersError}</div>}
<FormMessage/>
</FormItem> </FormItem>
)} )}
/> />
<h3 className="text-sm font-semibold mb-2 mt-2">Connection Details</h3> {/* Tags */}
<Separator className="p-0.25 mb-2" />
<div className="mb-2" />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="username" name="tagsInput"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Tags</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Username" {...field} /> <Input
placeholder="Add tags (space to add)"
autoComplete="off"
value={addSSHForm.watch('tagsInput') || ''}
onChange={e => {
const value = e.target.value;
const tags = addSSHForm.watch('tags') as string[];
if (value.endsWith(' ')) {
const tag = value.trim();
if (tag && !tags.includes(tag)) {
addSSHForm.setValue('tags', [...tags, tag]);
}
addSSHForm.setValue('tagsInput', '');
} else {
addSSHForm.setValue('tagsInput', value);
}
}}
/>
</FormControl> </FormControl>
<FormMessage/> {/* Tag chips */}
{(addSSHForm.watch('tags') as string[]).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{(addSSHForm.watch('tags') as string[]).map((tag: string) => (
<Button
key={tag}
type="button"
variant="secondary"
size="sm"
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
onClick={() => addSSHForm.setValue('tags', (addSSHForm.watch('tags') as string[]).filter((t: string) => t !== tag))}
>
{tag}
<span className="ml-1 text-lg leading-none">&times;</span>
</Button>
))}
</div>
)}
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Connection Details */}
<Separator className="p-0.25 mt-1 mb-3" />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="ip" name="ip"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>IP Address</FormLabel> <FormLabel>IP</FormLabel>
<FormControl> <FormControl>
<Input placeholder="IP Address" {...field} /> <Input placeholder="127.0.0.1" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem>
)}
/>
<FormField
control={addSSHForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username123" {...field} />
</FormControl>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="port" name="port"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Port" type="number" {...field} /> <Input
placeholder="22"
{...field}
onChange={e => field.onChange(Number(e.target.value) || 22)}
/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Authentication */}
<Separator className="p-0.25 mt-1 mb-3" />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="authMethod" name="authMethod"
render={({field}) => ( render={({ field }) => (
<FormItem> <Tabs value={field.value} onValueChange={field.onChange}>
<h3 className="text-sm font-semibold">Authentication</h3>
<Separator className="p-0.25 mb-1"/>
<Tabs value={field.value} onValueChange={field.onChange} className="w-full mt-0">
<TabsList className="grid w-full grid-cols-2 !mb-0"> <TabsList className="grid w-full grid-cols-2 !mb-0">
<TabsTrigger value="password">Password</TabsTrigger> <TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="key">SSH Key</TabsTrigger> <TabsTrigger value="key">SSH Key</TabsTrigger>
@@ -644,26 +728,24 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="password" name="password"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="Password" {...field} /> <Input type="password" placeholder="password123" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</TabsContent> </TabsContent>
<TabsContent value="key" className="mt-1"> <TabsContent value="key" className="mt-1">
<FormField <Controller
control={addSSHForm.control} control={addSSHForm.control}
name="sshKeyFile" name="sshKeyFile"
render={({field}) => { render={({ field }) => (
const file = field.value as File | null;
return (
<FormItem> <FormItem>
<FormLabel>SSH Key</FormLabel> <FormLabel>SSH Private Key</FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<input <input
@@ -677,42 +759,40 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/> />
<Button type="button" variant="outline" className="w-full"> <Button type="button" variant="outline" className="w-full">
{file ? file.name : "Upload"} {field.value && typeof field.value === 'object' && 'name' in field.value ? (field.value as File).name : "Upload"}
</Button> </Button>
</div> </div>
</FormControl> </FormControl>
<FormMessage/>
</FormItem> </FormItem>
); )}
}}
/> />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="keyPassword" name="keyPassword"
render={({field}) => ( render={({ field }) => (
<FormItem className="mt-3"> <FormItem className="mt-3">
<FormLabel>Key Password (if protected)</FormLabel> <FormLabel>Key Password (if protected)</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="Key Password" {...field} /> <Input type="password" placeholder="Enter key password" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="keyType" name="keyType"
render={({field}) => { render={({ field }) => {
const keyTypeOptions = [ const keyTypeOptions = [
{value: 'auto', label: 'Auto-detect'}, { value: 'auto', label: 'Auto-detect' },
{value: 'ssh-rsa', label: 'RSA'}, { value: 'ssh-rsa', label: 'RSA' },
{value: 'ssh-ed25519', label: 'ED25519'}, { value: 'ssh-ed25519', label: 'ED25519' },
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'}, { value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256' },
{value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'}, { value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384' },
{value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'}, { value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521' },
{value: 'ssh-dss', label: 'DSA'}, { value: 'ssh-dss', label: 'DSA' },
{value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'}, { value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256' },
{value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'}, { value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512' },
]; ];
const [dropdownOpen, setDropdownOpen] = React.useState(false); const [dropdownOpen, setDropdownOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); const dropdownRef = React.useRef<HTMLDivElement>(null);
@@ -777,60 +857,61 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
)} )}
</div> </div>
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</FormItem>
)} )}
/> />
<h3 className="text-sm font-semibold mb-2">Other</h3> {/* Other */}
<Separator className="p-0.25 mt-1 mb-3"/> <Separator className="p-0.25 mt-1 mb-3" />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="defaultPath" name="defaultPath"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Default Path</FormLabel> <FormLabel>Default Path</FormLabel>
<FormControl> <FormControl>
<Input placeholder="/home/user" {...field} /> <Input placeholder="/home/user" {...field} />
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={addSSHForm.control} control={addSSHForm.control}
name="isPinned" name="isPinned"
render={({field}) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Switch checked={!!field.value} onCheckedChange={field.onChange}/> <Switch checked={!!field.value} onCheckedChange={field.onChange} />
<FormLabel className="mb-0">Pin Connection</FormLabel> <FormLabel className="mb-0">Pin Connection</FormLabel>
</div> </div>
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</form> </form>
</Form>
</div> </div>
<SheetFooter className="px-4 pt-1 pb-4"> <SheetFooter className="px-4 pt-1 pb-4">
<Button type="submit" form="add-host-form" className="w-full" disabled={addSubmitting}> <Button type="submit" form="add-host-form" className="w-full" disabled={addSubmitting}>
{addSubmitting ? 'Adding...' : 'Add SSH'} {addSubmitting ? 'Adding...' : 'Add SSH'}
</Button> </Button>
<SheetClose asChild> <SheetClose asChild>
<Button type="button" variant="outline" className="w-full mt-1"> <Button type="button" variant="outline" className="w-full mt-1" disabled={addSubmitting}>
Close Close
</Button> </Button>
</SheetClose> </SheetClose>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
{/* Main black div: servers list or file/folder browser */} {/* Main black div: servers list or file/folder browser */}
@@ -1113,6 +1194,377 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Add Edit SSH modal logic (not in SidebarMenu, but as a Sheet rendered at root, open when editingSSH is set) */}
<Sheet open={!!editingSSH} onOpenChange={open => {
if (!open) {
setTimeout(() => {
setEditingSSH(null);
form.reset();
}, 100);
}
}}>
<SheetContent side="left" className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5">
<SheetTitle>Edit SSH</SheetTitle>
<SheetDescription>
Edit the SSH connection details.
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4">
<Form {...form}>
<form id="edit-host-form" onSubmit={form.handleSubmit(async (values: any) => {
setSSHFormError(null);
setSSHFormLoading(true);
try {
const jwt = getJWT();
let sshKeyContent = values.sshKey;
if (values.sshKeyFile instanceof File) {
sshKeyContent = await values.sshKeyFile.text();
}
const payload = {
name: values.name,
folder: values.folder,
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
ip: values.ip,
port: values.port,
username: values.username,
password: values.password,
sshKey: sshKeyContent,
keyPassword: values.keyPassword,
keyType: values.keyType,
isPinned: values.isPinned,
defaultPath: values.defaultPath,
authMethod: values.authMethod,
};
await axios.put(`${API_BASE_DB}/config_editor/ssh/host/${editingSSH.id}`, payload, {headers: {Authorization: `Bearer ${jwt}`}});
await fetchSSH();
setEditingSSH(null);
setTimeout(() => form.reset(), 100); // reset after closing
} catch (err: any) {
let errorMsg = err?.response?.data?.error || err?.message || 'Failed to update SSH connection';
if (typeof errorMsg !== 'string') {
errorMsg = 'An unknown error occurred. Please check the backend logs.';
}
setSSHFormError(errorMsg);
} finally {
setSSHFormLoading(false);
}
})} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({field}) => (
<FormItem className="relative">
<FormLabel>Folder</FormLabel>
<FormControl>
<Input
ref={el => {
if (typeof field.ref === 'function') field.ref(el);
(folderInputRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
}}
placeholder="e.g. Work"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={e => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
disabled={foldersLoading}
/>
</FormControl>
{folderDropdownOpen && folders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{folders.map(folder => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(folder);
setFolderDropdownOpen(false);
}}
disabled={foldersLoading}
>
{folder}
</Button>
))}
</div>
</div>
)}
{foldersLoading &&
<div className="text-xs text-muted-foreground mt-1">Loading folders...</div>}
{foldersError &&
<div className="text-xs text-red-500 mt-1">{foldersError}</div>}
<FormMessage/>
</FormItem>
)}
/>
<h3 className="text-sm font-semibold mb-2 mt-2">Connection Details</h3>
<Separator className="p-0.25 mb-2" />
<div className="mb-2" />
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ip"
render={({field}) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="IP Address" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({field}) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input placeholder="Port" type="number" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="authMethod"
render={({field}) => (
<FormItem>
<h3 className="text-sm font-semibold">Authentication</h3>
<Separator className="p-0.25 mb-1"/>
<Tabs value={field.value} onValueChange={field.onChange} className="w-full mt-0">
<TabsList className="grid w-full grid-cols-2 !mb-0">
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="key">SSH Key</TabsTrigger>
</TabsList>
<TabsContent value="password" className="mt-1">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key" className="mt-1">
<FormField
control={form.control}
name="sshKeyFile"
render={({field}) => {
const file = field.value as File | null;
return (
<FormItem>
<FormLabel>SSH Key</FormLabel>
<FormControl>
<div className="relative">
<input
id="file-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={e => {
const file = e.target.files?.[0];
field.onChange(file || null);
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button type="button" variant="outline" className="w-full">
{file ? file.name : "Upload"}
</Button>
</div>
</FormControl>
<FormMessage/>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keyPassword"
render={({field}) => (
<FormItem className="mt-3">
<FormLabel>Key Password (if protected)</FormLabel>
<FormControl>
<Input type="password" placeholder="Key Password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({field}) => {
const keyTypeOptions = [
{value: 'auto', label: 'Auto-detect'},
{value: 'ssh-rsa', label: 'RSA'},
{value: 'ssh-ed25519', label: 'ED25519'},
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
{value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'},
{value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'},
{value: 'ssh-dss', label: 'DSA'},
{value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'},
{value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'},
];
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
return (
<FormItem className="mt-3 relative">
<FormLabel>Key Type</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={buttonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setDropdownOpen(open => !open)}
>
{keyTypeOptions.find(opt => opt.value === field.value)?.label || 'Auto-detect'}
</Button>
{dropdownOpen && (
<div
ref={dropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map(opt => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
<FormMessage/>
</FormItem>
);
}}
/>
</TabsContent>
</Tabs>
</FormItem>
)}
/>
<h3 className="text-sm font-semibold mb-2">Other</h3>
<Separator className="p-0.25 mt-1 mb-3"/>
<FormField
control={form.control}
name="defaultPath"
render={({field}) => (
<FormItem>
<FormLabel>Default Path</FormLabel>
<FormControl>
<Input placeholder="/home/user" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPinned"
render={({field}) => (
<FormItem>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Switch checked={!!field.value} onCheckedChange={field.onChange}/>
<FormLabel className="mb-0">Pin Connection</FormLabel>
</div>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</form>
</Form>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Button type="submit" form="edit-host-form" className="w-full" disabled={sshFormLoading}>
{sshFormLoading ? 'Saving...' : 'Save'}
</Button>
<SheetClose asChild>
<Button type="button" variant="outline" className="w-full mt-1" disabled={sshFormLoading}>
Close
</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</SidebarProvider> </SidebarProvider>
); );
}); });

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button.tsx';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card.tsx';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator.tsx';
import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } from 'lucide-react'; import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } from 'lucide-react';
interface SSHConnection { interface SSHConnection {

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card.tsx';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button.tsx';
import { Star, Trash2, Folder, File, Plus } from 'lucide-react'; import { Star, Trash2, Folder, File, Plus } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input.tsx';
import { useState } from 'react'; import { useState } from 'react';
interface FileItem { interface FileItem {

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button.tsx';
import { X, Home } from 'lucide-react'; import { X, Home } from 'lucide-react';
interface ConfigTab { interface ConfigTab {

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { ConfigTabList } from "./ConfigTabList"; import { ConfigTabList } from "./ConfigTabList.tsx";
export function ConfigTopbar(props: any): React.ReactElement { export function ConfigTopbar(props: any): React.ReactElement {
return ( return (

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
)
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from "react"; 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 { SSHTerminal } from "./SSHTerminal.tsx";
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx"; import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
interface ConfigEditorProps { interface ConfigEditorProps {

View File

@@ -52,15 +52,13 @@ import {
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion.tsx";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover.tsx";
import Icon from "../../../public/icon.svg";
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
interface SidebarProps { interface SidebarProps {
onSelectView: (view: string) => void; 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"> <SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroup 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"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" /> Termix / Terminal
- Termix / SSH
</SidebarGroupLabel> </SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden"> <SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">

View File

@@ -1,4 +1,4 @@
import {SSHTabList} from "@/apps/SSH/SSHTabList.tsx"; import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx";
import React from "react"; import React from "react";
interface TerminalTab { interface TerminalTab {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx"; import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH Tunnel/SSHTunnelViewer.tsx"; import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import axios from "axios"; import axios from "axios";
interface ConfigEditorProps { interface ConfigEditorProps {

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator.tsx";
import { Loader2, Edit, Trash2 } from "lucide-react"; import { Loader2, Edit, Trash2 } from "lucide-react";
const CONNECTION_STATES = { const CONNECTION_STATES = {

View File

@@ -48,8 +48,7 @@ import { Input } from "@/components/ui/input.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
import { Switch } from "@/components/ui/switch.tsx"; import { Switch } from "@/components/ui/switch.tsx";
import axios from "axios"; import axios from "axios";
import Icon from "../../../public/icon.svg"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
interface SidebarProps { interface SidebarProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -478,8 +477,7 @@ export const SSHTunnelSidebar = React.forwardRef<{ openEditSheet: (tunnel: any)
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" /> Termix / Tunnel
- Termix / SSH Tunnel
</SidebarGroupLabel> </SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow"> <SidebarGroupContent className="flex flex-col flex-grow">

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { SSHTunnelObject } from "./SSHTunnelObject"; import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator.tsx";
interface SSHTunnelViewerProps { interface SSHTunnelViewerProps {
tunnels: Array<{ tunnels: Array<{

229
src/apps/SSH/ssh-axios.ts Normal file
View 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 };

View File

@@ -21,7 +21,6 @@ import {
import { import {
Separator, Separator,
} from "@/components/ui/separator.tsx" } from "@/components/ui/separator.tsx"
import Icon from "../../../public/icon.svg";
interface SidebarProps { interface SidebarProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -34,8 +33,7 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2"> <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> </SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow"> <SidebarGroupContent className="flex flex-col flex-grow">

View File

@@ -21,7 +21,6 @@ import {
import { import {
Separator, Separator,
} from "@/components/ui/separator.tsx" } from "@/components/ui/separator.tsx"
import Icon from "../../../public/icon.svg";
interface SidebarProps { interface SidebarProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -34,8 +33,7 @@ export function ToolsSidebar({ onSelectView }: SidebarProps): React.ReactElement
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2"> <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> </SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow"> <SidebarGroupContent className="flex flex-col flex-grow">

View File

@@ -2,21 +2,16 @@ import express from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import userRoutes from './routes/users.js'; import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.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 chalk from 'chalk';
import cors from 'cors'; import cors from 'cors';
// CORS for local dev
const app = express(); const app = express();
app.use(cors({ app.use(cors({
origin: 'http://localhost:5173', origin: '*',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'] allowedHeaders: ['Content-Type', 'Authorization']
})); }));
// Custom logger (adapted from starter.ts, with a database icon)
const dbIconSymbol = '🗄️'; const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { 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('/users', userRoutes);
app.use('/ssh', sshRoutes); 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) => { app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
}); });
// Start server
const PORT = 8081; 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);
});

View File

@@ -38,6 +38,7 @@ if (!fs.existsSync(dbDir)) {
const sqlite = new Database('./db/data/db.sqlite'); const sqlite = new Database('./db/data/db.sqlite');
// Create tables using Drizzle schema
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -45,102 +46,92 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS config_editor_data (
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT, name TEXT,
path TEXT NOT NULL, ip TEXT NOT NULL,
server TEXT, port INTEGER NOT NULL,
last_opened TEXT, username TEXT NOT NULL,
created_at TEXT NOT NULL, folder TEXT,
updated_at TEXT NOT NULL, 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) FOREIGN KEY(user_id) REFERENCES users(id)
); );
`); `);
try {
sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get(); // Function to safely add a column if it doesn't exist
} catch (e) { const addColumnIfNotExists = (table: string, column: string, definition: string) => {
sqlite.exec('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;'); 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 { try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (!row) { if (!row) {
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
} }
} catch (e) { } catch (e) {
logger.warn('Could not initialize default settings');
} }
export const db = drizzle(sqlite, { schema }); export const db = drizzle(sqlite, { schema });

View File

@@ -1,4 +1,5 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', { export const users = sqliteTable('users', {
id: text('id').primaryKey(), // Unique user ID (nanoid) 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 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', { export const settings = sqliteTable('settings', {
key: text('key').primaryKey(), key: text('key').primaryKey(),
value: text('value').notNull(), value: text('value').notNull(),
}); });
export const configEditorData = sqliteTable('config_editor_data', { export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text('user_id').notNull().references(() => users.id),
type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut' name: text('name'), // Host name
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'),
ip: text('ip').notNull(), ip: text('ip').notNull(),
port: integer('port').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'), password: text('password'),
authMethod: text('auth_method'), key: text('key', { length: 8192 }), // Increased for larger keys
key: text('key', { length: 8192 }), keyPassword: text('key_password'), // Password for protected keys
keyPassword: text('key_password'), keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
keyType: text('key_type'), enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true),
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }), enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true),
isPinned: integer('is_pinned', { mode: 'boolean' }), tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections
defaultPath: text('default_path'), 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`),
}); });

View File

@@ -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;

View File

@@ -4,6 +4,7 @@ import { sshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import chalk from 'chalk'; import chalk from 'chalk';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import multer from 'multer';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
const dbIconSymbol = '🗄️'; const dbIconSymbol = '🗄️';
@@ -47,6 +48,22 @@ interface JWTPayload {
exp?: number; 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 // JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) { function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
@@ -68,8 +85,34 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
// Route: Create SSH data (requires JWT) // Route: Create SSH data (requires JWT)
// POST /ssh/host // POST /ssh/host
router.post('/host', authenticateJWT, async (req: Request, res: Response) => { router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body; 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input'); logger.warn('Invalid SSH data input');
@@ -80,17 +123,20 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
userId: userId, userId: userId,
name, name,
folder, folder,
tags: Array.isArray(tags) ? tags.join(',') : tags, tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip, ip,
port, port,
username, username,
authMethod, authType: authMethod,
saveAuthMethod: saveAuthMethod ? 1 : 0, pin: !!pin ? 1 : 0,
isPinned: isPinned ? 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, defaultPath: defaultPath || null,
}; };
if (saveAuthMethod) { // Handle authentication data based on authMethod
if (authMethod === 'password') { if (authMethod === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
@@ -102,12 +148,6 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
sshDataObj.keyType = keyType; sshDataObj.keyType = keyType;
sshDataObj.password = null; sshDataObj.password = null;
} }
} else {
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
}
try { try {
await db.insert(sshData).values(sshDataObj); await db.insert(sshData).values(sshDataObj);
@@ -120,11 +160,36 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
// Route: Update SSH data (requires JWT) // Route: Update SSH data (requires JWT)
// PUT /ssh/host/:id // PUT /ssh/host/:id
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => { router.put('/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body; 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 { id } = req.params;
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
logger.warn('Invalid SSH data input for update'); logger.warn('Invalid SSH data input for update');
return res.status(400).json({ error: 'Invalid SSH data' }); return res.status(400).json({ error: 'Invalid SSH data' });
@@ -133,17 +198,20 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
const sshDataObj: any = { const sshDataObj: any = {
name, name,
folder, folder,
tags: Array.isArray(tags) ? tags.join(',') : tags, tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip, ip,
port, port,
username, username,
authMethod, authType: authMethod,
saveAuthMethod: saveAuthMethod ? 1 : 0, pin: !!pin ? 1 : 0,
isPinned: isPinned ? 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, defaultPath: defaultPath || null,
}; };
if (saveAuthMethod) { // Handle authentication data based on authMethod
if (authMethod === 'password') { if (authMethod === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
@@ -155,15 +223,9 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
sshDataObj.keyType = keyType; sshDataObj.keyType = keyType;
sshDataObj.password = null; sshDataObj.password = null;
} }
} else {
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
}
try { try {
const result = await db.update(sshData) await db.update(sshData)
.set(sshDataObj) .set(sshDataObj)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
res.json({ message: 'SSH data updated' }); res.json({ message: 'SSH data updated' });
@@ -186,13 +248,62 @@ router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
.select() .select()
.from(sshData) .from(sshData)
.where(eq(sshData.userId, userId)); .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) { } catch (err) {
logger.error('Failed to fetch SSH data', err); logger.error('Failed to fetch SSH data', err);
res.status(500).json({ error: 'Failed to fetch SSH data' }); 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) // Route: Get all unique folders for the authenticated user (requires JWT)
// GET /ssh/folders // GET /ssh/folders
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => { router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {

View File

@@ -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;

View 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 }