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

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

168
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-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",

View File

@@ -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"
},

View File

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

View File

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

View File

@@ -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.");
});
}, []);

View File

@@ -3,7 +3,7 @@ import {
Computer,
Server,
File,
Hammer, ChevronUp, User2
Hammer, ChevronUp, User2, HardDrive
} from "lucide-react";
import {
@@ -16,16 +16,30 @@ import {
SidebarMenuButton,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import Icon from "/public/icon.svg";
import {
Separator,
} from "@/components/ui/separator.tsx"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose
} from "@/components/ui/sheet";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import axios from "axios";
import {Button} from "@/components/ui/button.tsx";
import {Homepage} from "@/apps/Homepage/Homepage.tsx";
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx";
import {SSH} from "@/apps/SSH/Terminal/SSH.tsx";
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
import {Tools} from "@/apps/Tools/Tools.tsx";
interface SidebarProps {
onSelectView: (view: string) => void;
@@ -40,22 +54,22 @@ function handleLogout() {
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const apiBase =
typeof window !== "undefined" && window.location.hostname === "localhost"
? "http://localhost:8081/users"
: "/users";
typeof window !== "undefined" && window.location.hostname === "localhost"
? "http://localhost:8081/users"
: "/users";
const API = axios.create({
baseURL: apiBase,
baseURL: apiBase,
});
export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: SidebarProps): React.ReactElement {
export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, username}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
@@ -72,8 +86,8 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
try {
await API.patch(
"/registration-allowed",
{ allowed: checked },
{ headers: { Authorization: `Bearer ${jwt}` } }
{allowed: checked},
{headers: {Authorization: `Bearer ${jwt}`}}
);
setAllowRegistration(checked);
} catch (e) {
@@ -81,103 +95,120 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
setRegLoading(false);
}
};
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" />
- Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH"}>
<SidebarMenuButton onClick={() => onSelectView("ssh")} disabled={disabled}>
<Computer />
<span>SSH</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"SSH Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_tunnel")} disabled={disabled}>
<Server />
<span>SSH Tunnel</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")} disabled={disabled}>
<File />
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
<Hammer />
<span>Tools</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{ width: '100%' }}
disabled={disabled}
<div>
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}>
<HardDrive/>
<span>SSH Manager</span>
</SidebarMenuButton>
</SidebarMenuItem>
<div className="ml-5">
<SidebarMenuItem key={"Terminal"}>
<SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}>
<Computer/>
<span>Terminal</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
disabled={disabled}>
<Server/>
<span>Tunnel</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
disabled={disabled}>
<File/>
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
<Hammer/>
<span>Tools</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<User2 /> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
{isAdmin && (
<DropdownMenuItem className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" onSelect={() => setAdminSheetOpen(true)}>
<span>Admin Settings</span>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => setAdminSheetOpen(true)}>
<span>Admin Settings</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
)}
<DropdownMenuItem className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" onSelect={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
{isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
<SheetContent side="left" className="w-[320px]">
<SheetHeader>
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-4">
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle} disabled={regLoading} />
Allow new account registration
</label>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2" />
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</Sidebar>
</SidebarProvider>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
{isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
<SheetContent side="left" className="w-[320px]">
<SheetHeader>
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-4">
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</Sidebar>
</SidebarProvider>
</div>
)
}

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef } from "react";
import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar";
import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList";
import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView";
import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor";
import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
import axios from 'axios';
import { Button } from '@/components/ui/button';
import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar";
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button.tsx';
import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import { cn } from '@/lib/utils.ts';
function getJWT() {
return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
@@ -216,11 +216,11 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
return (
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
<div className="flex-1 min-h-0">
<ConfigCodeEditor
<ConfigCodeEditor
content={tab.content}
fileNameOld={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
/>
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
import React, { useState } from "react";
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");
};
const handleFormSubmit = () => {
setEditingHost(null);
setActiveTab("host_viewer");
};
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset editingHost when switching to host_viewer
if (value === "host_viewer") {
setEditingHost(null);
}
};
return (
<div>
<SSHManagerSidebar
onSelectView={onSelectView}
/>
<div className="flex w-screen h-screen overflow-hidden">
<div className="w-[256px]" />
<div className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col h-full min-h-0">
<TabsList>
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
<TabsTrigger value="add_host">
{editingHost ? "Edit Host" : "Add Host"}
</TabsTrigger>
</TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 mt-1 mb-1" />
<SSHManagerHostViewer onEditHost={handleEditHost}/>
</TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 mt-1 mb-1" />
<div className="flex flex-col h-full min-h-0">
<SSHManagerHostEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect, useMemo } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios";
import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
fetchHosts();
}, []);
const fetchHosts = async () => {
try {
setLoading(true);
const data = await getSSHHosts();
setHosts(data);
setError(null);
} catch (err) {
console.error('Failed to fetch hosts:', err);
setError('Failed to load hosts');
} finally {
setLoading(false);
}
};
const handleDelete = async (hostId: number, hostName: string) => {
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
try {
await deleteSSHHost(hostId);
await fetchHosts(); // Refresh the list
} catch (err) {
console.error('Failed to delete host:', err);
alert('Failed to delete host');
}
}
};
const handleEdit = (host: SSHHost) => {
if (onEditHost) {
onEditHost(host);
}
};
// Filter and sort hosts
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => {
const searchableText = [
host.name || '',
host.username,
host.ip,
host.folder || '',
...(host.tags || []),
host.authType,
host.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
}
// Sort: pinned first, then alphabetical by name/username
return filtered.sort((a, b) => {
// First, sort by pin status (pinned hosts first)
if (a.pin && !b.pin) return -1;
if (!a.pin && b.pin) return 1;
// Then sort alphabetically by name or username
const aName = a.name || a.username;
const bName = b.name || b.username;
return aName.localeCompare(bName);
});
}, [hosts, searchQuery]);
// Group hosts by folder
const hostsByFolder = useMemo(() => {
const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => {
const folder = host.folder || 'Uncategorized';
if (!grouped[folder]) {
grouped[folder] = [];
}
grouped[folder].push(host);
});
// Sort folders to ensure "Uncategorized" is always first
const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
return a.localeCompare(b);
});
// Create a new object with sorted folders
const sortedGrouped: { [key: string]: SSHHost[] } = {};
sortedFolders.forEach(folder => {
sortedGrouped[folder] = grouped[folder];
});
return sortedGrouped;
}, [filteredAndSortedHosts]);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading hosts...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchHosts} variant="outline">
Retry
</Button>
</div>
</div>
);
}
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
<p className="text-muted-foreground mb-4">
You haven't added any SSH hosts yet. Click "Add Host" to get started.
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-xl font-semibold">SSH Hosts</h2>
<p className="text-muted-foreground">
{filteredAndSortedHosts.length} hosts
</p>
</div>
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
</Button>
</div>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20">
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
<div key={folder} className="border rounded-md">
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
<AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2">
<Folder className="h-4 w-4" />
<span className="font-medium">{folder}</span>
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="p-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{folderHosts.map((host) => (
<div
key={host.id}
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
onClick={() => handleEdit(host)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{host.pin && <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />}
<h3 className="font-medium truncate text-sm">
{host.name || `${host.username}@${host.ip}`}
</h3>
</div>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port}
</p>
<p className="text-xs text-muted-foreground truncate">
{host.username}
</p>
</div>
<div className="flex gap-1 flex-shrink-0 ml-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleEdit(host);
}}
className="h-5 w-5 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<div className="mt-2 space-y-1">
{/* Tags */}
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5" />
{tag}
</Badge>
))}
{host.tags.length > 6 && (
<Badge variant="outline" className="text-xs px-1 py-0">
+{host.tags.length - 6}
</Badge>
)}
</div>
)}
{/* Features */}
<div className="flex flex-wrap gap-1">
{host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Terminal className="h-2 w-2 mr-0.5" />
Terminal
</Badge>
)}
{host.enableTunnel && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Network className="h-2 w-2 mr-0.5" />
Tunnel
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
<span className="ml-0.5">({host.tunnelConnections.length})</span>
)}
</Badge>
)}
{host.enableConfigEditor && (
<Badge variant="outline" className="text-xs px-1 py-0">
<FileEdit className="h-2 w-2 mr-0.5" />
Config
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / SSH Manager
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from "react";
import { 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 {

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

229
src/apps/SSH/ssh-axios.ts Normal file
View File

@@ -0,0 +1,229 @@
// SSH Host Management API functions
import axios from 'axios';
interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: 'password' | 'key';
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableConfigEditor?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
// Determine the base URL based on environment
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
// Create axios instance with base configuration
const api = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
// Add request interceptor to include JWT token
api.interceptors.request.use((config) => {
const token = getCookie('jwt'); // Adjust based on your token storage
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Get all SSH hosts
export async function getSSHHosts(): Promise<SSHHost[]> {
try {
const response = await api.get('/ssh/host');
return response.data;
} catch (error) {
console.error('Error fetching SSH hosts:', error);
throw error;
}
}
// Create new SSH host
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
// Prepare the data according to your backend schema
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [], // Array of strings
pin: hostData.pin || false,
authMethod: hostData.authType, // Backend expects 'authMethod'
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false, // Default to true
enableTunnel: hostData.enableTunnel !== false, // Default to true
enableConfigEditor: hostData.enableConfigEditor !== false, // Default to true
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [], // Array of tunnel objects
};
// If tunnel is disabled, clear tunnel data
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
// If config editor is disabled, clear config data
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
// Handle file upload for SSH key
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
// Add the file
formData.append('key', hostData.key);
// Add all other data as JSON string
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
// Submit with FormData
const response = await api.post('/ssh/host', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
// Submit with JSON
const response = await api.post('/ssh/host', submitData);
return response.data;
}
} catch (error) {
console.error('Error creating SSH host:', error);
throw error;
}
}
// Update existing SSH host
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
// Handle disabled features
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
// Handle file upload for SSH key
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await api.put(`/ssh/host/${hostId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await api.put(`/ssh/host/${hostId}`, submitData);
return response.data;
}
} catch (error) {
console.error('Error updating SSH host:', error);
throw error;
}
}
// Delete SSH host
export async function deleteSSHHost(hostId: number): Promise<any> {
try {
const response = await api.delete(`/ssh/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error deleting SSH host:', error);
throw error;
}
}
// Get SSH host by ID
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try {
const response = await api.get(`/ssh/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error fetching SSH host:', error);
throw error;
}
}
export { api };

View File

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

View File

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

View File

@@ -2,21 +2,16 @@ import express from 'express';
import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import sshTunnelRoutes from './routes/ssh_tunnel.js';
import configEditorRoutes from './routes/config_editor.js';
import chalk from 'chalk';
import cors from 'cors';
// CORS for local dev
const app = express();
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Custom logger (adapted from starter.ts, with a database icon)
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
@@ -51,14 +46,16 @@ app.get('/health', (req, res) => {
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
app.use('/ssh_tunnel', sshTunnelRoutes);
app.use('/config_editor', configEditorRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
// Start server
const PORT = 8081;
app.listen(PORT);
app.listen(PORT, () => {
logger.success(`Database server started on port ${PORT}`);
}).on('error', (err) => {
logger.error(`Failed to start database server:`, err);
process.exit(1);
});

View File

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

View File

@@ -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`),
});

View File

@@ -1,317 +0,0 @@
import express from 'express';
import { db } from '../db/index.js';
import { configEditorData, configSshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const router = express.Router();
// --- JWT Auth Middleware ---
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// --- Config Data Endpoints (DB-backed, per user) ---
router.get('/recent', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'recent')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch recent files' });
}
});
router.post('/recent', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: filePath, server, lastOpened } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'recent',
name,
path: filePath,
server: server ? JSON.stringify(server) : null,
lastOpened: lastOpened || now,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to recent' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to recent' });
}
});
router.get('/pinned', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'pinned')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch pinned files' });
}
});
router.post('/pinned', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: filePath, server } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'pinned',
name,
path: filePath,
server: server ? JSON.stringify(server) : null,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to pinned' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to pinned' });
}
});
router.get('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch shortcuts' });
}
});
router.post('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: folderPath, server } = req.body;
if (!folderPath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'shortcut',
name,
path: folderPath,
server: server ? JSON.stringify(server) : null,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to shortcuts' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to shortcuts' });
}
});
// DELETE /config_editor/shortcuts
router.delete('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'Missing path' });
try {
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
res.json({ message: 'Shortcut removed' });
} catch (err) {
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
// POST /config_editor/shortcuts/delete (for compatibility)
router.post('/shortcuts/delete', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'Missing path' });
try {
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
res.json({ message: 'Shortcut removed' });
} catch (err) {
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
// --- Local Default Path Endpoints ---
// GET /config_editor/local_default_path
router.get('/local_default_path', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const row = await db.select().from(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')))
.then(rows => rows[0]);
res.json({ defaultPath: row?.path || '/' });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch local default path' });
}
});
// POST /config_editor/local_default_path
router.post('/local_default_path', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { defaultPath } = req.body;
if (!defaultPath) return res.status(400).json({ error: 'Missing defaultPath' });
try {
const now = new Date().toISOString();
// Upsert: delete old, insert new
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')));
await db.insert(configEditorData).values({
userId,
type: 'local_default_path',
name: 'Local Files',
path: defaultPath,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Local default path saved' });
} catch (err) {
res.status(500).json({ error: 'Failed to save local default path' });
}
});
// --- SSH Connection CRUD for Config Editor ---
// GET /config_editor/ssh/host
router.get('/ssh/host', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db.select().from(configSshData).where(eq(configSshData.userId, userId));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch SSH hosts' });
}
});
// POST /config_editor/ssh/host
router.post('/ssh/host', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
if (!userId || !ip || !port) {
return res.status(400).json({ error: 'Invalid SSH data' });
}
const sshDataObj: any = {
userId,
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
ip,
port,
username,
authMethod,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = sshKey;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
await db.insert(configSshData).values(sshDataObj);
res.json({ message: 'SSH host created' });
} catch (err) {
res.status(500).json({ error: 'Failed to create SSH host' });
}
});
// PUT /config_editor/ssh/host/:id
router.put('/ssh/host/:id', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { id } = req.params;
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
if (!userId || !ip || !port || !id) {
return res.status(400).json({ error: 'Invalid SSH data' });
}
const sshDataObj: any = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
ip,
port,
username,
authMethod,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = sshKey;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
await db.update(configSshData)
.set(sshDataObj)
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
res.json({ message: 'SSH host updated' });
} catch (err) {
res.status(500).json({ error: 'Failed to update SSH host' });
}
});
// --- SSH Connection CRUD (reuse /ssh/host endpoints, or proxy) ---
router.delete('/ssh/host/:id', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!userId || !id) {
return res.status(400).json({ error: 'Invalid userId or id' });
}
try {
await db.delete(configSshData)
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
res.json({ message: 'SSH host deleted' });
} catch (err) {
res.status(500).json({ error: 'Failed to delete SSH host' });
}
});
// GET /config_editor/ssh/folders
router.get('/ssh/folders', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select({ folder: configSshData.folder })
.from(configSshData)
.where(eq(configSshData.userId, userId));
const folderCounts: Record<string, number> = {};
data.forEach(d => {
if (d.folder && d.folder.trim() !== '') {
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
res.json(folders);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch SSH folders' });
}
});
export default router;

View File

@@ -4,6 +4,7 @@ import { sshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import 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) => {

View File

@@ -1,306 +0,0 @@
import express from 'express';
import { db } from '../db/index.js';
import { sshTunnelData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import type { Request, Response, NextFunction } from 'express';
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
const router = express.Router();
function isNonEmptyString(val: any): val is string {
return typeof val === 'string' && val.trim().length > 0;
}
function isValidPort(val: any): val is number {
return typeof val === 'number' && val > 0 && val < 65536;
}
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
// Only allow bypass if X-Internal-Request header is set
if (req.headers['x-internal-request'] === '1') {
(req as any).userId = 'internal_service';
return next();
}
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Missing or invalid Authorization header');
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
logger.warn('Invalid or expired token');
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Route: Create SSH tunnel data (requires JWT)
// POST /ssh_tunnel/tunnel
router.post('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
const {
name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
} = req.body;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
!isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
!isValidPort(endpointSSHPort)) {
logger.warn('Invalid SSH tunnel data input');
return res.status(400).json({ error: 'Invalid SSH tunnel data' });
}
const sshTunnelDataObj: any = {
userId: userId,
name,
folder,
sourcePort,
endpointPort,
sourceIP,
sourceSSHPort,
sourceUsername,
sourceAuthMethod,
endpointIP,
endpointSSHPort,
endpointUsername,
endpointAuthMethod,
maxRetries: maxRetries || 3,
retryInterval: retryInterval || 5000,
connectionState: 'DISCONNECTED',
autoStart: autoStart || false,
isPinned: isPinned || false
};
// Handle source authentication
if (sourceAuthMethod === 'password') {
sshTunnelDataObj.sourcePassword = sourcePassword;
sshTunnelDataObj.sourceSSHKey = null;
sshTunnelDataObj.sourceKeyPassword = null;
sshTunnelDataObj.sourceKeyType = null;
} else if (sourceAuthMethod === 'key') {
sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
sshTunnelDataObj.sourceKeyType = sourceKeyType;
sshTunnelDataObj.sourcePassword = null;
}
// Handle endpoint authentication
if (endpointAuthMethod === 'password') {
sshTunnelDataObj.endpointPassword = endpointPassword;
sshTunnelDataObj.endpointSSHKey = null;
sshTunnelDataObj.endpointKeyPassword = null;
sshTunnelDataObj.endpointKeyType = null;
} else if (endpointAuthMethod === 'key') {
sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
sshTunnelDataObj.endpointKeyType = endpointKeyType;
sshTunnelDataObj.endpointPassword = null;
}
try {
await db.insert(sshTunnelData).values(sshTunnelDataObj);
res.json({ message: 'SSH tunnel data created' });
} catch (err) {
logger.error('Failed to save SSH tunnel data', err);
res.status(500).json({ error: 'Failed to save SSH tunnel data' });
}
});
// Route: Update SSH tunnel data (requires JWT)
// PUT /ssh_tunnel/tunnel/:id
router.put('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
const {
name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
} = req.body;
const { id } = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
!isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
!isValidPort(endpointSSHPort) || !id) {
logger.warn('Invalid SSH tunnel data input for update');
return res.status(400).json({ error: 'Invalid SSH tunnel data' });
}
const sshTunnelDataObj: any = {
name,
folder,
sourcePort,
endpointPort,
sourceIP,
sourceSSHPort,
sourceUsername,
sourceAuthMethod,
endpointIP,
endpointSSHPort,
endpointUsername,
endpointAuthMethod,
maxRetries: maxRetries || 3,
retryInterval: retryInterval || 5000,
autoStart: autoStart || false,
isPinned: isPinned || false
};
// Handle source authentication
if (sourceAuthMethod === 'password') {
sshTunnelDataObj.sourcePassword = sourcePassword;
sshTunnelDataObj.sourceSSHKey = null;
sshTunnelDataObj.sourceKeyPassword = null;
sshTunnelDataObj.sourceKeyType = null;
} else if (sourceAuthMethod === 'key') {
sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
sshTunnelDataObj.sourceKeyType = sourceKeyType;
sshTunnelDataObj.sourcePassword = null;
}
// Handle endpoint authentication
if (endpointAuthMethod === 'password') {
sshTunnelDataObj.endpointPassword = endpointPassword;
sshTunnelDataObj.endpointSSHKey = null;
sshTunnelDataObj.endpointKeyPassword = null;
sshTunnelDataObj.endpointKeyType = null;
} else if (endpointAuthMethod === 'key') {
sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
sshTunnelDataObj.endpointKeyType = endpointKeyType;
sshTunnelDataObj.endpointPassword = null;
}
try {
const result = await db.update(sshTunnelData)
.set(sshTunnelDataObj)
.where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
res.json({ message: 'SSH tunnel data updated' });
} catch (err) {
logger.error('Failed to update SSH tunnel data', err);
res.status(500).json({ error: 'Failed to update SSH tunnel data' });
}
});
// Route: Get SSH tunnel data for the authenticated user (requires JWT)
// GET /ssh_tunnel/tunnel
router.get('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
// If internal request and allAutoStart=1, return all autoStart tunnels
if (req.headers['x-internal-request'] === '1' && req.query.allAutoStart === '1') {
try {
const data = await db
.select()
.from(sshTunnelData)
.where(eq(sshTunnelData.autoStart, true));
return res.json(data);
} catch (err) {
logger.error('Failed to fetch all auto-start SSH tunnel data', err);
return res.status(500).json({ error: 'Failed to fetch auto-start SSH tunnel data' });
}
}
// Default: filter by userId
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH tunnel data fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select()
.from(sshTunnelData)
.where(eq(sshTunnelData.userId, userId));
res.json(data);
} catch (err) {
logger.error('Failed to fetch SSH tunnel data', err);
res.status(500).json({ error: 'Failed to fetch SSH tunnel data' });
}
});
// Route: Get all unique folders for the authenticated user (requires JWT)
// GET /ssh_tunnel/folders
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH tunnel folder fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select({ folder: sshTunnelData.folder })
.from(sshTunnelData)
.where(eq(sshTunnelData.userId, userId));
const folderCounts: Record<string, number> = {};
data.forEach(d => {
if (d.folder && d.folder.trim() !== '') {
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
res.json(folders);
} catch (err) {
logger.error('Failed to fetch SSH tunnel folders', err);
res.status(500).json({ error: 'Failed to fetch SSH tunnel folders' });
}
});
// Route: Delete SSH tunnel by id (requires JWT)
// DELETE /ssh_tunnel/tunnel/:id
router.delete('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid userId or id for SSH tunnel delete');
return res.status(400).json({ error: 'Invalid userId or id' });
}
try {
const result = await db.delete(sshTunnelData)
.where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
res.json({ message: 'SSH tunnel deleted' });
} catch (err) {
logger.error('Failed to delete SSH tunnel', err);
res.status(500).json({ error: 'Failed to delete SSH tunnel' });
}
});
export default router;

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }