diff --git a/package-lock.json b/package-lock.json index c5a6bd57..51a0356e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -26,6 +27,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/multer": "^2.0.0", "@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1", @@ -48,6 +50,7 @@ "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.525.0", + "multer": "^2.0.2", "nanoid": "^5.1.5", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -57,6 +60,7 @@ "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", + "validator": "^13.15.15", "ws": "^8.18.3", "zod": "^4.0.5" }, @@ -3423,7 +3427,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -3434,7 +3437,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3460,7 +3462,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -3472,7 +3473,6 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3485,7 +3485,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3510,7 +3509,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -3520,11 +3518,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.0.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz", "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -3534,14 +3540,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -3568,7 +3572,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -3579,7 +3582,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -4149,6 +4151,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -4425,6 +4433,12 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -4434,6 +4448,17 @@ "node": ">=10.0.0" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4612,6 +4637,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -6549,6 +6589,79 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", @@ -7500,6 +7613,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7798,6 +7919,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -7840,7 +7967,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -7958,6 +8084,15 @@ "dev": true, "license": "MIT" }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8126,6 +8261,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 5df3cddb..628be0c7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -30,6 +31,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/multer": "^2.0.0", "@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1", @@ -52,6 +54,7 @@ "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.525.0", + "multer": "^2.0.2", "nanoid": "^5.1.5", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -61,6 +64,7 @@ "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", + "validator": "^13.15.15", "ws": "^8.18.3", "zod": "^4.0.5" }, diff --git a/src/App.tsx b/src/App.tsx index a2df8c68..08b62989 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,11 @@ -import React from "react" +import React, {useEffect} from "react" import {Homepage} from "@/apps/Homepage/Homepage.tsx" -import {SSH} from "@/apps/SSH/SSH.tsx" -import {SSHTunnel} from "@/apps/SSH Tunnel/SSHTunnel.tsx"; -import {ConfigEditor} from "@/apps/Config Editor/ConfigEditor.tsx"; +import {SSH} from "@/apps/SSH/Terminal/SSH.tsx" +import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx"; +import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx"; import {Tools} from "@/apps/Tools/Tools.tsx"; +import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx" function App() { const [view, setView] = React.useState("homepage") @@ -15,11 +16,15 @@ function App() { return - case "ssh": + case "ssh_manager": + return + case "terminal": return - case "ssh_tunnel": + case "tunnel": return diff --git a/src/apps/Homepage/Homepage.tsx b/src/apps/Homepage/Homepage.tsx index d4fe427b..3ec39d3e 100644 --- a/src/apps/Homepage/Homepage.tsx +++ b/src/apps/Homepage/Homepage.tsx @@ -1,24 +1,34 @@ -import { HomepageSidebar } from "@/apps/Homepage/HomepageSidebar.tsx"; -import React, { useState } from "react"; -import { HomepageAuth } from "@/apps/Homepage/HomepageAuth.tsx"; +import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx"; +import React, {useEffect, useState} from "react"; +import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx"; interface HomepageProps { onSelectView: (view: string) => void; } -export function Homepage({ onSelectView }: HomepageProps): React.ReactElement { +export function Homepage({onSelectView}: HomepageProps): React.ReactElement { const [loggedIn, setLoggedIn] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const [username, setUsername] = useState(null); + return (
- -
+ +
- +
); diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx index ec3e0e8a..061d676a 100644 --- a/src/apps/Homepage/HomepageAuth.tsx +++ b/src/apps/Homepage/HomepageAuth.tsx @@ -58,8 +58,6 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername, } setDbError(null); }).catch(() => { - setFirstUser(true); - setTab("signup"); setDbError("Could not connect to the database. Please try again later."); }); }, []); diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx index 81fcdc88..2669a643 100644 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ b/src/apps/Homepage/HomepageSidebar.tsx @@ -3,7 +3,7 @@ import { Computer, Server, File, - Hammer, ChevronUp, User2 + Hammer, ChevronUp, User2, HardDrive } from "lucide-react"; import { @@ -16,16 +16,30 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarProvider, } from "@/components/ui/sidebar.tsx" -import Icon from "/public/icon.svg"; import { Separator, } from "@/components/ui/separator.tsx" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu"; -import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, + SheetClose +} from "@/components/ui/sheet"; import {Checkbox} from "@/components/ui/checkbox.tsx"; import axios from "axios"; import {Button} from "@/components/ui/button.tsx"; +import {Homepage} from "@/apps/Homepage/Homepage.tsx"; +import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"; +import {SSH} from "@/apps/SSH/Terminal/SSH.tsx"; +import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx"; +import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx"; +import {Tools} from "@/apps/Tools/Tools.tsx"; interface SidebarProps { onSelectView: (view: string) => void; @@ -40,22 +54,22 @@ function handleLogout() { } function getCookie(name: string) { - return document.cookie.split('; ').reduce((r, v) => { - const parts = v.split('='); - return parts[0] === name ? decodeURIComponent(parts[1]) : r; - }, ""); + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); } const apiBase = - typeof window !== "undefined" && window.location.hostname === "localhost" - ? "http://localhost:8081/users" - : "/users"; + typeof window !== "undefined" && window.location.hostname === "localhost" + ? "http://localhost:8081/users" + : "/users"; const API = axios.create({ - baseURL: apiBase, + baseURL: apiBase, }); -export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: SidebarProps): React.ReactElement { +export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, username}: SidebarProps): React.ReactElement { const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); const [allowRegistration, setAllowRegistration] = React.useState(true); const [regLoading, setRegLoading] = React.useState(false); @@ -72,8 +86,8 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S try { await API.patch( "/registration-allowed", - { allowed: checked }, - { headers: { Authorization: `Bearer ${jwt}` } } + {allowed: checked}, + {headers: {Authorization: `Bearer ${jwt}`}} ); setAllowRegistration(checked); } catch (e) { @@ -81,103 +95,120 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S setRegLoading(false); } }; + return ( - - - - - - Icon - - Termix - - - - - - onSelectView("ssh")} disabled={disabled}> - - SSH - - - - onSelectView("ssh_tunnel")} disabled={disabled}> - - SSH Tunnel - - - - onSelectView("config_editor")} disabled={disabled}> - - Config Editor - - - - onSelectView("tools")} disabled={disabled}> - - Tools - - - - - - - - - - - - - + + + + + + Termix + + + + + + onSelectView("ssh_manager")} disabled={disabled}> + + SSH Manager + + +
+ + onSelectView("terminal")} disabled={disabled}> + + Terminal + + + + onSelectView("tunnel")} + disabled={disabled}> + + Tunnel + + + + onSelectView("config_editor")} + disabled={disabled}> + + Config Editor + + +
+ + onSelectView("tools")} disabled={disabled}> + + Tools + + +
+
+
+
+ + + + + + + + {username ? username : 'Signed out'} + + + + - {username ? username : 'Signed out'} - -
-
- - {isAdmin && ( - setAdminSheetOpen(true)}> - Admin Settings + {isAdmin && ( + setAdminSheetOpen(true)}> + Admin Settings + + )} + + Sign out - )} - - Sign out - - -
-
-
-
- {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} - {isAdmin && ( - - - - Admin Settings - -
- -
- - - - - - -
-
- )} -
-
+ + + + + + {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} + {isAdmin && ( + + + + Admin Settings + +
+ +
+ + + + + + +
+
+ )} + + +
) } \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigCodeEditor.tsx b/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx similarity index 100% rename from src/apps/Config Editor/ConfigCodeEditor.tsx rename to src/apps/SSH/Config Editor/ConfigCodeEditor.tsx diff --git a/src/apps/Config Editor/ConfigEditor.tsx b/src/apps/SSH/Config Editor/ConfigEditor.tsx similarity index 95% rename from src/apps/Config Editor/ConfigEditor.tsx rename to src/apps/SSH/Config Editor/ConfigEditor.tsx index c51f8bb7..01072ca8 100644 --- a/src/apps/Config Editor/ConfigEditor.tsx +++ b/src/apps/SSH/Config Editor/ConfigEditor.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect, useRef } from "react"; -import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar"; -import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList"; -import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView"; -import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor"; +import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx"; +import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx"; +import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx"; +import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx"; import axios from 'axios'; -import { Button } from '@/components/ui/button'; -import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar"; -import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button.tsx'; +import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx"; +import { cn } from '@/lib/utils.ts'; function getJWT() { return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; @@ -216,11 +216,11 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => return (
- setTabContent(tab.id, content)} - /> + />
); diff --git a/src/apps/Config Editor/ConfigEditorSidebar.tsx b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx similarity index 61% rename from src/apps/Config Editor/ConfigEditorSidebar.tsx rename to src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx index bcadc15b..8ccc5b71 100644 --- a/src/apps/Config Editor/ConfigEditorSidebar.tsx +++ b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx @@ -6,26 +6,24 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarProvider -} from '@/components/ui/sidebar'; -import {Separator} from '@/components/ui/separator'; -import Icon from '../../../public/icon.svg'; -import {Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose} from '@/components/ui/sheet'; -import {Button} from '@/components/ui/button'; -import {Input} from '@/components/ui/input'; -import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, ArrowUp, CornerDownLeft} from 'lucide-react'; -import axios from 'axios'; -import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'; -import {Switch} from '@/components/ui/switch'; -import {SheetDescription} from '@/components/ui/sheet'; -import {Form, FormField, FormItem, FormLabel, FormControl, FormMessage} from '@/components/ui/form'; +} from '@/components/ui/sidebar.tsx'; +import {Separator} from '@/components/ui/separator.tsx'; +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.tsx'; +import {Button} from '@/components/ui/button.tsx'; +import {Input} from '@/components/ui/input.tsx'; +import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs.tsx'; +import {Switch} from '@/components/ui/switch.tsx'; +import {SheetDescription} from '@/components/ui/sheet.tsx'; +import {Form, FormField, FormItem, FormLabel, FormControl, FormMessage} from '@/components/ui/form.tsx'; 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 {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; -import {MoreVertical} from 'lucide-react'; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion'; -import {ScrollArea} from '@/components/ui/scroll-area'; -import { cn } from '@/lib/utils'; +import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover.tsx'; +import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx'; +import {ScrollArea} from '@/components/ui/scroll-area.tsx'; +import { cn } from '@/lib/utils.ts'; +import axios from 'axios'; function getJWT() { return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; @@ -55,6 +53,8 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( defaultPath: '/', folder: '', authMethod: 'password', + tags: [] as string[], + tagsInput: '', } }); React.useEffect(() => { @@ -66,7 +66,9 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( const handleAddSSH = () => { setAddSheetOpen(true); }; + // Update onAddSSHSubmit to only close the modal after a successful request, and show errors otherwise const onAddSSHSubmit = async (values: any) => { + console.log('onAddSSHSubmit called', values); setAddSubmitError(null); setAddSubmitting(true); try { @@ -75,26 +77,44 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( if (values.sshKeyFile instanceof File) { 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, + folder: values.folder, + 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, - folder: values.folder, 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 fetchSSH(); setAddSheetOpen(false); - addSSHForm.reset(); + setTimeout(() => addSSHForm.reset(), 100); // reset after closing } 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 { setAddSubmitting(false); } @@ -421,7 +441,11 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( await fetchSSH(); // setShowAddSSH(false); // No longer used } 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 { setSSHFormLoading(false); } @@ -474,6 +498,13 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( }; }, [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 --- // Expect a prop: tabs: Tab[] // Use: props.tabs @@ -484,8 +515,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( - Icon - - Termix / Config + Termix / Config @@ -498,12 +528,15 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( + {/* Add SSH button and modal here, as siblings */} - + + + Add SSH @@ -515,127 +548,178 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( {addSubmitError && (
{addSubmitError}
)} - +
- ( - - Name - - - - - - )} - /> - ( - - Folder - - { - if (typeof field.ref === 'function') field.ref(el); - (folderInputRef as React.MutableRefObject).current = el; - }} - placeholder="e.g. Work" - autoComplete="off" - value={field.value} - onFocus={() => setFolderDropdownOpen(true)} - onChange={e => { - field.onChange(e); - setFolderDropdownOpen(true); - }} - disabled={foldersLoading} - /> - - {folderDropdownOpen && folders.length > 0 && ( -
-
- {folders.map(folder => ( + {/* Name */} + ( + + Name + + + + + + )} + /> + {/* Folder */} + ( + + Folder + + { + if (typeof field.ref === 'function') field.ref(el); + (folderInputRef as React.MutableRefObject).current = el; + }} + placeholder="e.g. Work" + autoComplete="off" + value={field.value} + onFocus={() => setFolderDropdownOpen(true)} + onChange={e => { + field.onChange(e); + setFolderDropdownOpen(true); + }} + disabled={foldersLoading} + /> + + {folderDropdownOpen && filteredFolders.length > 0 && ( +
+
+ {filteredFolders.map((folder) => ( + + ))} +
+
+ )} + {foldersLoading &&
Loading folders...
} + {foldersError &&
{foldersError}
} + +
+ )} + /> + {/* Tags */} + ( + + Tags + + { + 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); + } + }} + /> + + {/* Tag chips */} + {(addSSHForm.watch('tags') as string[]).length > 0 && ( +
+ {(addSSHForm.watch('tags') as string[]).map((tag: string) => ( ))}
-
- )} - {foldersLoading && -
Loading folders...
} - {foldersError && -
{foldersError}
} - - - )} - /> -

Connection Details

- -
- ( - - Username - - - - - - )} - /> - ( - - IP Address - - - - - - )} - /> - ( - - Port - - - - - - )} - /> - ( - -

Authentication

- - + )} + +
+ )} + /> + {/* Connection Details */} + + ( + + IP + + + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Port + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> + {/* Authentication */} + + ( + Password SSH Key @@ -644,75 +728,71 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( ( + render={({ field }) => ( Password - + - + )} /> - { - const file = field.value as File | null; - return ( - - SSH Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
- -
- ); - }} + render={({ field }) => ( + + SSH Private Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} /> ( + render={({ field }) => ( Key Password (if protected) - + - + )} /> { + 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'}, + { 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(null); @@ -777,60 +857,61 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( )}
- + ); }} /> - - )} - /> -

Other

- - ( - - Default Path - - - - - - )} - /> - ( - - -
- - Pin Connection -
-
- -
- )} - /> - + )} + /> + {/* Other */} + + ( + + Default Path + + + + + + )} + /> + ( + + +
+ + Pin Connection +
+
+ +
+ )} + /> + +
-
+
{/* Main black div: servers list or file/folder browser */} @@ -1113,6 +1194,377 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( + {/* Add Edit SSH modal logic (not in SidebarMenu, but as a Sheet rendered at root, open when editingSSH is set) */} + { + if (!open) { + setTimeout(() => { + setEditingSSH(null); + form.reset(); + }, 100); + } + }}> + + + Edit SSH + + Edit the SSH connection details. + + +
+
+ { + 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"> + ( + + Name + + + + + + )} + /> + ( + + Folder + + { + if (typeof field.ref === 'function') field.ref(el); + (folderInputRef as React.MutableRefObject).current = el; + }} + placeholder="e.g. Work" + autoComplete="off" + value={field.value} + onFocus={() => setFolderDropdownOpen(true)} + onChange={e => { + field.onChange(e); + setFolderDropdownOpen(true); + }} + disabled={foldersLoading} + /> + + {folderDropdownOpen && folders.length > 0 && ( +
+
+ {folders.map(folder => ( + + ))} +
+
+ )} + {foldersLoading && +
Loading folders...
} + {foldersError && +
{foldersError}
} + +
+ )} + /> +

Connection Details

+ +
+ ( + + Username + + + + + + )} + /> + ( + + IP Address + + + + + + )} + /> + ( + + Port + + + + + + )} + /> + ( + +

Authentication

+ + + + Password + SSH Key + + + ( + + Password + + + + + + )} + /> + + + { + const file = field.value as File | null; + return ( + + SSH Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+ +
+ ); + }} + /> + ( + + Key Password (if protected) + + + + + + )} + /> + { + 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(null); + const buttonRef = React.useRef(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 ( + + Key Type + +
+ + {dropdownOpen && ( +
+
+ {keyTypeOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ +
+ ); + }} + /> +
+
+
+ )} + /> +

Other

+ + ( + + Default Path + + + + + + )} + /> + ( + + +
+ + Pin Connection +
+
+ +
+ )} + /> + + +
+ + + + + + + + ); }); diff --git a/src/apps/Config Editor/ConfigFileSidebarViewer.tsx b/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx similarity index 97% rename from src/apps/Config Editor/ConfigFileSidebarViewer.tsx rename to src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx index 9076c089..42d29232 100644 --- a/src/apps/Config Editor/ConfigFileSidebarViewer.tsx +++ b/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; +import { Button } from '@/components/ui/button.tsx'; +import { Card } from '@/components/ui/card.tsx'; +import { Separator } from '@/components/ui/separator.tsx'; import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } from 'lucide-react'; interface SSHConnection { diff --git a/src/apps/Config Editor/ConfigHomeView.tsx b/src/apps/SSH/Config Editor/ConfigHomeView.tsx similarity index 97% rename from src/apps/Config Editor/ConfigHomeView.tsx rename to src/apps/SSH/Config Editor/ConfigHomeView.tsx index c9bd038e..955e9b3c 100644 --- a/src/apps/Config Editor/ConfigHomeView.tsx +++ b/src/apps/SSH/Config Editor/ConfigHomeView.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { Card } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card.tsx'; +import { Button } from '@/components/ui/button.tsx'; import { Star, Trash2, Folder, File, Plus } from 'lucide-react'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx'; +import { Input } from '@/components/ui/input.tsx'; import { useState } from 'react'; interface FileItem { diff --git a/src/apps/Config Editor/ConfigTabList.tsx b/src/apps/SSH/Config Editor/ConfigTabList.tsx similarity index 97% rename from src/apps/Config Editor/ConfigTabList.tsx rename to src/apps/SSH/Config Editor/ConfigTabList.tsx index 249810aa..b95040ba 100644 --- a/src/apps/Config Editor/ConfigTabList.tsx +++ b/src/apps/SSH/Config Editor/ConfigTabList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@/components/ui/button.tsx'; import { X, Home } from 'lucide-react'; interface ConfigTab { diff --git a/src/apps/Config Editor/ConfigTopbar.tsx b/src/apps/SSH/Config Editor/ConfigTopbar.tsx similarity index 73% rename from src/apps/Config Editor/ConfigTopbar.tsx rename to src/apps/SSH/Config Editor/ConfigTopbar.tsx index 61c422f8..62637cde 100644 --- a/src/apps/Config Editor/ConfigTopbar.tsx +++ b/src/apps/SSH/Config Editor/ConfigTopbar.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ConfigTabList } from "./ConfigTabList"; +import { ConfigTabList } from "./ConfigTabList.tsx"; export function ConfigTopbar(props: any): React.ReactElement { return ( diff --git a/src/apps/SSH/Manager/SSHManager.tsx b/src/apps/SSH/Manager/SSHManager.tsx new file mode 100644 index 00000000..c2463e2c --- /dev/null +++ b/src/apps/SSH/Manager/SSHManager.tsx @@ -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(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 ( +
+ + +
+
+ +
+ + + Host Viewer + + {editingHost ? "Edit Host" : "Add Host"} + + + + + + + + +
+ +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx b/src/apps/SSH/Manager/SSHManagerHostEditor.tsx new file mode 100644 index 00000000..97f43874 --- /dev/null +++ b/src/apps/SSH/Manager/SSHManagerHostEditor.tsx @@ -0,0 +1,1023 @@ +import {zodResolver} from "@hookform/resolvers/zod" +import {Controller, useForm} from "react-hook-form" +import {z} from "zod" + +import {Button} from "@/components/ui/button.tsx" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {ScrollArea} from "@/components/ui/scroll-area" +import {Separator} from "@/components/ui/separator.tsx"; +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; +import React, {useEffect, useRef, useState} from "react"; +import {Switch} from "@/components/ui/switch.tsx"; +import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; +import { createSSHHost, updateSSHHost, getSSHHosts } from '@/apps/SSH/ssh-axios'; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: 'password' | 'key'; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableConfigEditor: boolean; + defaultPath: string; + tunnelConnections: any[]; + createdAt: string; + updatedAt: string; +} + +interface SSHManagerHostEditorProps { + editingHost?: SSHHost | null; + onFormSubmit?: () => void; +} + +export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHostEditorProps) { + // State for dynamic data + const [hosts, setHosts] = useState([]); + const [folders, setFolders] = useState([]); + const [sshConfigurations, setSshConfigurations] = useState([]); + const [loading, setLoading] = useState(true); + + // State for authentication tab selection + const [authTab, setAuthTab] = useState<'password' | 'key'>('password'); + + // Fetch hosts and extract folders and configurations + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const hostsData = await getSSHHosts(); + setHosts(hostsData); + + // Extract unique folders (excluding empty ones) + const uniqueFolders = [...new Set( + hostsData + .filter(host => host.folder && host.folder.trim() !== '') + .map(host => host.folder) + )].sort(); + + // Extract unique host names for SSH configurations + const uniqueConfigurations = [...new Set( + hostsData + .filter(host => host.name && host.name.trim() !== '') + .map(host => host.name) + )].sort(); + + setFolders(uniqueFolders); + setSshConfigurations(uniqueConfigurations); + } catch (error) { + console.error('Failed to fetch hosts:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + // Create dynamic form schema based on fetched data + const formSchema = z.object({ + name: z.string().optional(), + ip: z.string().min(1), + port: z.coerce.number().min(1).max(65535), + username: z.string().min(1), + folder: z.string().optional(), + tags: z.array(z.string().min(1)).default([]), + pin: z.boolean().default(false), + authType: z.enum(['password', 'key']), + password: z.string().optional(), + key: z.instanceof(File).optional().nullable(), + keyPassword: z.string().optional(), + keyType: z.enum([ + 'auto', + 'ssh-rsa', + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'ssh-dss', + 'ssh-rsa-sha2-256', + 'ssh-rsa-sha2-512', + ]).optional(), + enableTerminal: z.boolean().default(true), + enableTunnel: z.boolean().default(true), + tunnelConnections: z.array(z.object({ + sourcePort: z.coerce.number().min(1).max(65535), + endpointPort: z.coerce.number().min(1).max(65535), + endpointHost: z.string().min(1), + maxRetries: z.coerce.number().min(0).max(100).default(3), + retryInterval: z.coerce.number().min(1).max(3600).default(10), + autoStart: z.boolean().default(false), + })).default([]), + enableConfigEditor: z.boolean().default(true), + defaultPath: z.string().optional(), + }).superRefine((data, ctx) => { + // Conditional validation based on authType + if (data.authType === 'password') { + if (!data.password || data.password.trim() === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password is required when using password authentication", + path: ['password'] + }); + } + } else if (data.authType === 'key') { + if (!data.key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "SSH Private Key is required when using key authentication", + path: ['key'] + }); + } + if (!data.keyType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Key Type is required when using key authentication", + path: ['keyType'] + }); + } + } + + // Validate endpointHost against available configurations + data.tunnelConnections.forEach((connection, index) => { + if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must select a valid SSH configuration from the list", + path: ['tunnelConnections', index, 'endpointHost'] + }); + } + }); + }); + + type FormData = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: editingHost?.name || "", + ip: editingHost?.ip || "", + port: editingHost?.port || 22, + username: editingHost?.username || "", + folder: editingHost?.folder || "", + tags: editingHost?.tags || [], + pin: editingHost?.pin || false, + authType: (editingHost?.authType as 'password' | 'key') || "password", + password: "", + key: null, + keyPassword: "", + keyType: "auto", + enableTerminal: editingHost?.enableTerminal !== false, + enableTunnel: editingHost?.enableTunnel !== false, + enableConfigEditor: editingHost?.enableConfigEditor !== false, + defaultPath: editingHost?.defaultPath || "/", + tunnelConnections: editingHost?.tunnelConnections || [], + } + }); + + // Update form when editingHost changes + useEffect(() => { + if (editingHost) { + // Determine the default auth type based on what's available + const defaultAuthType = editingHost.key ? 'key' : 'password'; + + // Update the auth tab state + setAuthTab(defaultAuthType); + + form.reset({ + name: editingHost.name || "", + ip: editingHost.ip || "", + port: editingHost.port || 22, + username: editingHost.username || "", + folder: editingHost.folder || "", + tags: editingHost.tags || [], + pin: editingHost.pin || false, + authType: defaultAuthType, + password: editingHost.password || "", + key: editingHost.key ? new File([editingHost.key], "key.pem") : null, + keyPassword: editingHost.keyPassword || "", + keyType: (editingHost.keyType as any) || "auto", + enableTerminal: editingHost.enableTerminal !== false, + enableTunnel: editingHost.enableTunnel !== false, + enableConfigEditor: editingHost.enableConfigEditor !== false, + defaultPath: editingHost.defaultPath || "/", + tunnelConnections: editingHost.tunnelConnections || [], + }); + } else { + // Reset to password tab for new hosts + setAuthTab('password'); + + form.reset({ + name: "", + ip: "", + port: 22, + username: "", + folder: "", + tags: [], + pin: false, + authType: "password", + password: "", + key: null, + keyPassword: "", + keyType: "auto", + enableTerminal: true, + enableTunnel: true, + enableConfigEditor: true, + defaultPath: "/", + tunnelConnections: [], + }); + } + }, [editingHost, form]); + + const onSubmit = async (data: any) => { + try { + const formData = data as FormData; + + // Set default name if empty or undefined + if (!formData.name || formData.name.trim() === '') { + formData.name = `${formData.username}@${formData.ip}`; + } + + if (editingHost) { + await updateSSHHost(editingHost.id, formData); + console.log('Host updated successfully'); + } else { + await createSSHHost(formData); + console.log('Host created successfully'); + } + + // Call the callback to redirect to host viewer + if (onFormSubmit) { + onFormSubmit(); + } + } catch (error) { + console.error('Failed to save host:', error); + alert('Failed to save host. Please try again.'); + } + }; + + // Tag input state + const [tagInput, setTagInput] = useState(""); + + // Folder dropdown state + const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); + const folderInputRef = useRef(null); + const folderDropdownRef = useRef(null); + + // Folder filtering logic + const folderValue = form.watch('folder'); + const filteredFolders = React.useMemo(() => { + if (!folderValue) return folders; + return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase())); + }, [folderValue, folders]); + + // Handle folder click + const handleFolderClick = (folder: string) => { + form.setValue('folder', folder); + setFolderDropdownOpen(false); + }; + + // Close dropdown on outside click + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + folderDropdownRef.current && + !folderDropdownRef.current.contains(event.target as Node) && + folderInputRef.current && + !folderInputRef.current.contains(event.target as Node) + ) { + setFolderDropdownOpen(false); + } + } + + if (folderDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [folderDropdownOpen]); + + // keyType Dropdown + 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 [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); + const keyTypeButtonRef = useRef(null); + const keyTypeDropdownRef = useRef(null); + + useEffect(() => { + function onClickOutside(event: MouseEvent) { + if ( + keyTypeDropdownOpen && + keyTypeDropdownRef.current && + !keyTypeDropdownRef.current.contains(event.target as Node) && + keyTypeButtonRef.current && + !keyTypeButtonRef.current.contains(event.target as Node) + ) { + setKeyTypeDropdownOpen(false); + } + } + + document.addEventListener("mousedown", onClickOutside); + return () => document.removeEventListener("mousedown", onClickOutside); + }, [keyTypeDropdownOpen]); + + // SSH Configuration dropdown state and logic + const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ [key: number]: boolean }>({}); + const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); + const sshConfigDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); + + // SSH Configuration filtering logic + const getFilteredSshConfigs = (index: number) => { + const value = form.watch(`tunnelConnections.${index}.endpointHost`); + + // Get current host name to exclude it from the list + const currentHostName = form.watch('name') || `${form.watch('username')}@${form.watch('ip')}`; + + // Filter out the current host and apply search filter + let filtered = sshConfigurations.filter(config => config !== currentHostName); + + if (value) { + filtered = filtered.filter(config => + config.toLowerCase().includes(value.toLowerCase()) + ); + } + + return filtered; + }; + + // Handle SSH configuration click + const handleSshConfigClick = (config: string, index: number) => { + form.setValue(`tunnelConnections.${index}.endpointHost`, config); + setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false })); + }; + + // Close SSH configuration dropdown on outside click + useEffect(() => { + function handleSshConfigClickOutside(event: MouseEvent) { + const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(key => sshConfigDropdownOpen[parseInt(key)]); + + openDropdowns.forEach((indexStr: string) => { + const index = parseInt(indexStr); + if ( + sshConfigDropdownRefs.current[index] && + !sshConfigDropdownRefs.current[index]?.contains(event.target as Node) && + sshConfigInputRefs.current[index] && + !sshConfigInputRefs.current[index]?.contains(event.target as Node) + ) { + setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false })); + } + }); + } + + const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(open => open); + + if (hasOpenDropdowns) { + document.addEventListener('mousedown', handleSshConfigClickOutside); + } else { + document.removeEventListener('mousedown', handleSshConfigClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleSshConfigClickOutside); + }; + }, [sshConfigDropdownOpen]); + + return ( +
+
+ + + + + General + Terminal + Tunnel + Config Editor + + + Connection Details +
+ ( + + IP + + + + + )} + /> + + ( + + Port + + + + + )} + /> + + ( + + Username + + + + + )} + /> +
+ Organization +
+ ( + + Name + + + + + )} + /> + + ( + + Folder + + setFolderDropdownOpen(true)} + onChange={e => { + field.onChange(e); + setFolderDropdownOpen(true); + }} + /> + + {/* Folder dropdown menu */} + {folderDropdownOpen && filteredFolders.length > 0 && ( +
+
+ {filteredFolders.map((folder) => ( + + ))} +
+
+ )} +
+ )} + /> + + ( + + Tags + +
+ {field.value.map((tag: string, idx: number) => ( + + {tag} + + + ))} + setTagInput(e.target.value)} + onKeyDown={e => { + if (e.key === " " && tagInput.trim() !== "") { + e.preventDefault(); + if (!field.value.includes(tagInput.trim())) { + field.onChange([...field.value, tagInput.trim()]); + } + setTagInput(""); + } else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) { + field.onChange(field.value.slice(0, -1)); + } + }} + placeholder="add tags (space to add)" + /> +
+
+
+ )} + /> + + ( + + Pin Connection + + + + + )} + /> +
+ Authentication + { + setAuthTab(value as 'password' | 'key'); + form.setValue('authType', value as 'password' | 'key'); + }} + className="flex-1 flex flex-col h-full min-h-0" + > + + Password + Key + + + ( + + Password + + + + + )} + /> + + +
+ ( + + SSH Private Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> + ( + + Key Password + + + + + )} + /> + ( + + Key Type + +
+ + {keyTypeDropdownOpen && ( +
+
+ {keyTypeOptions.map((opt) => ( + + ))} +
+
+ )} +
+
+
+ )} + /> +
+
+
+
+ + ( + + Enable Terminal + + + + + Enable/disable host visibility in Terminal tab. + + + )} + /> + + {form.watch('enableTerminal') && ( +
+ {/* Tunnel Config (none yet) */} +
+ )} +
+ + ( + + Enable Tunnel + + + + + Enable/disable host visibility in Tunnel tab. + + + )} + /> + + {form.watch('enableTunnel') && ( + <> + + + Sshpass Required For Password Authentication +
+ For password-based SSH authentication, sshpass must be installed on both the local and remote servers. Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. +
+
+ Other installation methods: +
• CentOS/RHEL/Fedora: sudo yum install sshpass or sudo dnf install sshpass
+
• macOS: brew install hudochenkov/sshpass/sshpass
+
• Windows: Use WSL or consider SSH key authentication
+
+
+
+ + + + SSH Server Configuration Required +
For reverse SSH tunnels, the endpoint SSH server must allow:
+
GatewayPorts yes (bind remote ports)
+
AllowTcpForwarding yes (port forwarding)
+
PermitRootLogin yes (if using root)
+
Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd
+
+
+ + ( + + Tunnel Connections + +
+ {field.value.map((connection, index) => ( +
+
+

Connection {index + 1}

+ +
+
+ ( + + Source Port (Local) + + + + + )} + /> + ( + + Endpoint Port (Remote) + + + + + )} + /> + ( + + Endpoint SSH Configuration + + { + sshConfigInputRefs.current[index] = el; + }} + placeholder="endpoint ssh configuration" + className="min-h-[40px]" + autoComplete="off" + value={endpointHostField.value} + onFocus={() => setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }))} + onChange={e => { + endpointHostField.onChange(e); + setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true })); + }} + /> + + {/* SSH Configuration dropdown menu */} + {sshConfigDropdownOpen[index] && getFilteredSshConfigs(index).length > 0 && ( +
{ + sshConfigDropdownRefs.current[index] = el; + }} + 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" + > +
+ {getFilteredSshConfigs(index).map((config) => ( + + ))} +
+
+ )} +
+ )} + /> +
+ +

+ This tunnel will forward traffic from port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on the source machine (current connection details in general tab) to port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on the endpoint machine. +

+ +
+ ( + + Max Retries + + + + + Maximum number of retry attempts for tunnel connection. + + + )} + /> + ( + + Retry Interval (seconds) + + + + + Time to wait between retry attempts. + + + )} + /> + ( + + Auto Start on Container Launch + + + + + Automatically start this tunnel when the container launches. + + + )} + /> +
+
+ ))} + +
+
+
+ )} + /> + + + + )} +
+ + ( + + Enable Config Editor + + + + + Enable/disable host visibility in Config Editor tab. + + + )} + /> + + {form.watch('enableConfigEditor') && ( +
+ ( + + Default Path + + + + Set default directory shown when connected via Config Editor + + )} + /> +
+ )} +
+
+
+
+ + +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx new file mode 100644 index 00000000..0ee6659b --- /dev/null +++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading hosts...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (hosts.length === 0) { + return ( +
+
+ +

No SSH Hosts

+

+ You haven't added any SSH hosts yet. Click "Add Host" to get started. +

+
+
+ ); + } + + return ( +
+
+
+

SSH Hosts

+

+ {filteredAndSortedHosts.length} hosts +

+
+ +
+ + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + +
+ {Object.entries(hostsByFolder).map(([folder, folderHosts]) => ( +
+ + + +
+ + {folder} + + {folderHosts.length} + +
+
+ +
+ {folderHosts.map((host) => ( +
handleEdit(host)} + > +
+
+
+ {host.pin && } +

+ {host.name || `${host.username}@${host.ip}`} +

+
+

+ {host.ip}:{host.port} +

+

+ {host.username} +

+
+
+ + +
+
+ +
+ {/* Tags */} + {host.tags && host.tags.length > 0 && ( +
+ {host.tags.slice(0, 6).map((tag, index) => ( + + + {tag} + + ))} + {host.tags.length > 6 && ( + + +{host.tags.length - 6} + + )} +
+ )} + + {/* Features */} +
+ {host.enableTerminal && ( + + + Terminal + + )} + {host.enableTunnel && ( + + + Tunnel + {host.tunnelConnections && host.tunnelConnections.length > 0 && ( + ({host.tunnelConnections.length}) + )} + + )} + {host.enableConfigEditor && ( + + + Config + + )} +
+
+
+ ))} +
+
+
+
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/apps/SSH/Manager/SSHManagerSidebar.tsx b/src/apps/SSH/Manager/SSHManagerSidebar.tsx new file mode 100644 index 00000000..c29d95fe --- /dev/null +++ b/src/apps/SSH/Manager/SSHManagerSidebar.tsx @@ -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 ( + + + + + + Termix / SSH Manager + + + + + + {/* Sidebar Items */} + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/apps/SSH/SSH.tsx b/src/apps/SSH/Terminal/SSH.tsx similarity index 99% rename from src/apps/SSH/SSH.tsx rename to src/apps/SSH/Terminal/SSH.tsx index 8029ae58..110225df 100644 --- a/src/apps/SSH/SSH.tsx +++ b/src/apps/SSH/Terminal/SSH.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect } from "react"; -import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx"; +import { SSHSidebar } from "@/apps/SSH/Terminal/SSHSidebar.tsx"; import { SSHTerminal } from "./SSHTerminal.tsx"; -import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx"; -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; +import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx"; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx'; import * as ResizablePrimitive from "react-resizable-panels"; interface ConfigEditorProps { diff --git a/src/apps/SSH/SSHSidebar.tsx b/src/apps/SSH/Terminal/SSHSidebar.tsx similarity index 99% rename from src/apps/SSH/SSHSidebar.tsx rename to src/apps/SSH/Terminal/SSHSidebar.tsx index 06d925e9..93863678 100644 --- a/src/apps/SSH/SSHSidebar.tsx +++ b/src/apps/SSH/Terminal/SSHSidebar.tsx @@ -52,15 +52,13 @@ import { AccordionContent, AccordionItem, AccordionTrigger, -} from "@/components/ui/accordion"; -import { ScrollArea } from "@/components/ui/scroll-area"; +} from "@/components/ui/accordion.tsx"; +import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Popover, PopoverContent, PopoverTrigger, -} from "@/components/ui/popover"; -import Icon from "../../../public/icon.svg"; -import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"; +} from "@/components/ui/popover.tsx"; interface SidebarProps { onSelectView: (view: string) => void; @@ -637,8 +635,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa - Icon - - Termix / SSH + Termix / Terminal diff --git a/src/apps/SSH/SSHTabList.tsx b/src/apps/SSH/Terminal/SSHTabList.tsx similarity index 100% rename from src/apps/SSH/SSHTabList.tsx rename to src/apps/SSH/Terminal/SSHTabList.tsx diff --git a/src/apps/SSH/SSHTerminal.tsx b/src/apps/SSH/Terminal/SSHTerminal.tsx similarity index 100% rename from src/apps/SSH/SSHTerminal.tsx rename to src/apps/SSH/Terminal/SSHTerminal.tsx diff --git a/src/apps/SSH/SSHTopbar.tsx b/src/apps/SSH/Terminal/SSHTopbar.tsx similarity index 94% rename from src/apps/SSH/SSHTopbar.tsx rename to src/apps/SSH/Terminal/SSHTopbar.tsx index 0baf1b62..f64dadd4 100644 --- a/src/apps/SSH/SSHTopbar.tsx +++ b/src/apps/SSH/Terminal/SSHTopbar.tsx @@ -1,4 +1,4 @@ -import {SSHTabList} from "@/apps/SSH/SSHTabList.tsx"; +import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx"; import React from "react"; interface TerminalTab { diff --git a/src/apps/SSH Tunnel/SSHTunnel.tsx b/src/apps/SSH/Tunnel/SSHTunnel.tsx similarity index 98% rename from src/apps/SSH Tunnel/SSHTunnel.tsx rename to src/apps/SSH/Tunnel/SSHTunnel.tsx index 0bafb493..1a54c7f1 100644 --- a/src/apps/SSH Tunnel/SSHTunnel.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnel.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from "react"; -import { SSHTunnelSidebar } from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx"; -import { SSHTunnelViewer } from "@/apps/SSH Tunnel/SSHTunnelViewer.tsx"; +import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx"; +import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx"; import axios from "axios"; interface ConfigEditorProps { diff --git a/src/apps/SSH Tunnel/SSHTunnelObject.tsx b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx similarity index 98% rename from src/apps/SSH Tunnel/SSHTunnelObject.tsx rename to src/apps/SSH/Tunnel/SSHTunnelObject.tsx index 358fd3e8..d11c4170 100644 --- a/src/apps/SSH Tunnel/SSHTunnelObject.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button.tsx"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"; +import { Separator } from "@/components/ui/separator.tsx"; import { Loader2, Edit, Trash2 } from "lucide-react"; const CONNECTION_STATES = { diff --git a/src/apps/SSH Tunnel/SSHTunnelSidebar.tsx b/src/apps/SSH/Tunnel/SSHTunnelSidebar.tsx similarity index 99% rename from src/apps/SSH Tunnel/SSHTunnelSidebar.tsx rename to src/apps/SSH/Tunnel/SSHTunnelSidebar.tsx index 074333d8..189c3e44 100644 --- a/src/apps/SSH Tunnel/SSHTunnelSidebar.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelSidebar.tsx @@ -48,8 +48,7 @@ import { Input } from "@/components/ui/input.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; import { Switch } from "@/components/ui/switch.tsx"; import axios from "axios"; -import Icon from "../../../public/icon.svg"; -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; interface SidebarProps { onSelectView: (view: string) => void; @@ -478,8 +477,7 @@ export const SSHTunnelSidebar = React.forwardRef<{ openEditSheet: (tunnel: any) - Icon - - Termix / SSH Tunnel + Termix / Tunnel diff --git a/src/apps/SSH Tunnel/SSHTunnelViewer.tsx b/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx similarity index 97% rename from src/apps/SSH Tunnel/SSHTunnelViewer.tsx rename to src/apps/SSH/Tunnel/SSHTunnelViewer.tsx index 6134539f..372dc94f 100644 --- a/src/apps/SSH Tunnel/SSHTunnelViewer.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { SSHTunnelObject } from "./SSHTunnelObject"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; -import { Separator } from "@/components/ui/separator"; +import { SSHTunnelObject } from "./SSHTunnelObject.tsx"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx"; +import { Separator } from "@/components/ui/separator.tsx"; interface SSHTunnelViewerProps { tunnels: Array<{ diff --git a/src/apps/SSH/ssh-axios.ts b/src/apps/SSH/ssh-axios.ts new file mode 100644 index 00000000..ab7833c1 --- /dev/null +++ b/src/apps/SSH/ssh-axios.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }; \ No newline at end of file diff --git a/src/apps/Template/TemplateSidebar.tsx b/src/apps/Template/TemplateSidebar.tsx index f5a1889b..b7912410 100644 --- a/src/apps/Template/TemplateSidebar.tsx +++ b/src/apps/Template/TemplateSidebar.tsx @@ -21,7 +21,6 @@ import { import { Separator, } from "@/components/ui/separator.tsx" -import Icon from "../../../public/icon.svg"; interface SidebarProps { onSelectView: (view: string) => void; @@ -34,8 +33,7 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem - Icon - - Termix / Template + Termix / Template diff --git a/src/apps/Tools/ToolsSidebar.tsx b/src/apps/Tools/ToolsSidebar.tsx index 6fe480d7..859078cb 100644 --- a/src/apps/Tools/ToolsSidebar.tsx +++ b/src/apps/Tools/ToolsSidebar.tsx @@ -21,7 +21,6 @@ import { import { Separator, } from "@/components/ui/separator.tsx" -import Icon from "../../../public/icon.svg"; interface SidebarProps { onSelectView: (view: string) => void; @@ -34,8 +33,7 @@ export function ToolsSidebar({ onSelectView }: SidebarProps): React.ReactElement - Icon - - Termix / Tools + Termix / Tools diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 04d6d889..b3ea03eb 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -2,21 +2,16 @@ import express from 'express'; import bodyParser from 'body-parser'; import userRoutes from './routes/users.js'; import sshRoutes from './routes/ssh.js'; -import sshTunnelRoutes from './routes/ssh_tunnel.js'; -import configEditorRoutes from './routes/config_editor.js'; import chalk from 'chalk'; import cors from 'cors'; -// CORS for local dev const app = express(); app.use(cors({ - origin: 'http://localhost:5173', - credentials: true, + origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); -// Custom logger (adapted from starter.ts, with a database icon) const dbIconSymbol = '🗄️'; const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { @@ -51,14 +46,16 @@ app.get('/health', (req, res) => { app.use('/users', userRoutes); app.use('/ssh', sshRoutes); -app.use('/ssh_tunnel', sshTunnelRoutes); -app.use('/config_editor', configEditorRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Unhandled error:', err); res.status(500).json({ error: 'Internal Server Error' }); }); -// Start server const PORT = 8081; -app.listen(PORT); \ No newline at end of file +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); +}); \ No newline at end of file diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 24861db6..7328beba 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -38,6 +38,7 @@ if (!fs.existsSync(dbDir)) { const sqlite = new Database('./db/data/db.sqlite'); +// Create tables using Drizzle schema sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, @@ -45,102 +46,92 @@ CREATE TABLE IF NOT EXISTS users ( password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0 ); -CREATE TABLE IF NOT EXISTS ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - folder TEXT, - tags TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT, - password TEXT, - auth_method TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - save_auth_method INTEGER, - is_pinned INTEGER, - default_path TEXT, - FOREIGN KEY(user_id) REFERENCES users(id) -); -CREATE TABLE IF NOT EXISTS config_ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - folder TEXT, - tags TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT, - password TEXT, - auth_method TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - save_auth_method INTEGER, - is_pinned INTEGER, - default_path TEXT, - FOREIGN KEY(user_id) REFERENCES users(id) -); -CREATE TABLE IF NOT EXISTS ssh_tunnel_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - folder TEXT, - source_port INTEGER NOT NULL, - endpoint_port INTEGER NOT NULL, - source_ip TEXT NOT NULL, - source_ssh_port INTEGER NOT NULL, - source_username TEXT, - source_password TEXT, - source_auth_method TEXT, - source_ssh_key TEXT, - source_key_password TEXT, - source_key_type TEXT, - endpoint_ip TEXT NOT NULL, - endpoint_ssh_port INTEGER NOT NULL, - endpoint_username TEXT, - endpoint_password TEXT, - endpoint_auth_method TEXT, - endpoint_ssh_key TEXT, - endpoint_key_password TEXT, - endpoint_key_type TEXT, - max_retries INTEGER NOT NULL DEFAULT 3, - retry_interval INTEGER NOT NULL DEFAULT 5000, - connection_state TEXT NOT NULL DEFAULT 'DISCONNECTED', - auto_start INTEGER NOT NULL DEFAULT 0, - is_pinned INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(user_id) REFERENCES users(id) -); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS config_editor_data ( + +CREATE TABLE IF NOT EXISTS ssh_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, - type TEXT NOT NULL, name TEXT, - path TEXT NOT NULL, - server TEXT, - last_opened TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + enable_config_editor INTEGER NOT NULL DEFAULT 1, + default_path TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ); `); -try { - sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get(); -} catch (e) { - sqlite.exec('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;'); -} + +// Function to safely add a column if it doesn't exist +const addColumnIfNotExists = (table: string, column: string, definition: string) => { + try { + // Try to select the column to see if it exists + sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get(); + } catch (e) { + // Column doesn't exist, add it + try { + sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`); + } catch (alterError) { + logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`); + } + } +}; + +// Auto-migrate: Add any missing columns based on current schema +const migrateSchema = () => { + logger.info('Checking for schema updates...'); + + // Add missing columns to users table + addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); + + // Add missing columns to ssh_data table + addColumnIfNotExists('ssh_data', 'name', 'TEXT'); + addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); + addColumnIfNotExists('ssh_data', 'tags', 'TEXT'); + addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0'); + addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"'); + addColumnIfNotExists('ssh_data', 'password', 'TEXT'); + addColumnIfNotExists('ssh_data', 'key', 'TEXT'); + addColumnIfNotExists('ssh_data', 'key_password', 'TEXT'); + addColumnIfNotExists('ssh_data', 'key_type', 'TEXT'); + addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1'); + addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1'); + addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT'); + addColumnIfNotExists('ssh_data', 'enable_config_editor', 'INTEGER NOT NULL DEFAULT 1'); + addColumnIfNotExists('ssh_data', 'default_path', 'TEXT'); + addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); + addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); + + logger.success('Schema migration completed'); +}; + +// Run auto-migration +migrateSchema(); + +// Initialize default settings try { const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); if (!row) { sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); } } catch (e) { + logger.warn('Could not initialize default settings'); } export const db = drizzle(sqlite, { schema }); \ No newline at end of file diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index b4c2cf42..26b74367 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,4 +1,5 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; export const users = sqliteTable('users', { id: text('id').primaryKey(), // Unique user ID (nanoid) @@ -7,87 +8,31 @@ export const users = sqliteTable('users', { is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag }); -export const sshData = sqliteTable('ssh_data', { - id: integer('id').primaryKey({ autoIncrement: true }), - userId: text('user_id').notNull().references(() => users.id), - name: text('name'), - folder: text('folder'), - tags: text('tags'), - ip: text('ip').notNull(), - port: integer('port').notNull(), - username: text('username'), - password: text('password'), - authMethod: text('auth_method'), - key: text('key', { length: 8192 }), // Increased for larger keys - keyPassword: text('key_password'), // Password for protected keys - keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.) - saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }), - isPinned: integer('is_pinned', { mode: 'boolean' }), - defaultPath: text('default_path'), // Default path for SSH connection -}); - -export const sshTunnelData = sqliteTable('ssh_tunnel_data', { - id: integer('id').primaryKey({ autoIncrement: true }), - userId: text('user_id').notNull().references(() => users.id), - name: text('name'), - folder: text('folder'), - sourcePort: integer('source_port').notNull(), - endpointPort: integer('endpoint_port').notNull(), - sourceIP: text('source_ip').notNull(), - sourceSSHPort: integer('source_ssh_port').notNull(), - sourceUsername: text('source_username'), - sourcePassword: text('source_password'), - sourceAuthMethod: text('source_auth_method'), - sourceSSHKey: text('source_ssh_key', { length: 8192 }), - sourceKeyPassword: text('source_key_password'), - sourceKeyType: text('source_key_type'), - endpointIP: text('endpoint_ip').notNull(), - endpointSSHPort: integer('endpoint_ssh_port').notNull(), - endpointUsername: text('endpoint_username'), - endpointPassword: text('endpoint_password'), - endpointAuthMethod: text('endpoint_auth_method'), - endpointSSHKey: text('endpoint_ssh_key', { length: 8192 }), - endpointKeyPassword: text('endpoint_key_password'), - endpointKeyType: text('endpoint_key_type'), - maxRetries: integer('max_retries').notNull().default(3), - retryInterval: integer('retry_interval').notNull().default(5000), - connectionState: text('connection_state').notNull().default('DISCONNECTED'), - autoStart: integer('auto_start', { mode: 'boolean' }).notNull().default(false), - isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false), -}); - export const settings = sqliteTable('settings', { key: text('key').primaryKey(), value: text('value').notNull(), }); -export const configEditorData = sqliteTable('config_editor_data', { +export const sshData = sqliteTable('ssh_data', { id: integer('id').primaryKey({ autoIncrement: true }), userId: text('user_id').notNull().references(() => users.id), - type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut' - name: text('name'), - path: text('path').notNull(), - server: text('server', { length: 2048 }), // JSON stringified server info (if SSH) - lastOpened: text('last_opened'), // ISO string (for recent) - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at').notNull(), -}); - -export const configSshData = sqliteTable('config_ssh_data', { - id: integer('id').primaryKey({ autoIncrement: true }), - userId: text('user_id').notNull().references(() => users.id), - name: text('name'), - folder: text('folder'), - tags: text('tags'), + name: text('name'), // Host name ip: text('ip').notNull(), port: integer('port').notNull(), - username: text('username'), + username: text('username').notNull(), + folder: text('folder'), + tags: text('tags'), // JSON stringified array + pin: integer('pin', { mode: 'boolean' }).notNull().default(false), + authType: text('auth_type').notNull(), // 'password' | 'key' password: text('password'), - authMethod: text('auth_method'), - key: text('key', { length: 8192 }), - keyPassword: text('key_password'), - keyType: text('key_type'), - saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }), - isPinned: integer('is_pinned', { mode: 'boolean' }), - defaultPath: text('default_path'), + key: text('key', { length: 8192 }), // Increased for larger keys + keyPassword: text('key_password'), // Password for protected keys + keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.) + enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true), + enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true), + tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections + enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true), + defaultPath: text('default_path'), // Default path for SSH connection + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), }); \ No newline at end of file diff --git a/src/backend/database/routes/config_editor.ts b/src/backend/database/routes/config_editor.ts deleted file mode 100644 index e7c48e9b..00000000 --- a/src/backend/database/routes/config_editor.ts +++ /dev/null @@ -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 = {}; - 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; \ No newline at end of file diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 23a7e81b..4de08d95 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -4,6 +4,7 @@ import { sshData } from '../db/schema.js'; import { eq, and } from 'drizzle-orm'; import chalk from 'chalk'; import jwt from 'jsonwebtoken'; +import multer from 'multer'; import type { Request, Response, NextFunction } from 'express'; const dbIconSymbol = '🗄️'; @@ -47,6 +48,22 @@ interface JWTPayload { exp?: number; } +// Configure multer for file uploads +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: (req, file, cb) => { + // Only allow specific file types for SSH keys + if (file.fieldname === 'key') { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } + } +}); + // JWT authentication middleware function authenticateJWT(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; @@ -68,8 +85,34 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { // Route: Create SSH data (requires JWT) // POST /ssh/host -router.post('/host', authenticateJWT, async (req: Request, res: Response) => { - const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body; +router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { + let hostData: any; + + // Check if this is a multipart form data request (file upload) + if (req.headers['content-type']?.includes('multipart/form-data')) { + // Parse the JSON data from the 'data' field + if (req.body.data) { + try { + hostData = JSON.parse(req.body.data); + } catch (err) { + logger.warn('Invalid JSON data in multipart request'); + return res.status(400).json({ error: 'Invalid JSON data' }); + } + } else { + logger.warn('Missing data field in multipart request'); + return res.status(400).json({ error: 'Missing data field' }); + } + + // Add the file data if present + if (req.file) { + hostData.key = req.file.buffer.toString('utf8'); + } + } else { + // Regular JSON request + hostData = req.body; + } + + const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData; const userId = (req as any).userId; if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { logger.warn('Invalid SSH data input'); @@ -80,33 +123,30 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => { userId: userId, name, folder, - tags: Array.isArray(tags) ? tags.join(',') : tags, + tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), ip, port, username, - authMethod, - saveAuthMethod: saveAuthMethod ? 1 : 0, - isPinned: isPinned ? 1 : 0, + authType: authMethod, + pin: !!pin ? 1 : 0, + enableTerminal: !!enableTerminal ? 1 : 0, + enableTunnel: !!enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, + enableConfigEditor: !!enableConfigEditor ? 1 : 0, defaultPath: defaultPath || null, }; - if (saveAuthMethod) { - if (authMethod === 'password') { - sshDataObj.password = password; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (authMethod === 'key') { - sshDataObj.key = key; - sshDataObj.keyPassword = keyPassword; - sshDataObj.keyType = keyType; - sshDataObj.password = null; - } - } else { - sshDataObj.password = null; + // Handle authentication data based on authMethod + if (authMethod === 'password') { + sshDataObj.password = password; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; + } else if (authMethod === 'key') { + sshDataObj.key = key; + sshDataObj.keyPassword = keyPassword; + sshDataObj.keyType = keyType; + sshDataObj.password = null; } try { @@ -120,11 +160,36 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => { // Route: Update SSH data (requires JWT) // PUT /ssh/host/:id -router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => { - const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body; +router.put('/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { + let hostData: any; + + // Check if this is a multipart form data request (file upload) + if (req.headers['content-type']?.includes('multipart/form-data')) { + // Parse the JSON data from the 'data' field + if (req.body.data) { + try { + hostData = JSON.parse(req.body.data); + } catch (err) { + logger.warn('Invalid JSON data in multipart request'); + return res.status(400).json({ error: 'Invalid JSON data' }); + } + } else { + logger.warn('Missing data field in multipart request'); + return res.status(400).json({ error: 'Missing data field' }); + } + + // Add the file data if present + if (req.file) { + hostData.key = req.file.buffer.toString('utf8'); + } + } else { + // Regular JSON request + hostData = req.body; + } + + const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData; const { id } = req.params; const userId = (req as any).userId; - if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) { logger.warn('Invalid SSH data input for update'); return res.status(400).json({ error: 'Invalid SSH data' }); @@ -133,37 +198,34 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => const sshDataObj: any = { name, folder, - tags: Array.isArray(tags) ? tags.join(',') : tags, + tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), ip, port, username, - authMethod, - saveAuthMethod: saveAuthMethod ? 1 : 0, - isPinned: isPinned ? 1 : 0, + authType: authMethod, + pin: !!pin ? 1 : 0, + enableTerminal: !!enableTerminal ? 1 : 0, + enableTunnel: !!enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, + enableConfigEditor: !!enableConfigEditor ? 1 : 0, defaultPath: defaultPath || null, }; - if (saveAuthMethod) { - if (authMethod === 'password') { - sshDataObj.password = password; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (authMethod === 'key') { - sshDataObj.key = key; - sshDataObj.keyPassword = keyPassword; - sshDataObj.keyType = keyType; - sshDataObj.password = null; - } - } else { - sshDataObj.password = null; + // Handle authentication data based on authMethod + if (authMethod === 'password') { + sshDataObj.password = password; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; + } else if (authMethod === 'key') { + sshDataObj.key = key; + sshDataObj.keyPassword = keyPassword; + sshDataObj.keyType = keyType; + sshDataObj.password = null; } try { - const result = await db.update(sshData) + await db.update(sshData) .set(sshDataObj) .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); res.json({ message: 'SSH data updated' }); @@ -186,13 +248,62 @@ router.get('/host', authenticateJWT, async (req: Request, res: Response) => { .select() .from(sshData) .where(eq(sshData.userId, userId)); - res.json(data); + // Convert tags to array, booleans to bool, tunnelConnections to array + const result = data.map((row: any) => ({ + ...row, + tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], + enableConfigEditor: !!row.enableConfigEditor, + })); + res.json(result); } catch (err) { logger.error('Failed to fetch SSH data', err); res.status(500).json({ error: 'Failed to fetch SSH data' }); } }); +// Route: Get SSH host by ID (requires JWT) +// GET /ssh/host/:id +router.get('/host/:id', authenticateJWT, async (req: Request, res: Response) => { + const { id } = req.params; + const userId = (req as any).userId; + + if (!isNonEmptyString(userId) || !id) { + logger.warn('Invalid request for SSH host fetch'); + return res.status(400).json({ error: 'Invalid request' }); + } + + try { + const data = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); + + if (data.length === 0) { + return res.status(404).json({ error: 'SSH host not found' }); + } + + const host = data[0]; + const result = { + ...host, + tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [], + pin: !!host.pin, + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], + enableConfigEditor: !!host.enableConfigEditor, + }; + + res.json(result); + } catch (err) { + logger.error('Failed to fetch SSH host', err); + res.status(500).json({ error: 'Failed to fetch SSH host' }); + } +}); + // Route: Get all unique folders for the authenticated user (requires JWT) // GET /ssh/folders router.get('/folders', authenticateJWT, async (req: Request, res: Response) => { diff --git a/src/backend/database/routes/ssh_tunnel.ts b/src/backend/database/routes/ssh_tunnel.ts deleted file mode 100644 index 07759bcc..00000000 --- a/src/backend/database/routes/ssh_tunnel.ts +++ /dev/null @@ -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 = {}; - 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; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 00000000..02054139 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants }