Completed intial SSH section with user/ssh backend
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
/db/
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ For production environments, we recommend running the website via Nginx. See the
|
|||||||
```bash
|
```bash
|
||||||
cd src/backend
|
cd src/backend
|
||||||
node database.cjs
|
node database.cjs
|
||||||
node ssh.cjs
|
node ssh.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the WebSocket services on ports 8081 and 8082.
|
This will start the WebSocket services on ports 8081 and 8082.
|
||||||
|
|||||||
2256
package-lock.json
generated
2256
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -6,16 +6,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:backend": "tsc -p tsconfig.node.json",
|
||||||
|
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
@@ -24,14 +29,24 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@xterm/addon-attach": "^0.11.0",
|
"@xterm/addon-attach": "^0.11.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-unicode11": "^0.8.0",
|
"@xterm/addon-unicode11": "^0.8.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"drizzle-orm": "^0.44.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
@@ -45,14 +60,22 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
|
import { HomepageSidebar } from "@/apps/Homepage/HomepageSidebar.tsx";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { HomepageAuth } from "@/apps/Homepage/HomepageAuth.tsx";
|
||||||
|
|
||||||
interface HomepageProps {
|
interface HomepageProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement {
|
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement {
|
||||||
|
const [loggedIn, setLoggedIn] = useState(false);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex min-h-screen">
|
||||||
<HomepageSidebar
|
<HomepageSidebar onSelectView={onSelectView} disabled={!loggedIn} isAdmin={isAdmin} username={loggedIn ? username : null} />
|
||||||
onSelectView={onSelectView}
|
<div className="flex-1 bg-background" />
|
||||||
/>
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 flex justify-center items-center z-50"
|
||||||
|
style={{ left: 256 }}
|
||||||
|
>
|
||||||
|
<HomepageAuth setLoggedIn={setLoggedIn} setIsAdmin={setIsAdmin} setUsername={setUsername} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
287
src/apps/Homepage/HomepageAuth.tsx
Normal file
287
src/apps/Homepage/HomepageAuth.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
function setCookie(name: string, value: string, days = 7) {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
function getCookie(name: string) {
|
||||||
|
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";
|
||||||
|
|
||||||
|
const API = axios.create({
|
||||||
|
baseURL: apiBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||||
|
setLoggedIn: (loggedIn: boolean) => void;
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void;
|
||||||
|
setUsername: (username: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername, ...props }: HomepageAuthProps) {
|
||||||
|
const [tab, setTab] = useState<"login" | "signup">("login");
|
||||||
|
const [localUsername, setLocalUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||||
|
const [firstUser, setFirstUser] = useState(false);
|
||||||
|
const [dbError, setDbError] = useState<string | null>(null);
|
||||||
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
API.get("/registration-allowed").then(res => {
|
||||||
|
setRegistrationAllowed(res.data.allowed);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
API.get("/count").then(res => {
|
||||||
|
if (res.data.count === 0) {
|
||||||
|
setFirstUser(true);
|
||||||
|
setTab("signup");
|
||||||
|
} else {
|
||||||
|
setFirstUser(false);
|
||||||
|
}
|
||||||
|
setDbError(null);
|
||||||
|
}).catch(() => {
|
||||||
|
setFirstUser(true);
|
||||||
|
setTab("signup");
|
||||||
|
setDbError("Could not connect to the database. Please try again later.");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (jwt) {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
API.get("/me", { headers: { Authorization: `Bearer ${jwt}` } }),
|
||||||
|
API.get("/db-health")
|
||||||
|
])
|
||||||
|
.then(([meRes]) => {
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.data.is_admin);
|
||||||
|
setUsername(meRes.data.username || null);
|
||||||
|
setDbError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setCookie("jwt", "", -1);
|
||||||
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
|
setDbError("Could not connect to the database. Please try again later.");
|
||||||
|
} else {
|
||||||
|
setDbError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
}
|
||||||
|
}, [setLoggedIn, setIsAdmin, setUsername]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let res, meRes;
|
||||||
|
if (tab === "login") {
|
||||||
|
res = await API.post("/get", { username: localUsername, password });
|
||||||
|
} else {
|
||||||
|
await API.post("/create", { username: localUsername, password });
|
||||||
|
res = await API.post("/get", { username: localUsername, password });
|
||||||
|
}
|
||||||
|
setCookie("jwt", res.data.token);
|
||||||
|
[meRes] = await Promise.all([
|
||||||
|
API.get("/me", { headers: { Authorization: `Bearer ${res.data.token}` } }),
|
||||||
|
API.get("/db-health")
|
||||||
|
]);
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.data.is_admin);
|
||||||
|
setUsername(meRes.data.username || null);
|
||||||
|
setDbError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || "Unknown error");
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setCookie("jwt", "", -1);
|
||||||
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
|
setDbError("Could not connect to the database. Please try again later.");
|
||||||
|
} else {
|
||||||
|
setDbError(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Spinner = (
|
||||||
|
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex justify-center items-center min-h-screen bg-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
|
||||||
|
{dbError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{dbError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{firstUser && !dbError && !internalLoggedIn && (
|
||||||
|
<Alert variant="default" className="mb-4">
|
||||||
|
<AlertTitle>First User</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!registrationAllowed && !internalLoggedIn && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Registration Disabled</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
New account registration is currently disabled by an admin. Please log in or contact an administrator.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{(internalLoggedIn || (loading && getCookie("jwt"))) && (
|
||||||
|
<div className="flex flex-1 justify-center items-center p-0 m-0">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Alert className="my-2">
|
||||||
|
<AlertTitle>Logged in!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You are logged in! Use the sidebar to access all tools.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-sm"
|
||||||
|
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-border"></div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-sm"
|
||||||
|
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||||
|
>
|
||||||
|
Feedback
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-border"></div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-sm"
|
||||||
|
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||||
|
>
|
||||||
|
Discord
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!internalLoggedIn && (!loading || !getCookie("jwt"))) && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "login"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => setTab("login")}
|
||||||
|
aria-selected={tab === "login"}
|
||||||
|
disabled={loading || firstUser}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "signup"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => setTab("signup")}
|
||||||
|
aria-selected={tab === "signup"}
|
||||||
|
disabled={loading || !registrationAllowed}
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-xl font-bold mb-1">
|
||||||
|
{tab === "login" ? "Login to your account" : "Create a new account"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" type="password" required className="h-11 text-base" value={password} onChange={e => setPassword(e.target.value)} disabled={loading || internalLoggedIn} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" disabled={loading || internalLoggedIn}>
|
||||||
|
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mt-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
Computer,
|
Computer,
|
||||||
Server,
|
Server,
|
||||||
File,
|
File,
|
||||||
Hammer
|
Hammer, ChevronUp, User2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent, SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
@@ -20,12 +20,66 @@ import {
|
|||||||
import {
|
import {
|
||||||
Separator,
|
Separator,
|
||||||
} from "@/components/ui/separator.tsx"
|
} from "@/components/ui/separator.tsx"
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
username?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomepageSidebar({ onSelectView }: SidebarProps): React.ReactElement {
|
function handleLogout() {
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
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";
|
||||||
|
|
||||||
|
const API = axios.create({
|
||||||
|
baseURL: apiBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: SidebarProps): React.ReactElement {
|
||||||
|
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||||
|
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||||
|
const [regLoading, setRegLoading] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (adminSheetOpen) {
|
||||||
|
API.get("/registration-allowed").then(res => {
|
||||||
|
setAllowRegistration(res.data.allowed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [adminSheetOpen]);
|
||||||
|
const handleToggle = async (checked: boolean) => {
|
||||||
|
setRegLoading(true);
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
try {
|
||||||
|
await API.patch(
|
||||||
|
"/registration-allowed",
|
||||||
|
{ allowed: checked },
|
||||||
|
{ headers: { Authorization: `Bearer ${jwt}` } }
|
||||||
|
);
|
||||||
|
setAllowRegistration(checked);
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
setRegLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -37,45 +91,90 @@ export function HomepageSidebar({ onSelectView }: SidebarProps): React.ReactElem
|
|||||||
<Separator className="p-0.25 mt-1 mb-1" />
|
<Separator className="p-0.25 mt-1 mb-1" />
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
|
||||||
{/* Sidebar Items */}
|
|
||||||
<SidebarMenuItem key={"SSH"}>
|
<SidebarMenuItem key={"SSH"}>
|
||||||
<SidebarMenuButton asChild onClick={() => onSelectView("ssh")}>
|
<SidebarMenuButton onClick={() => onSelectView("ssh")} disabled={disabled}>
|
||||||
<div>
|
<Computer />
|
||||||
<Computer/>
|
<span>SSH</span>
|
||||||
<span>{"SSH"}</span>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem key={"SSH Tunnel"}>
|
<SidebarMenuItem key={"SSH Tunnel"}>
|
||||||
<SidebarMenuButton asChild onClick={() => onSelectView("ssh_tunnel")}>
|
<SidebarMenuButton onClick={() => onSelectView("ssh_tunnel")} disabled={disabled}>
|
||||||
<div>
|
<Server />
|
||||||
<Server/>
|
<span>SSH Tunnel</span>
|
||||||
<span>{"SSH Tunnel"}</span>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem key={"Config Editor"}>
|
<SidebarMenuItem key={"Config Editor"}>
|
||||||
<SidebarMenuButton asChild onClick={() => onSelectView("config_editor")}>
|
<SidebarMenuButton onClick={() => onSelectView("config_editor")} disabled={disabled}>
|
||||||
<div>
|
<File />
|
||||||
<File/>
|
<span>Config Editor</span>
|
||||||
<span>{"Config Editor"}</span>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem key={"Tools"}>
|
<SidebarMenuItem key={"Tools"}>
|
||||||
<SidebarMenuButton asChild onClick={() => onSelectView("tools")}>
|
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
|
||||||
<div>
|
<Hammer />
|
||||||
<Hammer/>
|
<span>Tools</span>
|
||||||
<span>{"Tools"}</span>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</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"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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>
|
</Sidebar>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx";
|
import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx";
|
||||||
import { SSHTerminal } from "./SSHTerminal.tsx";
|
import { SSHTerminal } from "./SSHTerminal.tsx";
|
||||||
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx";
|
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx";
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
interface ConfigEditorProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
@@ -15,37 +16,20 @@ type Tab = {
|
|||||||
terminalRef: React.RefObject<any>;
|
terminalRef: React.RefObject<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TerminalOverlay({ tabId, splitScreen }: { tabId: number, splitScreen: boolean }) {
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = document.getElementById(`terminal-container-${tabId}`);
|
|
||||||
if (el) {
|
|
||||||
el.style.opacity = '1';
|
|
||||||
el.style.zIndex = '10';
|
|
||||||
el.style.left = splitScreen ? '8px' : '0px';
|
|
||||||
el.style.width = splitScreen ? 'calc(100% - 8px)' : '100%';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (el) {
|
|
||||||
el.style.opacity = '0';
|
|
||||||
el.style.zIndex = '1';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [tabId, splitScreen]);
|
|
||||||
return <div style={{ width: '100%', height: '100%', position: 'relative' }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||||
const [allTabs, setAllTabs] = useState<Tab[]>([]);
|
const [allTabs, setAllTabs] = useState<Tab[]>([]);
|
||||||
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
||||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||||
const nextTabId = useRef(1);
|
const nextTabId = useRef(1);
|
||||||
const [splitKey, setSplitKey] = useState(0);
|
|
||||||
|
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
|
||||||
|
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const panelGroupRefs = useRef<{ [key: string]: any }>({});
|
||||||
|
|
||||||
const setActiveTab = (tabId: number) => {
|
const setActiveTab = (tabId: number) => {
|
||||||
setCurrentTab(tabId);
|
setCurrentTab(tabId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to fit all visible terminals
|
|
||||||
const fitVisibleTerminals = () => {
|
const fitVisibleTerminals = () => {
|
||||||
allTabs.forEach((terminal) => {
|
allTabs.forEach((terminal) => {
|
||||||
const isVisible =
|
const isVisible =
|
||||||
@@ -57,7 +41,6 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap setSplitScreenTab to fit before and after
|
|
||||||
const setSplitScreenTab = (tabId: number) => {
|
const setSplitScreenTab = (tabId: number) => {
|
||||||
fitVisibleTerminals();
|
fitVisibleTerminals();
|
||||||
setAllSplitScreenTab((prev) => {
|
setAllSplitScreenTab((prev) => {
|
||||||
@@ -75,7 +58,6 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setCloseTab = (tabId: number) => {
|
const setCloseTab = (tabId: number) => {
|
||||||
// Find the tab and call disconnect on its terminal
|
|
||||||
const tab = allTabs.find((t) => t.id === tabId);
|
const tab = allTabs.find((t) => t.id === tabId);
|
||||||
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
|
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||||
tab.terminalRef.current.disconnect();
|
tab.terminalRef.current.disconnect();
|
||||||
@@ -88,290 +70,312 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render all terminals absolutely positioned, always mounted
|
const updatePanelRects = () => {
|
||||||
const renderAllTerminals = () => (
|
setPanelRects((prev) => {
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
|
const next: Record<string, DOMRect | null> = { ...prev };
|
||||||
{allTabs.map((tab) => (
|
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
||||||
<div
|
if (ref) {
|
||||||
key={tab.id}
|
next[id] = ref.getBoundingClientRect();
|
||||||
id={`terminal-container-${tab.id}`}
|
}
|
||||||
style={{
|
});
|
||||||
position: 'absolute',
|
return next;
|
||||||
top: 0,
|
});
|
||||||
left: 0,
|
};
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 1,
|
|
||||||
opacity: 0,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
transition: 'opacity 0.15s',
|
|
||||||
}}
|
|
||||||
data-terminal-id={tab.id}
|
|
||||||
>
|
|
||||||
<SSHTerminal
|
|
||||||
key={tab.id}
|
|
||||||
ref={tab.terminalRef}
|
|
||||||
hostConfig={tab.hostConfig}
|
|
||||||
isVisible={false}
|
|
||||||
title={tab.title}
|
|
||||||
showTitle={false}
|
|
||||||
splitScreen={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper to show a terminal in a panel by toggling zIndex/opacity
|
useEffect(() => {
|
||||||
const showTerminal = (tab: Tab, splitScreen: boolean) => (
|
const observers: ResizeObserver[] = [];
|
||||||
<TerminalOverlay tabId={tab.id} splitScreen={splitScreen} />
|
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
||||||
);
|
if (ref) {
|
||||||
|
const observer = new ResizeObserver(() => updatePanelRects());
|
||||||
|
observer.observe(ref);
|
||||||
|
observers.push(observer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updatePanelRects();
|
||||||
|
return () => {
|
||||||
|
observers.forEach((observer) => observer.disconnect());
|
||||||
|
};
|
||||||
|
}, [allSplitScreenTab, currentTab, allTabs.length]);
|
||||||
|
|
||||||
const renderTerminals = () => {
|
const renderAllTerminals = () => {
|
||||||
if (allSplitScreenTab.length === 0) {
|
const layoutStyles: Record<number, React.CSSProperties> = {};
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{allTabs.map((tab) => (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
zIndex: tab.id === currentTab ? 10 : 1,
|
|
||||||
opacity: tab.id === currentTab ? 1 : 0,
|
|
||||||
transition: 'opacity 0.15s',
|
|
||||||
marginTop: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalOverlay tabId={tab.id} splitScreen={false} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split screen logic
|
|
||||||
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
||||||
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== currentTab)].filter((t): t is Tab => !!t);
|
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
|
||||||
|
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||||
|
layoutStyles[mainTab.id] = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
layoutTabs.forEach((tab) => {
|
||||||
|
const rect = panelRects[String(tab.id)];
|
||||||
|
if (rect) {
|
||||||
|
const parentRect = panelRefs.current['parent']?.getBoundingClientRect();
|
||||||
|
let top = rect.top, left = rect.left, width = rect.width, height = rect.height;
|
||||||
|
if (parentRect) {
|
||||||
|
top = rect.top - parentRect.top;
|
||||||
|
left = rect.left - parentRect.left;
|
||||||
|
}
|
||||||
|
layoutStyles[tab.id] = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: top + 28,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
height: height - 28,
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div ref={el => { panelRefs.current['parent'] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 1, overflow: 'hidden' }}>
|
||||||
|
{allTabs.map((tab) => {
|
||||||
|
const style = layoutStyles[tab.id]
|
||||||
|
? { ...layoutStyles[tab.id], overflow: 'hidden' }
|
||||||
|
: { display: 'none', overflow: 'hidden' };
|
||||||
|
const isVisible = !!layoutStyles[tab.id];
|
||||||
|
return (
|
||||||
|
<div key={tab.id} style={style} data-terminal-id={tab.id}>
|
||||||
|
<SSHTerminal
|
||||||
|
key={tab.id}
|
||||||
|
ref={tab.terminalRef}
|
||||||
|
hostConfig={tab.hostConfig}
|
||||||
|
isVisible={isVisible}
|
||||||
|
title={tab.title}
|
||||||
|
showTitle={false}
|
||||||
|
splitScreen={allSplitScreenTab.length > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSplitOverlays = () => {
|
||||||
|
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
||||||
|
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
||||||
|
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
|
||||||
|
if (allSplitScreenTab.length === 0) return null;
|
||||||
|
|
||||||
// 2 splits: horizontal
|
|
||||||
if (layoutTabs.length === 2) {
|
if (layoutTabs.length === 2) {
|
||||||
const [tab1, tab2] = layoutTabs;
|
const [tab1, tab2] = layoutTabs;
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup key={splitKey} direction="horizontal" className="h-full w-full">
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<ResizablePrimitive.PanelGroup
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||||
<div style={{
|
direction="horizontal"
|
||||||
background: '#18181b',
|
className="h-full w-full"
|
||||||
color: '#fff',
|
id="main-horizontal"
|
||||||
fontSize: 13,
|
>
|
||||||
height: 28,
|
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
|
||||||
lineHeight: '28px',
|
<div ref={el => { panelRefs.current[String(tab1.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
padding: '0 10px',
|
<div style={{
|
||||||
borderBottom: '1px solid #222224',
|
background: '#18181b',
|
||||||
letterSpacing: 1,
|
color: '#fff',
|
||||||
margin: 0,
|
fontSize: 13,
|
||||||
}}>{tab1.title}</div>
|
height: 28,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
lineHeight: '28px',
|
||||||
{showTerminal(tab1, true)}
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
}}>{tab1.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||||
<ResizableHandle />
|
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
|
||||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<div ref={el => { panelRefs.current[String(tab2.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
<div style={{
|
||||||
<div style={{
|
background: '#18181b',
|
||||||
background: '#18181b',
|
color: '#fff',
|
||||||
color: '#fff',
|
fontSize: 13,
|
||||||
fontSize: 13,
|
height: 28,
|
||||||
height: 28,
|
lineHeight: '28px',
|
||||||
lineHeight: '28px',
|
padding: '0 10px',
|
||||||
padding: '0 10px',
|
borderBottom: '1px solid #222224',
|
||||||
borderBottom: '1px solid #222224',
|
letterSpacing: 1,
|
||||||
letterSpacing: 1,
|
margin: 0,
|
||||||
margin: 0,
|
pointerEvents: 'auto',
|
||||||
}}>{tab2.title}</div>
|
zIndex: 11,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
}}>{tab2.title}</div>
|
||||||
{showTerminal(tab2, true)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
</ResizablePrimitive.PanelGroup>
|
||||||
</ResizablePanelGroup>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3 splits: vertical group (top: horizontal with 2, bottom: single)
|
|
||||||
if (layoutTabs.length === 3) {
|
if (layoutTabs.length === 3) {
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup key={splitKey} direction="vertical" className="h-full w-full">
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<ResizablePrimitive.PanelGroup
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||||
{/* Left/top panel */}
|
direction="vertical"
|
||||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
className="h-full w-full"
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
id="main-vertical"
|
||||||
<div style={{
|
>
|
||||||
background: '#18181b',
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||||
color: '#fff',
|
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||||
fontSize: 13,
|
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||||
height: 28,
|
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
lineHeight: '28px',
|
<div style={{
|
||||||
padding: '0 10px',
|
background: '#18181b',
|
||||||
borderBottom: '1px solid #222224',
|
color: '#fff',
|
||||||
letterSpacing: 1,
|
fontSize: 13,
|
||||||
margin: 0,
|
height: 28,
|
||||||
display: 'flex',
|
lineHeight: '28px',
|
||||||
alignItems: 'center',
|
padding: '0 10px',
|
||||||
justifyContent: 'space-between',
|
borderBottom: '1px solid #222224',
|
||||||
}}>{layoutTabs[0].title}</div>
|
letterSpacing: 1,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
margin: 0,
|
||||||
{showTerminal(layoutTabs[0], true)}
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
}}>{layoutTabs[0].title}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||||
<ResizableHandle />
|
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||||
{/* Right/top panel (no reset button here) */}
|
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<div style={{
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
background: '#18181b',
|
||||||
<div style={{
|
color: '#fff',
|
||||||
background: '#18181b',
|
fontSize: 13,
|
||||||
color: '#fff',
|
height: 28,
|
||||||
fontSize: 13,
|
lineHeight: '28px',
|
||||||
height: 28,
|
padding: '0 10px',
|
||||||
lineHeight: '28px',
|
borderBottom: '1px solid #222224',
|
||||||
padding: '0 10px',
|
letterSpacing: 1,
|
||||||
borderBottom: '1px solid #222224',
|
margin: 0,
|
||||||
letterSpacing: 1,
|
pointerEvents: 'auto',
|
||||||
margin: 0,
|
zIndex: 11,
|
||||||
display: 'flex',
|
}}>{layoutTabs[1].title}</div>
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}>
|
|
||||||
<span>{layoutTabs[1].title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
</ResizablePanel>
|
||||||
{showTerminal(layoutTabs[1], true)}
|
</ResizablePanelGroup>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</div>
|
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||||
</ResizablePanel>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||||
</ResizablePanelGroup>
|
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
</ResizablePanel>
|
<div style={{
|
||||||
<ResizableHandle />
|
background: '#18181b',
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
color: '#fff',
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
fontSize: 13,
|
||||||
<div style={{
|
height: 28,
|
||||||
background: '#18181b',
|
lineHeight: '28px',
|
||||||
color: '#fff',
|
padding: '0 10px',
|
||||||
fontSize: 13,
|
borderBottom: '1px solid #222224',
|
||||||
height: 28,
|
letterSpacing: 1,
|
||||||
lineHeight: '28px',
|
margin: 0,
|
||||||
padding: '0 10px',
|
pointerEvents: 'auto',
|
||||||
borderBottom: '1px solid #222224',
|
zIndex: 11,
|
||||||
letterSpacing: 1,
|
}}>{layoutTabs[2].title}</div>
|
||||||
margin: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>{layoutTabs[2].title}</div>
|
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
|
||||||
{showTerminal(layoutTabs[2], true)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
</ResizablePrimitive.PanelGroup>
|
||||||
</ResizablePanelGroup>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4 splits: 2x2 grid (vertical group with two horizontal groups)
|
|
||||||
if (layoutTabs.length === 4) {
|
if (layoutTabs.length === 4) {
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup key={splitKey} direction="vertical" className="h-full w-full">
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<ResizablePrimitive.PanelGroup
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
direction="vertical"
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
className="h-full w-full"
|
||||||
<div style={{
|
id="main-vertical"
|
||||||
background: '#18181b',
|
>
|
||||||
color: '#fff',
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||||
fontSize: 13,
|
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||||
height: 28,
|
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||||
lineHeight: '28px',
|
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
padding: '0 10px',
|
<div style={{
|
||||||
borderBottom: '1px solid #222224',
|
background: '#18181b',
|
||||||
letterSpacing: 1,
|
color: '#fff',
|
||||||
margin: 0,
|
fontSize: 13,
|
||||||
}}>{layoutTabs[0].title}</div>
|
height: 28,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
lineHeight: '28px',
|
||||||
{showTerminal(layoutTabs[0], true)}
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
}}>{layoutTabs[0].title}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||||
<ResizableHandle />
|
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
<div style={{
|
||||||
<div style={{
|
background: '#18181b',
|
||||||
background: '#18181b',
|
color: '#fff',
|
||||||
color: '#fff',
|
fontSize: 13,
|
||||||
fontSize: 13,
|
height: 28,
|
||||||
height: 28,
|
lineHeight: '28px',
|
||||||
lineHeight: '28px',
|
padding: '0 10px',
|
||||||
padding: '0 10px',
|
borderBottom: '1px solid #222224',
|
||||||
borderBottom: '1px solid #222224',
|
letterSpacing: 1,
|
||||||
letterSpacing: 1,
|
margin: 0,
|
||||||
margin: 0,
|
pointerEvents: 'auto',
|
||||||
}}>{layoutTabs[1].title}</div>
|
zIndex: 11,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
}}>{layoutTabs[1].title}</div>
|
||||||
{showTerminal(layoutTabs[1], true)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
</ResizablePanelGroup>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||||
<ResizableHandle />
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<ResizablePanelGroup ref={el => { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[2].id}`} order={1}>
|
||||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
<div style={{
|
||||||
<div style={{
|
background: '#18181b',
|
||||||
background: '#18181b',
|
color: '#fff',
|
||||||
color: '#fff',
|
fontSize: 13,
|
||||||
fontSize: 13,
|
height: 28,
|
||||||
height: 28,
|
lineHeight: '28px',
|
||||||
lineHeight: '28px',
|
padding: '0 10px',
|
||||||
padding: '0 10px',
|
borderBottom: '1px solid #222224',
|
||||||
borderBottom: '1px solid #222224',
|
letterSpacing: 1,
|
||||||
letterSpacing: 1,
|
margin: 0,
|
||||||
margin: 0,
|
pointerEvents: 'auto',
|
||||||
}}>{layoutTabs[2].title}</div>
|
zIndex: 11,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
}}>{layoutTabs[2].title}</div>
|
||||||
{showTerminal(layoutTabs[2], true)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||||
<ResizableHandle />
|
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[3].id}`} order={2}>
|
||||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
<div ref={el => { panelRefs.current[String(layoutTabs[3].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
<div style={{
|
||||||
<div style={{
|
background: '#18181b',
|
||||||
background: '#18181b',
|
color: '#fff',
|
||||||
color: '#fff',
|
fontSize: 13,
|
||||||
fontSize: 13,
|
height: 28,
|
||||||
height: 28,
|
lineHeight: '28px',
|
||||||
lineHeight: '28px',
|
padding: '0 10px',
|
||||||
padding: '0 10px',
|
borderBottom: '1px solid #222224',
|
||||||
borderBottom: '1px solid #222224',
|
letterSpacing: 1,
|
||||||
letterSpacing: 1,
|
margin: 0,
|
||||||
margin: 0,
|
pointerEvents: 'auto',
|
||||||
}}>{layoutTabs[3].title}</div>
|
zIndex: 11,
|
||||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
}}>{layoutTabs[3].title}</div>
|
||||||
{showTerminal(layoutTabs[3], true)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
</ResizablePanelGroup>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
</ResizablePrimitive.PanelGroup>
|
||||||
</ResizablePanelGroup>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -392,16 +396,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLayoutStyle = () => {
|
const getUniqueTabTitle = (baseTitle: string) => {
|
||||||
if (allSplitScreenTab.length === 0) {
|
let title = baseTitle;
|
||||||
return "flex flex-col h-full w-full";
|
let count = 1;
|
||||||
} else if (allSplitScreenTab.length === 1) {
|
const existingTitles = allTabs.map(t => t.title);
|
||||||
return "grid grid-cols-2 h-full w-full";
|
while (existingTitles.includes(title)) {
|
||||||
} else if (allSplitScreenTab.length === 2) {
|
title = `${baseTitle} (${count})`;
|
||||||
return "grid grid-cols-2 grid-rows-2 h-full w-full";
|
count++;
|
||||||
} else {
|
|
||||||
return "grid grid-cols-2 grid-rows-2 h-full w-full";
|
|
||||||
}
|
}
|
||||||
|
return title;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHostConnect = (hostConfig: any) => {
|
||||||
|
const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`;
|
||||||
|
const title = getUniqueTabTitle(baseTitle);
|
||||||
|
const terminalRef = React.createRef<any>();
|
||||||
|
const id = nextTabId.current++;
|
||||||
|
const newTab: Tab = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
hostConfig,
|
||||||
|
terminalRef,
|
||||||
|
};
|
||||||
|
setAllTabs((prev) => [...prev, newTab]);
|
||||||
|
setCurrentTab(id);
|
||||||
|
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -411,6 +430,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
<SSHSidebar
|
<SSHSidebar
|
||||||
onSelectView={onSelectView}
|
onSelectView={onSelectView}
|
||||||
onAddHostSubmit={onAddHostSubmit}
|
onAddHostSubmit={onAddHostSubmit}
|
||||||
|
onHostConnect={onHostConnect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Main area: fills the rest */}
|
{/* Main area: fills the rest */}
|
||||||
@@ -439,7 +459,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
{/* Split area below the topbar */}
|
{/* Split area below the topbar */}
|
||||||
<div style={{ height: 'calc(100% - 46px)', marginTop: 46, position: 'relative' }}>
|
<div style={{ height: 'calc(100% - 46px)', marginTop: 46, position: 'relative' }}>
|
||||||
{/* Absolutely render all terminals for persistence */}
|
{/* Show alert when no terminals are rendered */}
|
||||||
|
{allTabs.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
background: '#18181b',
|
||||||
|
border: '1px solid #434345',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#f7f7f7',
|
||||||
|
maxWidth: '400px',
|
||||||
|
zIndex: 30
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||||
|
Welcome to Termix SSH
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5' }}>
|
||||||
|
Click on any host title in the sidebar to open a terminal connection, or use the "Add Host" button to create a new connection.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Absolutely render all terminals for persistence and layout */}
|
||||||
{allSplitScreenTab.length > 0 && (
|
{allSplitScreenTab.length > 0 && (
|
||||||
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28 }}>
|
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28 }}>
|
||||||
<button
|
<button
|
||||||
@@ -459,14 +503,27 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
onClick={() => setSplitKey((k) => k + 1)}
|
onClick={() => {
|
||||||
|
if (allSplitScreenTab.length === 1) {
|
||||||
|
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||||
|
}
|
||||||
|
else if (allSplitScreenTab.length === 2) {
|
||||||
|
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||||
|
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
||||||
|
}
|
||||||
|
else if (allSplitScreenTab.length === 3) {
|
||||||
|
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||||
|
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
||||||
|
panelGroupRefs.current['bottom']?.setLayout([50, 50]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Reset Split Sizes
|
Reset Split Sizes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderAllTerminals()}
|
{renderAllTerminals()}
|
||||||
{renderTerminals()}
|
{renderSplitOverlays()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
||||||
import { useXTerm } from 'react-xtermjs';
|
import { useXTerm } from 'react-xtermjs';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||||
@@ -15,7 +15,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
{ hostConfig, isVisible, splitScreen = false },
|
{ hostConfig, isVisible, splitScreen = false },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
console.log('Rendering SSHTerminal', { hostConfig, isVisible });
|
|
||||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const webSocketRef = useRef<WebSocket | null>(null);
|
const webSocketRef = useRef<WebSocket | null>(null);
|
||||||
@@ -75,7 +74,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
|
|
||||||
// Always send cols + 1
|
|
||||||
const cols = terminal.cols + 1;
|
const cols = terminal.cols + 1;
|
||||||
const rows = terminal.rows;
|
const rows = terminal.rows;
|
||||||
|
|
||||||
@@ -93,7 +91,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
|
||||||
// Always send cols + 1
|
|
||||||
const cols = terminal.cols + 1;
|
const cols = terminal.cols + 1;
|
||||||
const rows = terminal.rows;
|
const rows = terminal.rows;
|
||||||
|
|
||||||
@@ -101,8 +98,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
terminal.writeln('WebSocket opened');
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'connectToHost',
|
type: 'connectToHost',
|
||||||
data: {
|
data: {
|
||||||
@@ -123,16 +118,13 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
ws.addEventListener('message', (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
console.log('WS message received:', msg); // Debug log
|
|
||||||
|
|
||||||
if (msg.type === 'data') {
|
if (msg.type === 'data') {
|
||||||
terminal.write(msg.data);
|
terminal.write(msg.data);
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||||
} else if (msg.type === 'connected') {
|
} else if (msg.type === 'connected') {
|
||||||
terminal.writeln('[SSH connected. Waiting for prompt...]');
|
/* nothing for now */
|
||||||
} else {
|
|
||||||
console.log('Unhandled message:', msg);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse message', err);
|
console.error('Failed to parse message', err);
|
||||||
@@ -166,13 +158,34 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
|||||||
ref={xtermRef}
|
ref={xtermRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: splitScreen ? 0 : 48,
|
top: splitScreen ? 0 : 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: '-1ch',
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
opacity: visible && isVisible ? 1 : 0,
|
opacity: visible && isVisible ? 1 : 0,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = `
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(180,180,180,0.7);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(120,120,120,0.9);
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
58
src/backend/db/database.ts
Normal file
58
src/backend/db/database.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import userRoutes from './routes/users.js';
|
||||||
|
import sshRoutes from './routes/ssh.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
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 app = express();
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/users', userRoutes);
|
||||||
|
app.use('/ssh', sshRoutes);
|
||||||
|
|
||||||
|
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);
|
||||||
83
src/backend/db/db/index.ts
Normal file
83
src/backend/db/db/index.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
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 dbDir = path.resolve('./db/data');
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database('./db/data/db.sqlite');
|
||||||
|
logger.success('Database connection established');
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
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,
|
||||||
|
save_auth_method INTEGER,
|
||||||
|
is_pinned INTEGER,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
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;');
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
29
src/backend/db/db/schema.ts
Normal file
29
src/backend/db/db/schema.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(), // Unique user ID (nanoid)
|
||||||
|
username: text('username').notNull(), // Username
|
||||||
|
password_hash: text('password_hash').notNull(), // Hashed password
|
||||||
|
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: 2048 }),
|
||||||
|
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||||
|
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settings = sqliteTable('settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
});
|
||||||
231
src/backend/db/routes/ssh.ts
Normal file
231
src/backend/db/routes/ssh.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { sshData } 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) {
|
||||||
|
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 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, saveAuthMethod, isPinned } = req.body;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||||
|
logger.warn('Invalid SSH data input');
|
||||||
|
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshDataObj: any = {
|
||||||
|
userId: userId,
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
authMethod,
|
||||||
|
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||||
|
isPinned: isPinned ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (saveAuthMethod) {
|
||||||
|
if (authMethod === 'password') {
|
||||||
|
sshDataObj.password = password;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
} else if (authMethod === 'key') {
|
||||||
|
sshDataObj.key = key;
|
||||||
|
sshDataObj.password = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sshDataObj.password = null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(sshData).values(sshDataObj);
|
||||||
|
res.json({ message: 'SSH data created' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to save SSH data', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save SSH data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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, saveAuthMethod, isPinned } = req.body;
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshDataObj: any = {
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
authMethod,
|
||||||
|
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||||
|
isPinned: isPinned ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (saveAuthMethod) {
|
||||||
|
if (authMethod === 'password') {
|
||||||
|
sshDataObj.password = password;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
} else if (authMethod === 'key') {
|
||||||
|
sshDataObj.key = key;
|
||||||
|
sshDataObj.password = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sshDataObj.password = null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.update(sshData)
|
||||||
|
.set(sshDataObj)
|
||||||
|
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||||
|
res.json({ message: 'SSH data updated' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to update SSH data', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update SSH data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get SSH data for the authenticated user (requires JWT)
|
||||||
|
// GET /ssh/host
|
||||||
|
router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
logger.warn('Invalid userId for SSH data fetch');
|
||||||
|
return res.status(400).json({ error: 'Invalid userId' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(eq(sshData.userId, userId));
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch SSH data', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get all unique folders for the authenticated user (requires JWT)
|
||||||
|
// GET /ssh/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 folder fetch');
|
||||||
|
return res.status(400).json({ error: 'Invalid userId' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await db
|
||||||
|
.select({ folder: sshData.folder })
|
||||||
|
.from(sshData)
|
||||||
|
.where(eq(sshData.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 folders', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch SSH folders' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Delete SSH host by id (requires JWT)
|
||||||
|
// DELETE /ssh/host/:id
|
||||||
|
router.delete('/host/: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 host delete');
|
||||||
|
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await db.delete(sshData)
|
||||||
|
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||||
|
res.json({ message: 'SSH host deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to delete SSH host', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete SSH host' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
226
src/backend/db/routes/users.ts
Normal file
226
src/backend/db/routes/users.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { users, settings } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JWTPayload {
|
||||||
|
userId: string;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT authentication middleware
|
||||||
|
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||||
|
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 user
|
||||||
|
// POST /users/create
|
||||||
|
router.post('/create', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||||
|
if (row && (row as any).value !== 'true') {
|
||||||
|
return res.status(403).json({ error: 'Registration is currently disabled' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||||
|
logger.warn('Invalid user creation attempt');
|
||||||
|
return res.status(400).json({ error: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
logger.warn(`Attempt to create duplicate username: ${username}`);
|
||||||
|
return res.status(409).json({ error: 'Username already exists' });
|
||||||
|
}
|
||||||
|
let isFirstUser = false;
|
||||||
|
try {
|
||||||
|
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
|
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||||
|
} catch (e) {
|
||||||
|
isFirstUser = true;
|
||||||
|
}
|
||||||
|
const saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||||
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||||
|
const id = nanoid();
|
||||||
|
await db.insert(users).values({ id, username, password_hash, is_admin: isFirstUser });
|
||||||
|
logger.success(`User created: ${username} (is_admin: ${isFirstUser})`);
|
||||||
|
res.json({ message: 'User created', is_admin: isFirstUser });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to create user', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get user JWT by username and password
|
||||||
|
// POST /users/get
|
||||||
|
router.post('/get', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||||
|
logger.warn('Invalid get user attempt');
|
||||||
|
return res.status(400).json({ error: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
logger.warn(`User not found: ${username}`);
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
const userRecord = user[0];
|
||||||
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
logger.warn(`Incorrect password for user: ${username}`);
|
||||||
|
return res.status(401).json({ error: 'Incorrect password' });
|
||||||
|
}
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||||
|
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' });
|
||||||
|
logger.success(`User authenticated: ${username}`);
|
||||||
|
res.json({ token });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get user', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get current user's username using JWT
|
||||||
|
// GET /users/me
|
||||||
|
router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
logger.warn('Invalid userId in JWT for /users/me');
|
||||||
|
return res.status(401).json({ error: 'Invalid userId' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0) {
|
||||||
|
logger.warn(`User not found for /users/me: ${userId}`);
|
||||||
|
return res.status(401).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
res.json({ username: user[0].username, is_admin: !!user[0].is_admin });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get username', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get username' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Count users
|
||||||
|
// GET /users/count
|
||||||
|
router.get('/count', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
|
const count = (countResult as any)?.count || 0;
|
||||||
|
res.json({ count });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to count users', err);
|
||||||
|
res.status(500).json({ error: 'Failed to count users' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: DB health check (actually queries DB)
|
||||||
|
// GET /users/db-health
|
||||||
|
router.get('/db-health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
db.$client.prepare('SELECT 1').get();
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('DB health check failed', err);
|
||||||
|
res.status(500).json({ error: 'Database not accessible' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Get registration allowed status
|
||||||
|
// GET /users/registration-allowed
|
||||||
|
router.get('/registration-allowed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||||
|
res.json({ allowed: row ? (row as any).value === 'true' : true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get registration allowed', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get registration allowed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Set registration allowed status (admin only)
|
||||||
|
// PATCH /users/registration-allowed
|
||||||
|
router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
const { allowed } = req.body;
|
||||||
|
if (typeof allowed !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'Invalid value for allowed' });
|
||||||
|
}
|
||||||
|
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
|
||||||
|
res.json({ allowed });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to set registration allowed', err);
|
||||||
|
res.status(500).json({ error: 'Failed to set registration allowed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
const WebSocket = require('ws');
|
|
||||||
const { Client } = require('ssh2');
|
|
||||||
|
|
||||||
const wss = new WebSocket.Server({ port: 8082 });
|
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
|
||||||
let sshConn = null;
|
|
||||||
let sshStream = null;
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
cleanupSSH();
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('message', (msg) => {
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(msg);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Invalid JSON received:', msg);
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { type, data } = parsed;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'connectToHost':
|
|
||||||
handleConnectToHost(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'resize':
|
|
||||||
handleResize(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disconnect':
|
|
||||||
cleanupSSH();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'input':
|
|
||||||
if (sshStream) sshStream.write(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.warn('Unknown message type:', type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleConnectToHost({ cols, rows, hostConfig }) {
|
|
||||||
const { ip, port, username, password } = hostConfig;
|
|
||||||
|
|
||||||
sshConn = new Client();
|
|
||||||
|
|
||||||
sshConn.on('ready', () => {
|
|
||||||
sshConn.shell({
|
|
||||||
term: "xterm-256color",
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
modes: {
|
|
||||||
ECHO: 1,
|
|
||||||
ECHOCTL: 0,
|
|
||||||
ICANON: 1,
|
|
||||||
TTY_OP_OSWRAP: 1
|
|
||||||
}
|
|
||||||
}, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Shell error:', err);
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Shell error: ' + err.message }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sshStream = stream;
|
|
||||||
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
ws.send(JSON.stringify({ type: 'data', data: chunk.toString() }));
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', () => {
|
|
||||||
cleanupSSH();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err) => {
|
|
||||||
console.error('SSH stream error:', err.message);
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH stream error: ' + err.message }));
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' }));
|
|
||||||
// stream.write('\n'); // Force prompt to appear (removed to avoid double prompt)
|
|
||||||
console.log('Sent connected message and newline to SSH stream');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
sshConn.on('error', (err) => {
|
|
||||||
console.error('SSH connection error:', err.message);
|
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'SSH error: ' + err.message }));
|
|
||||||
cleanupSSH();
|
|
||||||
});
|
|
||||||
|
|
||||||
sshConn.on('close', () => {
|
|
||||||
cleanupSSH();
|
|
||||||
});
|
|
||||||
|
|
||||||
sshConn.connect({
|
|
||||||
host: ip,
|
|
||||||
port,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
keepaliveInterval: 5000,
|
|
||||||
keepaliveCountMax: 10,
|
|
||||||
readyTimeout: 10000,
|
|
||||||
tcpKeepAlive: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResize({ cols, rows }) {
|
|
||||||
if (sshStream && sshStream.setWindow) {
|
|
||||||
sshStream.setWindow(rows, cols, rows, cols);
|
|
||||||
ws.send(JSON.stringify({ type: 'resized', cols, rows }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupSSH() {
|
|
||||||
if (sshStream) {
|
|
||||||
try {
|
|
||||||
sshStream.end();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error closing stream:', e.message);
|
|
||||||
}
|
|
||||||
sshStream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sshConn) {
|
|
||||||
try {
|
|
||||||
sshConn.end();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error closing connection:', e.message);
|
|
||||||
}
|
|
||||||
sshConn = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('WebSocket server running on ws://localhost:8082');
|
|
||||||
200
src/backend/ssh/ssh.ts
Normal file
200
src/backend/ssh/ssh.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||||
|
import { Client, type ClientChannel, type PseudoTtyOptions } from 'ssh2';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8082 });
|
||||||
|
|
||||||
|
const sshIconSymbol = '🖥️';
|
||||||
|
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')(`[${sshIconSymbol}]`)} ${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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
|
let sshConn: Client | null = null;
|
||||||
|
let sshStream: ClientChannel | null = null;
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
cleanupSSH();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (msg: RawData) => {
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(msg.toString());
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Invalid JSON received: ' + msg.toString());
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data } = parsed;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'connectToHost':
|
||||||
|
handleConnectToHost(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'resize':
|
||||||
|
handleResize(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disconnect':
|
||||||
|
cleanupSSH();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
if (sshStream) sshStream.write(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unknown message type: ' + type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleConnectToHost(data: {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
hostConfig: {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
authMethod?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { cols, rows, hostConfig } = data;
|
||||||
|
const { ip, port, username, password, key, authMethod } = hostConfig;
|
||||||
|
|
||||||
|
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||||
|
logger.error('Invalid username provided');
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid username provided' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
||||||
|
logger.error('Invalid IP provided');
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid IP provided' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!port || typeof port !== 'number' || port <= 0) {
|
||||||
|
logger.error('Invalid port provided');
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid port provided' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn = new Client();
|
||||||
|
|
||||||
|
sshConn.on('ready', () => {
|
||||||
|
const pseudoTtyOpts: PseudoTtyOptions = {
|
||||||
|
term: 'xterm-256color',
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
modes: {
|
||||||
|
ECHO: 1,
|
||||||
|
ECHOCTL: 0,
|
||||||
|
ICANON: 1,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Shell error: ' + err.message);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Shell error: ' + err.message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshStream = stream;
|
||||||
|
|
||||||
|
stream.on('data', (chunk: Buffer) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'data', data: chunk.toString() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('close', () => {
|
||||||
|
cleanupSSH();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err: Error) => {
|
||||||
|
logger.error('SSH stream error: ' + err.message);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'SSH stream error: ' + err.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sshConn.on('error', (err: Error) => {
|
||||||
|
logger.error('SSH connection error: ' + err.message);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'SSH error: ' + err.message }));
|
||||||
|
cleanupSSH();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshConn.on('close', () => {
|
||||||
|
cleanupSSH();
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectConfig: any = {
|
||||||
|
host: ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
keepaliveInterval: 5000,
|
||||||
|
keepaliveCountMax: 10,
|
||||||
|
readyTimeout: 10000,
|
||||||
|
};
|
||||||
|
if (authMethod === 'key' && key) {
|
||||||
|
connectConfig.privateKey = key;
|
||||||
|
} else {
|
||||||
|
connectConfig.password = password;
|
||||||
|
}
|
||||||
|
sshConn.connect(connectConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize(data: { cols: number; rows: number }) {
|
||||||
|
if (sshStream && sshStream.setWindow) {
|
||||||
|
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||||
|
ws.send(JSON.stringify({ type: 'resized', cols: data.cols, rows: data.rows }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSSH() {
|
||||||
|
if (sshStream) {
|
||||||
|
try {
|
||||||
|
sshStream.end();
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error('Error closing stream: ' + e.message);
|
||||||
|
}
|
||||||
|
sshStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshConn) {
|
||||||
|
try {
|
||||||
|
sshConn.end();
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error('Error closing connection: ' + e.message);
|
||||||
|
}
|
||||||
|
sshConn = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
53
src/backend/starter.ts
Normal file
53
src/backend/starter.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// npx tsc -p tsconfig.node.json
|
||||||
|
// node ./dist/backend/starter.js
|
||||||
|
|
||||||
|
import './db/database.js'
|
||||||
|
import './ssh/ssh.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const fixedIconSymbol = '🚀';
|
||||||
|
|
||||||
|
const getTimeStamp = (): string => {
|
||||||
|
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
|
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
logger.info("Starting all backend servers...");
|
||||||
|
|
||||||
|
logger.success("All servers started successfully");
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logger.info("Shutting down servers...");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to start servers:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
56
src/components/ui/scroll-area.tsx
Normal file
56
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"module": "module",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,13 @@
|
|||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "nodenext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": false,
|
||||||
|
"outDir": "./dist/backend",
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
@@ -21,5 +17,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["src/backend/**/*.ts"]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user