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
|
||||
*.sln
|
||||
*.sw?
|
||||
/db/
|
||||
|
||||
@@ -95,7 +95,7 @@ For production environments, we recommend running the website via Nginx. See the
|
||||
```bash
|
||||
cd src/backend
|
||||
node database.cjs
|
||||
node ssh.cjs
|
||||
node ssh.ts
|
||||
```
|
||||
|
||||
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": {
|
||||
"dev": "vite",
|
||||
"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 .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@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-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
@@ -24,14 +29,24 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.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",
|
||||
"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",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
@@ -45,14 +60,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
|
||||
import React from "react";
|
||||
import { HomepageSidebar } from "@/apps/Homepage/HomepageSidebar.tsx";
|
||||
import React, { useState } from "react";
|
||||
import { HomepageAuth } from "@/apps/Homepage/HomepageAuth.tsx";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="flex">
|
||||
<HomepageSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
<div className="flex min-h-screen">
|
||||
<HomepageSidebar onSelectView={onSelectView} disabled={!loggedIn} isAdmin={isAdmin} username={loggedIn ? username : null} />
|
||||
<div className="flex-1 bg-background" />
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 flex justify-center items-center z-50"
|
||||
style={{ left: 256 }}
|
||||
>
|
||||
<HomepageAuth setLoggedIn={setLoggedIn} setIsAdmin={setIsAdmin} setUsername={setUsername} />
|
||||
</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,
|
||||
Server,
|
||||
File,
|
||||
Hammer
|
||||
Hammer, ChevronUp, User2
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarContent, SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
@@ -20,12 +20,66 @@ import {
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
import axios from "axios";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
|
||||
interface SidebarProps {
|
||||
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 (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
@@ -37,45 +91,90 @@ export function HomepageSidebar({ onSelectView }: SidebarProps): React.ReactElem
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
|
||||
{/* Sidebar Items */}
|
||||
<SidebarMenuItem key={"SSH"}>
|
||||
<SidebarMenuButton asChild onClick={() => onSelectView("ssh")}>
|
||||
<div>
|
||||
<Computer/>
|
||||
<span>{"SSH"}</span>
|
||||
</div>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh")} disabled={disabled}>
|
||||
<Computer />
|
||||
<span>SSH</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"SSH Tunnel"}>
|
||||
<SidebarMenuButton asChild onClick={() => onSelectView("ssh_tunnel")}>
|
||||
<div>
|
||||
<Server/>
|
||||
<span>{"SSH Tunnel"}</span>
|
||||
</div>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh_tunnel")} disabled={disabled}>
|
||||
<Server />
|
||||
<span>SSH Tunnel</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Config Editor"}>
|
||||
<SidebarMenuButton asChild onClick={() => onSelectView("config_editor")}>
|
||||
<div>
|
||||
<File/>
|
||||
<span>{"Config Editor"}</span>
|
||||
</div>
|
||||
<SidebarMenuButton onClick={() => onSelectView("config_editor")} disabled={disabled}>
|
||||
<File />
|
||||
<span>Config Editor</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Tools"}>
|
||||
<SidebarMenuButton asChild onClick={() => onSelectView("tools")}>
|
||||
<div>
|
||||
<Hammer/>
|
||||
<span>{"Tools"}</span>
|
||||
</div>
|
||||
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
|
||||
<Hammer />
|
||||
<span>Tools</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:opacity-90 w-full"
|
||||
style={{ width: '100%' }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<User2 /> {username ? username : 'Signed out'}
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||
>
|
||||
{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>
|
||||
</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 { SSHTerminal } from "./SSHTerminal.tsx";
|
||||
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -15,37 +16,20 @@ type Tab = {
|
||||
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 {
|
||||
const [allTabs, setAllTabs] = useState<Tab[]>([]);
|
||||
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
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) => {
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
// Helper to fit all visible terminals
|
||||
const fitVisibleTerminals = () => {
|
||||
allTabs.forEach((terminal) => {
|
||||
const isVisible =
|
||||
@@ -57,7 +41,6 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
// Wrap setSplitScreenTab to fit before and after
|
||||
const setSplitScreenTab = (tabId: number) => {
|
||||
fitVisibleTerminals();
|
||||
setAllSplitScreenTab((prev) => {
|
||||
@@ -75,7 +58,6 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
};
|
||||
|
||||
const setCloseTab = (tabId: number) => {
|
||||
// Find the tab and call disconnect on its terminal
|
||||
const tab = allTabs.find((t) => t.id === tabId);
|
||||
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||
tab.terminalRef.current.disconnect();
|
||||
@@ -88,290 +70,312 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Render all terminals absolutely positioned, always mounted
|
||||
const renderAllTerminals = () => (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
|
||||
{allTabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-container-${tab.id}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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>
|
||||
);
|
||||
const updatePanelRects = () => {
|
||||
setPanelRects((prev) => {
|
||||
const next: Record<string, DOMRect | null> = { ...prev };
|
||||
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
||||
if (ref) {
|
||||
next[id] = ref.getBoundingClientRect();
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to show a terminal in a panel by toggling zIndex/opacity
|
||||
const showTerminal = (tab: Tab, splitScreen: boolean) => (
|
||||
<TerminalOverlay tabId={tab.id} splitScreen={splitScreen} />
|
||||
);
|
||||
useEffect(() => {
|
||||
const observers: ResizeObserver[] = [];
|
||||
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 = () => {
|
||||
if (allSplitScreenTab.length === 0) {
|
||||
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 renderAllTerminals = () => {
|
||||
const layoutStyles: Record<number, React.CSSProperties> = {};
|
||||
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 !== 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) {
|
||||
const [tab1, tab2] = layoutTabs;
|
||||
return (
|
||||
<ResizablePanelGroup key={splitKey} direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
}}>{tab1.title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(tab1, true)}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||
direction="horizontal"
|
||||
className="h-full w-full"
|
||||
id="main-horizontal"
|
||||
>
|
||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
|
||||
<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'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{tab1.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
}}>{tab2.title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(tab2, true)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
|
||||
<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={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{tab2.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 splits: vertical group (top: horizontal with 2, bottom: single)
|
||||
if (layoutTabs.length === 3) {
|
||||
return (
|
||||
<ResizablePanelGroup key={splitKey} direction="vertical" className="h-full w-full">
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
{/* Left/top panel */}
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(layoutTabs[0], true)}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||
direction="vertical"
|
||||
className="h-full w-full"
|
||||
id="main-vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<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'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
{/* Right/top panel (no reset button here) */}
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>{layoutTabs[1].title}</span>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<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={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[1].title}</div>
|
||||
</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(layoutTabs[1], true)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
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)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||
<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={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[2].title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4 splits: 2x2 grid (vertical group with two horizontal groups)
|
||||
if (layoutTabs.length === 4) {
|
||||
return (
|
||||
<ResizablePanelGroup key={splitKey} direction="vertical" className="h-full w-full">
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(layoutTabs[0], true)}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||
direction="vertical"
|
||||
className="h-full w-full"
|
||||
id="main-vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<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'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
}}>{layoutTabs[1].title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(layoutTabs[1], true)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<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={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[1].title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
}}>{layoutTabs[2].title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(layoutTabs[2], true)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup ref={el => { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
|
||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[2].id}`} order={1}>
|
||||
<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={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[2].title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
|
||||
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
}}>{layoutTabs[3].title}</div>
|
||||
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
|
||||
{showTerminal(layoutTabs[3], true)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[3].id}`} order={2}>
|
||||
<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={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[3].title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -392,16 +396,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
||||
};
|
||||
|
||||
const getLayoutStyle = () => {
|
||||
if (allSplitScreenTab.length === 0) {
|
||||
return "flex flex-col h-full w-full";
|
||||
} else if (allSplitScreenTab.length === 1) {
|
||||
return "grid grid-cols-2 h-full w-full";
|
||||
} else if (allSplitScreenTab.length === 2) {
|
||||
return "grid grid-cols-2 grid-rows-2 h-full w-full";
|
||||
} else {
|
||||
return "grid grid-cols-2 grid-rows-2 h-full w-full";
|
||||
const getUniqueTabTitle = (baseTitle: string) => {
|
||||
let title = baseTitle;
|
||||
let count = 1;
|
||||
const existingTitles = allTabs.map(t => t.title);
|
||||
while (existingTitles.includes(title)) {
|
||||
title = `${baseTitle} (${count})`;
|
||||
count++;
|
||||
}
|
||||
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 (
|
||||
@@ -411,6 +430,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
<SSHSidebar
|
||||
onSelectView={onSelectView}
|
||||
onAddHostSubmit={onAddHostSubmit}
|
||||
onHostConnect={onHostConnect}
|
||||
/>
|
||||
</div>
|
||||
{/* Main area: fills the rest */}
|
||||
@@ -439,7 +459,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
</div>
|
||||
{/* Split area below the topbar */}
|
||||
<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 && (
|
||||
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28 }}>
|
||||
<button
|
||||
@@ -459,14 +503,27 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
display: 'flex',
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renderAllTerminals()}
|
||||
{renderTerminals()}
|
||||
{renderSplitOverlays()}
|
||||
</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 { FitAddon } from '@xterm/addon-fit';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
@@ -15,7 +15,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
{ hostConfig, isVisible, splitScreen = false },
|
||||
ref
|
||||
) {
|
||||
console.log('Rendering SSHTerminal', { hostConfig, isVisible });
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
@@ -75,7 +74,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
|
||||
// Always send cols + 1
|
||||
const cols = terminal.cols + 1;
|
||||
const rows = terminal.rows;
|
||||
|
||||
@@ -93,7 +91,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
fitAddon.fit();
|
||||
setVisible(true);
|
||||
|
||||
// Always send cols + 1
|
||||
const cols = terminal.cols + 1;
|
||||
const rows = terminal.rows;
|
||||
|
||||
@@ -101,8 +98,6 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
webSocketRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
terminal.writeln('WebSocket opened');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connectToHost',
|
||||
data: {
|
||||
@@ -123,16 +118,13 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log('WS message received:', msg); // Debug log
|
||||
|
||||
if (msg.type === 'data') {
|
||||
terminal.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
} else if (msg.type === 'connected') {
|
||||
terminal.writeln('[SSH connected. Waiting for prompt...]');
|
||||
} else {
|
||||
console.log('Unhandled message:', msg);
|
||||
/* nothing for now */
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse message', err);
|
||||
@@ -166,13 +158,34 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
ref={xtermRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: splitScreen ? 0 : 48,
|
||||
top: splitScreen ? 0 : 0,
|
||||
left: 0,
|
||||
right: '-1ch',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
marginLeft: 2,
|
||||
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": {
|
||||
"baseUrl": ".",
|
||||
"module": "module",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"module": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noEmit": false,
|
||||
"outDir": "./dist/backend",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
@@ -21,5 +17,5 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["src/backend/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user