Clean commit without large files

This commit is contained in:
LukeGus
2025-08-07 02:20:27 -05:00
commit d0b139e388
186 changed files with 22902 additions and 0 deletions

46
src/App.tsx Normal file
View File

@@ -0,0 +1,46 @@
import React from "react"
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
import {SSH} from "@/apps/SSH/Terminal/SSH.tsx"
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
function App() {
const [view, setView] = React.useState<string>("homepage")
const renderActiveView = () => {
switch (view) {
case "homepage":
return <Homepage
onSelectView={setView}
/>
case "ssh_manager":
return <SSHManager
onSelectView={setView}
/>
case "terminal":
return <SSH
onSelectView={setView}
/>
case "tunnel":
return <SSHTunnel
onSelectView={setView}
/>
case "config_editor":
return <ConfigEditor
onSelectView={setView}
/>
}
}
return (
<div className="flex">
<main>
{renderActiveView()}
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,99 @@
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
import axios from "axios";
import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx";
interface HomepageProps {
onSelectView: (view: string) => void;
}
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,
});
export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [dbError, setDbError] = useState<string | null>(null);
useEffect(() => {
const jwt = getCookie("jwt");
if (jwt) {
setAuthLoading(true);
Promise.all([
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
API.get("/db-health")
])
.then(([meRes]) => {
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
})
.catch((err) => {
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(() => setAuthLoading(false));
} else {
setAuthLoading(false);
}
}, []);
return (
<div className="flex min-h-screen">
<HomepageSidebar
onSelectView={onSelectView}
disabled={!loggedIn || authLoading}
isAdmin={isAdmin}
username={loggedIn ? username : null}
/>
<div className="flex-1 bg-background grid grid-cols-3 items-center">
<div className="col-span-1"></div>
<div className="col-span-1 flex justify-center">
<HomepageAuth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
/>
</div>
<div className="col-span-1 flex justify-center">
<HomepageUpdateLog
loggedIn={loggedIn}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,403 @@
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 {Separator} from "@/components/ui/separator";
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;
loggedIn: boolean;
authLoading: boolean;
dbError: string | null;
setDbError: (error: string | null) => void;
}
export function HomepageAuth({
className,
setLoggedIn,
setIsAdmin,
setUsername,
loggedIn,
authLoading,
dbError,
setDbError,
...props
}: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup" | "external">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
useEffect(() => {
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
useEffect(() => {
API.get("/registration-allowed").then(res => {
setRegistrationAllowed(res.data.allowed);
});
}, []);
useEffect(() => {
API.get("/oidc-config").then((response) => {
if (response.data) {
setOidcConfigured(true);
} else {
setOidcConfigured(false);
}
}).catch((error) => {
if (error.response?.status === 404) {
setOidcConfigured(false);
} else {
setOidcConfigured(false);
}
});
}, []);
useEffect(() => {
API.get("/count").then(res => {
if (res.data.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
}).catch(() => {
setDbError("Could not connect to the database. Please try again later.");
});
}, [setDbError]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
let res, meRes;
if (tab === "login") {
res = await API.post("/login", {username: localUsername, password});
} else {
await API.post("/create", {username: localUsername, password});
res = await API.post("/login", {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);
}
}
async function handleOIDCLogin() {
setError(null);
setOidcLoading(true);
try {
const authResponse = await API.get("/oidc/authorize");
const {auth_url: authUrl} = authResponse.data;
if (!authUrl || authUrl === 'undefined') {
throw new Error('Invalid authorization URL received from backend');
}
window.location.replace(authUrl);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
setOidcLoading(false);
}
}
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get('success');
const token = urlParams.get('token');
const error = urlParams.get('error');
if (error) {
setError(`OIDC authentication failed: ${error}`);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (success && token) {
setOidcLoading(true);
setError(null);
setCookie("jwt", token);
API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
.then(meRes => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
window.history.replaceState({}, document.title, window.location.pathname);
})
.catch(err => {
setError("Failed to get user info after OIDC login");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
window.history.replaceState({}, document.title, window.location.pathname);
})
.finally(() => {
setOidcLoading(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(
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 className="inline">
You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub issue
</a>.
</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 || (authLoading && getCookie("jwt"))) && (
<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 available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar.
</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 className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
</Button>
</div>
</div>
)}
{(!internalLoggedIn && (!authLoading || !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>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => setTab("external")}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
External
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" :
tab === "signup" ? "Create a new account" :
"Login with external provider"}
</h2>
</div>
{tab === "external" ? (
<div className="flex flex-col gap-5">
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
</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>
);
}

View File

@@ -0,0 +1,436 @@
import React from 'react';
import {
Computer,
Server,
File,
Hammer, ChevronUp, User2, HardDrive
} from "lucide-react";
import {
Sidebar,
SidebarContent, SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
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 {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
import axios from "axios";
interface SidebarProps {
onSelectView: (view: string) => void;
getView?: () => string;
disabled?: boolean;
isAdmin?: boolean;
username?: string | null;
}
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,
getView,
disabled,
isAdmin,
username
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
React.useEffect(() => {
if (adminSheetOpen) {
API.get("/registration-allowed").then(res => {
setAllowRegistration(res.data.allowed);
});
API.get("/oidc-config").then(res => {
if (res.data) {
setOidcConfig(res.data);
}
}).catch((error) => {
});
}
}, [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);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
if (missingFields.length > 0) {
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post(
"/oidc-config",
oidcConfig,
{headers: {Authorization: `Bearer ${jwt}`}}
);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({
...prev,
[field]: value
}));
};
return (
<div>
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
disabled={disabled}>
<HardDrive/>
<span>SSH Manager</span>
</SidebarMenuButton>
</SidebarMenuItem>
<div className="ml-5">
<SidebarMenuItem key={"Terminal"}>
<SidebarMenuButton onClick={() => onSelectView("terminal")}
disabled={disabled}>
<Computer/>
<span>Terminal</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
disabled={disabled}>
<Server/>
<span>Tunnel</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
disabled={disabled}>
<File/>
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} 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-[400px] max-h-screen overflow-y-auto">
<SheetHeader>
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-6">
{/* Registration Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
<Separator className="p-0.25 mt-2 mb-2"/>
{/* OIDC Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">
Configure external identity provider for OIDC/OAuth2 authentication.
Users will see an "External" login option once configured.
</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="http://100.98.3.50:9000/application/o/token/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name", "preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
}}
>
Reset
</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</Sidebar>
</SidebarProvider>
</div>
)
}

View File

@@ -0,0 +1,187 @@
import React, {useEffect, useState} from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import axios from "axios";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
}
interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
interface RSSResponse {
feed: {
title: string;
description: string;
link: string;
updated: string;
};
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
interface VersionResponse {
status: 'up_to_date' | 'requires_update';
version: string;
latest_release: {
name: string;
published_at: string;
html_url: string;
};
cached: boolean;
cache_age?: number;
}
const apiBase =
typeof window !== "undefined" && window.location.hostname === "localhost"
? "http://localhost:8081"
: "";
const API = axios.create({
baseURL: apiBase,
});
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn) {
setLoading(true);
Promise.all([
API.get('/releases/rss?per_page=100'),
API.get('/version')
])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes.data);
setVersionInfo(versionRes.data);
setError(null);
})
.catch(err => {
setError('Failed to fetch update information');
})
.finally(() => setLoading(false));
}
}, [loggedIn]);
if (!loggedIn) {
return null;
}
const formatDescription = (description: string) => {
const firstLine = description.split('\n')[0];
return firstLine
.replace(/[#*`]/g, '')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 100) + (firstLine.length > 100 ? '...' : '');
};
return (
<div className="w-[400px] h-[600px] mr-8 flex flex-col border border-border rounded-lg bg-card p-4">
<div>
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
<Separator className="p-0.25 mt-3 mb-3"/>
{versionInfo && versionInfo.status === 'requires_update' && (
<Alert>
<AlertTitle>Update Available</AlertTitle>
<AlertDescription>
A new version ({versionInfo.version}) is available.
<Button
variant="link"
className="p-0 h-auto underline ml-1"
onClick={() => window.open("https://docs.termix.site/docs", '_blank')}
>
Update now
</Button>
</AlertDescription>
</Alert>
)}
</div>
{versionInfo && versionInfo.status === 'requires_update' && (
<Separator className="p-0.25 mt-3 mb-3"/>
)}
<div className="flex-1 overflow-y-auto space-y-3">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-border rounded-lg p-3 hover:bg-accent transition-colors cursor-pointer"
onClick={() => window.open(release.link, '_blank')}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-sm leading-tight flex-1">
{release.title}
</h4>
{release.isPrerelease && (
<span
className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded ml-2 flex-shrink-0">
Pre-release
</span>
)}
</div>
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-muted-foreground">
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>{release.assets.length} asset{release.assets.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
</div>
))}
{releases && releases.items.length === 0 && !loading && (
<Alert>
<AlertTitle>No Releases</AlertTitle>
<AlertDescription>
No releases found.
</AlertDescription>
</Alert>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,350 @@
import React, {useState, useEffect} from "react";
import CodeMirror from "@uiw/react-codemirror";
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
import {oneDark} from '@codemirror/theme-one-dark';
import {EditorView} from '@codemirror/view';
interface ConfigCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') {
return 'text';
}
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1) {
return 'text';
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) {
case 'ng':
return 'angular';
case 'apl':
return 'apl';
case 'asc':
return 'asciiArmor';
case 'ast':
return 'asterisk';
case 'bf':
return 'brainfuck';
case 'c':
return 'c';
case 'ceylon':
return 'ceylon';
case 'clj':
return 'clojure';
case 'cmake':
return 'cmake';
case 'cob':
case 'cbl':
return 'cobol';
case 'coffee':
return 'coffeescript';
case 'lisp':
return 'commonLisp';
case 'cpp':
case 'cc':
case 'cxx':
return 'cpp';
case 'cr':
return 'crystal';
case 'cs':
return 'csharp';
case 'css':
return 'css';
case 'cypher':
return 'cypher';
case 'd':
return 'd';
case 'dart':
return 'dart';
case 'diff':
case 'patch':
return 'diff';
case 'dockerfile':
return 'dockerfile';
case 'dtd':
return 'dtd';
case 'dylan':
return 'dylan';
case 'ebnf':
return 'ebnf';
case 'ecl':
return 'ecl';
case 'eiffel':
return 'eiffel';
case 'elm':
return 'elm';
case 'erl':
return 'erlang';
case 'factor':
return 'factor';
case 'fcl':
return 'fcl';
case 'fs':
return 'forth';
case 'f90':
case 'for':
return 'fortran';
case 's':
return 'gas';
case 'feature':
return 'gherkin';
case 'go':
return 'go';
case 'groovy':
return 'groovy';
case 'hs':
return 'haskell';
case 'hx':
return 'haxe';
case 'html':
case 'htm':
return 'html';
case 'http':
return 'http';
case 'idl':
return 'idl';
case 'java':
return 'java';
case 'js':
case 'mjs':
case 'cjs':
return 'javascript';
case 'jinja2':
case 'j2':
return 'jinja2';
case 'json':
return 'json';
case 'jsx':
return 'jsx';
case 'jl':
return 'julia';
case 'kt':
case 'kts':
return 'kotlin';
case 'less':
return 'less';
case 'lezer':
return 'lezer';
case 'liquid':
return 'liquid';
case 'litcoffee':
return 'livescript';
case 'lua':
return 'lua';
case 'md':
return 'markdown';
case 'nb':
case 'mat':
return 'mathematica';
case 'mbox':
return 'mbox';
case 'mmd':
return 'mermaid';
case 'mrc':
return 'mirc';
case 'moo':
return 'modelica';
case 'mscgen':
return 'mscgen';
case 'm':
return 'mumps';
case 'sql':
return 'mysql';
case 'nc':
return 'nesC';
case 'nginx':
return 'nginx';
case 'nix':
return 'nix';
case 'nsi':
return 'nsis';
case 'nt':
return 'ntriples';
case 'mm':
return 'objectiveCpp';
case 'octave':
return 'octave';
case 'oz':
return 'oz';
case 'pas':
return 'pascal';
case 'pl':
case 'pm':
return 'perl';
case 'pgsql':
return 'pgsql';
case 'php':
return 'php';
case 'pig':
return 'pig';
case 'ps1':
return 'powershell';
case 'properties':
return 'properties';
case 'proto':
return 'protobuf';
case 'pp':
return 'puppet';
case 'py':
return 'python';
case 'q':
return 'q';
case 'r':
return 'r';
case 'rb':
return 'ruby';
case 'rs':
return 'rust';
case 'sas':
return 'sas';
case 'sass':
case 'scss':
return 'sass';
case 'scala':
return 'scala';
case 'scm':
return 'scheme';
case 'shader':
return 'shader';
case 'sh':
case 'bash':
return 'shell';
case 'siv':
return 'sieve';
case 'st':
return 'smalltalk';
case 'sol':
return 'solidity';
case 'solr':
return 'solr';
case 'rq':
return 'sparql';
case 'xlsx':
case 'ods':
case 'csv':
return 'spreadsheet';
case 'nut':
return 'squirrel';
case 'tex':
return 'stex';
case 'styl':
return 'stylus';
case 'svelte':
return 'svelte';
case 'swift':
return 'swift';
case 'tcl':
return 'tcl';
case 'textile':
return 'textile';
case 'tiddlywiki':
return 'tiddlyWiki';
case 'tiki':
return 'tiki';
case 'toml':
return 'toml';
case 'troff':
return 'troff';
case 'tsx':
return 'tsx';
case 'ttcn':
return 'ttcn';
case 'ttl':
case 'turtle':
return 'turtle';
case 'ts':
return 'typescript';
case 'vb':
return 'vb';
case 'vbs':
return 'vbscript';
case 'vm':
return 'velocity';
case 'v':
return 'verilog';
case 'vhd':
case 'vhdl':
return 'vhdl';
case 'vue':
return 'vue';
case 'wat':
return 'wast';
case 'webidl':
return 'webIDL';
case 'xq':
case 'xquery':
return 'xQuery';
case 'xml':
return 'xml';
case 'yacas':
return 'yacas';
case 'yaml':
case 'yml':
return 'yaml';
case 'z80':
return 'z80';
default:
return 'text';
}
}
useEffect(() => {
document.body.style.overflowX = 'hidden';
return () => {
document.body.style.overflowX = '';
};
}, []);
return (
<div style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div
style={{
width: '100%',
height: '100%',
overflow: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
className="config-codemirror-scroll-wrapper"
>
<CodeMirror
value={content}
extensions={[
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
hyperLink,
oneDark,
EditorView.theme({
'&': {
backgroundColor: '#09090b !important',
},
'.cm-gutters': {
backgroundColor: '#18181b !important',
},
})
]}
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{lineNumbers: true}}
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,608 @@
import React, {useState, useEffect, useRef} from "react";
import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
import {Button} from '@/components/ui/button.tsx';
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import {cn} from '@/lib/utils.ts';
import {
getConfigEditorRecent,
getConfigEditorPinned,
getConfigEditorShortcuts,
addConfigEditorRecent,
removeConfigEditorRecent,
addConfigEditorPinned,
removeConfigEditorPinned,
addConfigEditorShortcut,
removeConfigEditorShortcut,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH
} from '@/apps/SSH/ssh-axios.ts';
interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
error?: string;
success?: string;
dirty?: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState<string | number>('home');
const [recent, setRecent] = useState<any[]>([]);
const [pinned, setPinned] = useState<any[]>([]);
const [shortcuts, setShortcuts] = useState<any[]>([]);
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
const [isSaving, setIsSaving] = useState(false);
const sidebarRef = useRef<any>(null);
useEffect(() => {
if (currentHost) {
fetchHomeData();
} else {
setRecent([]);
setPinned([]);
setShortcuts([]);
}
}, [currentHost]);
useEffect(() => {
if (activeTab === 'home' && currentHost) {
fetchHomeData();
}
}, [activeTab, currentHost]);
useEffect(() => {
if (activeTab === 'home' && currentHost) {
const interval = setInterval(() => {
fetchHomeData();
}, 2000);
return () => clearInterval(interval);
}
}, [activeTab, currentHost]);
async function fetchHomeData() {
if (!currentHost) return;
try {
const homeDataPromise = Promise.all([
getConfigEditorRecent(currentHost.id),
getConfigEditorPinned(currentHost.id),
getConfigEditorShortcuts(currentHost.id),
]);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
);
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file,
type: 'file',
isPinned: (pinnedRes || []).some(pinnedFile =>
pinnedFile.path === file.path && pinnedFile.name === file.name
)
}));
const pinnedWithType = (pinnedRes || []).map(file => ({
...file,
type: 'file'
}));
setRecent(recentWithPinnedStatus);
setPinned(pinnedWithType);
setShortcuts((shortcutsRes || []).map(shortcut => ({
...shortcut,
type: 'directory'
})));
} catch (err: any) {
}
}
const formatErrorMessage = (err: any, defaultMessage: string): string => {
if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as any;
if (axiosErr.response?.status === 403) {
return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`;
} else if (axiosErr.response?.status === 500) {
const backendError = axiosErr.response?.data?.error || 'Internal server error occurred';
return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`;
} else if (axiosErr.response?.data?.error) {
const backendError = axiosErr.response.data.error;
return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`;
} else {
return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`;
}
} else if (err instanceof Error) {
return `${err.message}. Check the Docker logs for detailed error information.`;
} else {
return `${defaultMessage}. Check the Docker logs for detailed error information.`;
}
};
const handleOpenFile = async (file: any) => {
const tabId = file.path;
if (!tabs.find(t => t.id === tabId)) {
const currentSshSessionId = currentHost?.id.toString();
setTabs([...tabs, {
id: tabId,
title: file.name,
fileName: file.name,
content: '',
filePath: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
loading: true
}]);
try {
const res = await readSSHFile(currentSshSessionId, file.path);
setTabs(tabs => tabs.map(t => t.id === tabId ? {
...t,
content: res.content,
loading: false,
error: undefined
} : t));
await addConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
} catch (err: any) {
const errorMessage = formatErrorMessage(err, 'Cannot read file');
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
}
}
setActiveTab(tabId);
};
const handleRemoveRecent = async (file: any) => {
try {
await removeConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
} catch (err) {
}
};
const handlePinFile = async (file: any) => {
try {
await addConfigEditorPinned({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
} catch (err) {
}
};
const handleUnpinFile = async (file: any) => {
try {
await removeConfigEditorPinned({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
} catch (err) {
}
};
const handleOpenShortcut = async (shortcut: any) => {
if (sidebarRef.current?.isOpeningShortcut) {
return;
}
if (sidebarRef.current && sidebarRef.current.openFolder) {
try {
sidebarRef.current.isOpeningShortcut = true;
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
await sidebarRef.current.openFolder(currentHost, normalizedPath);
} catch (err) {
} finally {
if (sidebarRef.current) {
sidebarRef.current.isOpeningShortcut = false;
}
}
} else {
}
};
const handleAddShortcut = async (folderPath: string) => {
try {
const name = folderPath.split('/').pop() || folderPath;
await addConfigEditorShortcut({
name,
path: folderPath,
isSSH: true,
sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id
});
fetchHomeData();
} catch (err) {
}
};
const handleRemoveShortcut = async (shortcut: any) => {
try {
await removeConfigEditorShortcut({
name: shortcut.name,
path: shortcut.path,
isSSH: true,
sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id
});
fetchHomeData();
} catch (err) {
}
};
const closeTab = (tabId: string | number) => {
const idx = tabs.findIndex(t => t.id === tabId);
const newTabs = tabs.filter(t => t.id !== tabId);
setTabs(newTabs);
if (activeTab === tabId) {
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
else setActiveTab('home');
}
if (currentHost) {
fetchHomeData();
}
};
const setTabContent = (tabId: string | number, content: string) => {
setTabs(tabs => tabs.map(t => t.id === tabId ? {
...t,
content,
dirty: true,
error: undefined,
success: undefined
} : t));
};
const handleSave = async (tab: Tab) => {
if (isSaving) {
return;
}
setIsSaving(true);
try {
if (!tab.sshSessionId) {
throw new Error('No SSH session ID available');
}
if (!tab.filePath) {
throw new Error('No file path available');
}
if (!currentHost?.id) {
throw new Error('No current host available');
}
try {
const statusPromise = getSSHStatus(tab.sshSessionId);
const statusTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
);
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, {
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword
});
const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
);
await Promise.race([connectPromise, connectTimeoutPromise]);
}
} catch (statusErr) {
}
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => {
reject(new Error('Save operation timed out'));
}, 30000)
);
const result = await Promise.race([savePromise, timeoutPromise]);
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
dirty: false,
success: 'File saved successfully'
} : t));
setTimeout(() => {
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
}, 3000);
Promise.allSettled([
(async () => {
try {
await addConfigEditorRecent({
name: tab.fileName,
path: tab.filePath,
isSSH: true,
sshSessionId: tab.sshSessionId,
hostId: currentHost.id
});
} catch (recentErr) {
}
})(),
(async () => {
try {
await fetchHomeData();
} catch (refreshErr) {
}
})()
]).then(() => {
});
} catch (err) {
let errorMessage = formatErrorMessage(err, 'Cannot save file');
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
}
setTabs(tabs => {
const updatedTabs = tabs.map(t => t.id === tab.id ? {
...t,
error: `Failed to save file: ${errorMessage}`
} : t);
return updatedTabs;
});
setTimeout(() => {
setTabs(currentTabs => [...currentTabs]);
}, 100);
} finally {
setIsSaving(false);
}
};
const handleHostChange = (host: SSHHost | null) => {
setCurrentHost(host);
setTabs([]);
setActiveTab('home');
};
if (!currentHost) {
return (
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar
onSelectView={onSelectView}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
onHostChange={handleHostChange}
/>
</div>
<div style={{
position: 'absolute',
top: 0,
left: 256,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#09090b'
}}>
<div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
</div>
</div>
</div>
);
}
return (
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar
onSelectView={onSelectView}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
onHostChange={handleHostChange}
/>
</div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
style={{height: 44}}>
{/* Tab list scrollable area */}
<div className="flex-1 min-w-0 h-full flex items-center">
<div
className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{minWidth: 0}}>
<ConfigTopbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={() => {
setActiveTab('home');
if (currentHost) {
fetchHomeData();
}
}}
/>
</div>
</div>
{/* Save button - always visible */}
<Button
className={cn(
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
)}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
onClick={() => {
const tab = tabs.find(t => t.id === activeTab);
if (tab && !isSaving) handleSave(tab);
}}
type="button"
style={{height: 36, alignSelf: 'center'}}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div style={{
position: 'absolute',
top: 44,
left: 256,
right: 0,
bottom: 0,
overflow: 'hidden',
zIndex: 10,
background: '#101014',
display: 'flex',
flexDirection: 'column'
}}>
{activeTab === 'home' ? (
<ConfigHomeView
recent={recent}
pinned={pinned}
shortcuts={shortcuts}
onOpenFile={handleOpenFile}
onRemoveRecent={handleRemoveRecent}
onPinFile={handlePinFile}
onUnpinFile={handleUnpinFile}
onOpenShortcut={handleOpenShortcut}
onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
/>
) : (
(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null;
return (
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
{/* Error display */}
{tab.error && (
<div
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-red-400"></span>
<span>{tab.error}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
error: undefined
} : t))}
className="text-red-400 hover:text-red-300 transition-colors"
>
</button>
</div>
</div>
)}
{/* Success display */}
{tab.success && (
<div
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400"></span>
<span>{tab.success}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
success: undefined
} : t))}
className="text-green-400 hover:text-green-300 transition-colors"
>
</button>
</div>
</div>
)}
<div className="flex-1 min-h-0">
<ConfigCodeEditor
content={tab.content}
fileName={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
</div>
</div>
);
})()
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,594 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar.tsx';
import {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx';
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
import {
getSSHHosts,
listSSHFiles,
connectSSH,
getSSHStatus,
getConfigEditorPinned,
addConfigEditorPinned,
removeConfigEditorPinned
} from '@/apps/SSH/ssh-axios.ts';
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
{onSelectView, onOpenFile, tabs, onHostChange}: {
onSelectView: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
onHostChange?: (host: SSHHost | null) => void;
},
ref
) {
const [sshConnections, setSSHConnections] = useState<SSHHost[]>([]);
const [loadingSSH, setLoadingSSH] = useState(false);
const [errorSSH, setErrorSSH] = useState<string | undefined>(undefined);
const [view, setView] = useState<'servers' | 'files'>('servers');
const [activeServer, setActiveServer] = useState<SSHHost | null>(null);
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [fileSearch, setFileSearch] = useState('');
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState<string | null>(null);
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string;
timestamp: number
}>>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
useEffect(() => {
fetchSSH();
}, []);
async function fetchSSH() {
setLoadingSSH(true);
setErrorSSH(undefined);
try {
const hosts = await getSSHHosts();
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
if (configEditorHosts.length > 0) {
const firstHost = configEditorHosts[0];
}
setSSHConnections(configEditorHosts);
} catch (err: any) {
setErrorSSH('Failed to load SSH connections');
} finally {
setLoadingSSH(false);
}
}
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
setFilesError('No authentication credentials available for this SSH host');
return null;
}
const connectionConfig = {
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache(prev => ({
...prev,
[sessionId]: {sessionId, timestamp: Date.now()}
}));
return sessionId;
} catch (err: any) {
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
setFilesError(null);
try {
let pinnedFiles: any[] = [];
try {
if (activeServer) {
pinnedFiles = await getConfigEditorPinned(activeServer.id);
}
} catch (err) {
}
if (activeServer && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(activeServer);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error('Failed to reconnect SSH session');
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(activeServer);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, view, activeServer, sshSessionId]);
async function handleSelectServer(server: SSHHost) {
if (connectingSSH) {
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]);
setActiveServer(server);
setCurrentPath(server.defaultPath || '/');
setView('files');
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
if (onHostChange) {
onHostChange(server);
}
} else {
w
setView('servers');
setActiveServer(null);
}
}
useImperativeHandle(ref, () => ({
openFolder: async (server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (activeServer?.id === server.id && currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]);
setActiveServer(server);
setCurrentPath(path);
setView('files');
if (!sshSessionId || activeServer?.id !== server.id) {
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
} else {
setView('servers');
setActiveServer(null);
}
} else {
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
}
},
fetchFiles: () => {
if (activeServer && sshSessionId) {
fetchFiles();
}
}
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const sshByFolder: Record<string, SSHHost[]> = {};
sshConnections.forEach(conn => {
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
if (!sshByFolder[folder]) sshByFolder[folder] = [];
sshByFolder[folder].push(conn);
});
const sortedFolders = Object.keys(sshByFolder);
if (sortedFolders.includes('No Folder')) {
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
sortedFolders.unshift('No Folder');
}
const filteredSshByFolder: Record<string, SSHHost[]> = {};
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return true;
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
(conn.tags || []).join(' ').toLowerCase().includes(q);
});
});
const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
return (
<SidebarProvider>
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Config
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow min-h-0">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
<div
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
{view === 'servers' && (
<>
<div
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by name, username, IP, folder, tags..."
className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
autoComplete="off"
/>
</div>
<ScrollArea className="flex-1 w-full h-full"
style={{height: '100%', maxHeight: '100%'}}>
<div className="flex flex-col h-full">
<div
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
<div className="flex-1 min-h-0">
<Accordion type="multiple" className="w-full"
value={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className="mt-0 w-full !border-b-transparent">
<AccordionTrigger
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 pb-2 pt-1 w-full">
{filteredSshByFolder[folder].map(conn => (
<Button
key={conn.id}
variant="outline"
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
onClick={() => handleSelectServer(conn)}
>
<div
className="flex items-center w-full">
{conn.pin && <Pin
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
<span
className="font-medium truncate">{conn.name || conn.ip}</span>
</div>
</Button>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
<Separator
className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</div>
</div>
</div>
</div>
</ScrollArea>
</>
)}
{view === 'files' && activeServer && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
style={{maxWidth: 260}}>
<Button
size="icon"
variant="outline"
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== '/' && path !== '') {
if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
setCurrentPath(path.slice(0, lastSlash));
} else {
setCurrentPath('/');
}
} else {
setView('servers');
if (onHostChange) {
onHostChange(null);
}
}
}}
>
<ArrowUp className="w-4 h-4"/>
</Button>
<Input ref={pathInputRef} value={currentPath}
onChange={e => setCurrentPath(e.target.value)}
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
/>
</div>
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
<Input
placeholder="Search files and folders..."
className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded"
autoComplete="off"
value={fileSearch}
onChange={e => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
height: '100%',
maxHeight: '100%',
paddingRight: 8,
scrollbarGutter: 'stable',
background: '#09090b'
}}>
<div className="p-2 pr-2">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div>
) : filesError ? (
<div className="text-xs text-red-500">{filesError}</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">No files or
folders found.</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)}
style={{maxWidth: 220, marginBottom: 8}}
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId
}))}
>
{item.type === 'directory' ?
<Folder
className="w-4 h-4 text-blue-400"/> :
<File
className="w-4 h-4 text-muted-foreground"/>}
<span
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
</div>
<div className="flex items-center gap-1">
{item.type === 'file' && (
<Button size="icon" variant="ghost"
className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeConfigEditorPinned({
name: item.name,
path: item.path,
hostId: activeServer?.id,
isSSH: true,
sshSessionId: activeServer?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? {
...f,
isPinned: false
} : f
));
} else {
await addConfigEditorPinned({
name: item.name,
path: item.path,
hostId: activeServer?.id,
isSSH: true,
sshSessionId: activeServer?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? {
...f,
isPinned: true
} : f
));
}
} catch (err) {
console.error('Failed to pin/unpin file:', err);
}
}}
>
<Pin
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
});
export {ConfigEditorSidebar};

View File

@@ -0,0 +1,149 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {Card} from '@/components/ui/card.tsx';
import {Separator} from '@/components/ui/separator.tsx';
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
interface SSHConnection {
id: string;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
interface FileItem {
name: string;
type: 'file' | 'directory' | 'link';
path: string;
isStarred?: boolean;
}
interface ConfigFileSidebarViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void;
currentPath: string;
files: FileItem[];
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
isSSHMode: boolean;
onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection;
}
export function ConfigFileSidebarViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: ConfigFileSidebarViewerProps) {
return (
<div className="flex flex-col h-full">
{/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b border-[#23232a]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
<Plus className="w-4 h-4"/>
</Button>
</div>
<div className="flex flex-col gap-1">
<Button
variant={!isSSHMode ? 'secondary' : 'ghost'}
className="w-full justify-start text-left px-2 py-1.5 rounded"
onClick={onSwitchToLocal}
>
<Server className="w-4 h-4 mr-2"/> Local Files
</Button>
{sshConnections.map((conn) => (
<div key={conn.id} className="flex items-center gap-1 group">
<Button
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)}
>
<Link2 className="w-4 h-4 mr-2"/>
{conn.name || conn.ip}
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4"/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
))}
</div>
</div>
{/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
<div className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
<File className="w-4 h-4 text-muted-foreground"/>}
<span className="text-sm text-white truncate">{item.name}</span>
</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onStarFile(item)}>
<Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
</Card>
))}
{files.length === 0 &&
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
import {Input} from '@/components/ui/input.tsx';
import {useState} from 'react';
interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: 'file' | 'directory';
sshSessionId?: string;
}
interface ShortcutItem {
name: string;
path: string;
}
interface ConfigHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
}
export function ConfigHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: ConfigHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
<div key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{file.name}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
)}
</div>
</div>
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
>
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
</div>
</div>
);
return (
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
Shortcuts</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span>
</div>
) : recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
file.isPinned
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span>
</div>
) : pinned.map((file) =>
renderFileCard(
file,
undefined,
() => onUnpinFile(file),
true
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
<Input
placeholder="Enter folder path"
value={newShortcut}
onChange={e => setNewShortcut(e.target.value)}
className="flex-1 bg-[#23232a] border-[#434345] text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
/>
<Button
size="sm"
variant="outline"
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1"/>
Add
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span>
</div>
) : shortcuts.map((shortcut) =>
renderShortcutCard(shortcut)
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {X, Home} from 'lucide-react';
interface ConfigTab {
id: string | number;
title: string;
}
interface ConfigTabListProps {
tabs: ConfigTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) {
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
<Button
onClick={onHomeClick}
variant="outline"
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
<Home className="w-4 h-4"/>
</Button>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-7 rounded-l-none p-0 !w-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,704 @@
import React, {useState, useRef, useEffect} from "react";
import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx";
import {SSHTerminal} from "./SSHTerminal.tsx";
import {SSHTopbar} from "@/apps/SSH/Terminal/SSHTopbar.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
type Tab = {
id: number;
title: string;
hostConfig: any;
terminalRef: React.RefObject<any>;
};
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 [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);
};
const fitVisibleTerminals = () => {
allTabs.forEach((terminal) => {
const isVisible =
(allSplitScreenTab.length === 0 && terminal.id === currentTab) ||
(allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id)));
if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') {
terminal.terminalRef.current.fit();
}
});
};
const setSplitScreenTab = (tabId: number) => {
fitVisibleTerminals();
setAllSplitScreenTab((prev) => {
let next;
if (prev.includes(tabId)) {
next = prev.filter((id) => id !== tabId);
} else if (prev.length < 3) {
next = [...prev, tabId];
} else {
next = prev;
}
setTimeout(() => fitVisibleTerminals(), 0);
return next;
});
};
const setCloseTab = (tabId: number) => {
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();
}
setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = allTabs.filter((tab) => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null);
}
};
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;
});
};
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 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 !== (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;
if (layoutTabs.length === 2) {
const [tab1, tab2] = layoutTabs;
return (
<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>
</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>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
return (
<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>
</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>
</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>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
return (
<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>
</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>
</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>
</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>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const onAddHostSubmit = (data: any) => {
const id = nextTabId.current++;
const title = `${data.ip || "Host"}:${data.port || 22}`;
const terminalRef = React.createRef<any>();
const newTab: Tab = {
id,
title,
hostConfig: data,
terminalRef,
};
setAllTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
};
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 (
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{
width: 256,
flexShrink: 0,
height: '100vh',
position: 'relative',
zIndex: 2,
margin: 0,
padding: 0,
border: 'none'
}}>
<SSHSidebar
onSelectView={onSelectView}
onAddHostSubmit={onAddHostSubmit}
onHostConnect={onHostConnect}
allTabs={allTabs}
runCommandOnTabs={(tabIds: number[], command: string) => {
allTabs.forEach(tab => {
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(command);
}
});
}}
/>
</div>
<div
className="terminal-container"
style={{
flex: 1,
height: '100vh',
position: 'relative',
overflow: 'hidden',
margin: 0,
padding: 0,
border: 'none',
}}
>
<div style={{position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10}}>
<SSHTopbar
allTabs={allTabs}
currentTab={currentTab ?? -1}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
<div style={{height: 'calc(100% - 46px)', marginTop: 46, position: 'relative'}}>
{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>
)}
{allSplitScreenTab.length > 0 && (
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
<button
style={{
background: '#18181b',
color: '#fff',
borderLeft: '1px solid #222224',
borderRight: '1px solid #222224',
borderTop: 'none',
borderBottom: '1px solid #222224',
borderRadius: 0,
padding: '2px 10px',
cursor: 'pointer',
fontSize: 13,
margin: 0,
height: 28,
display: 'flex',
alignItems: 'center',
}}
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()}
{renderSplitOverlays()}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,383 @@
import React, {useState} from 'react';
import {
CornerDownLeft,
Hammer, Pin
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import {Input} from "@/components/ui/input.tsx";
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SidebarProps {
onSelectView: (view: string) => void;
onHostConnect: (hostConfig: any) => void;
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
runCommandOnTabs: (tabIds: number[], command: string) => void;
}
export function SSHSidebar({onSelectView, onHostConnect, allTabs, runCommandOnTabs}: SidebarProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const fetchHosts = React.useCallback(async () => {
setHostsLoading(true);
setHostsError(null);
try {
const newHosts = await getSSHHosts();
const terminalHosts = newHosts.filter(host => host.enableTerminal);
const prevHosts = prevHostsRef.current;
const isSame =
terminalHosts.length === prevHosts.length &&
terminalHosts.every((h: SSHHost, i: number) => {
const prev = prevHosts[i];
if (!prev) return false;
return (
h.id === prev.id &&
h.name === prev.name &&
h.folder === prev.folder &&
h.ip === prev.ip &&
h.port === prev.port &&
h.username === prev.username &&
h.password === prev.password &&
h.authType === prev.authType &&
h.key === prev.key &&
h.pin === prev.pin &&
JSON.stringify(h.tags) === JSON.stringify(prev.tags)
);
});
if (!isSame) {
setHosts(terminalHosts);
prevHostsRef.current = terminalHosts;
}
} catch (err: any) {
setHostsError('Failed to load hosts');
} finally {
setHostsLoading(false);
}
}, []);
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
}, [fetchHosts]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [toolsCommand, setToolsCommand] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabToggle = (tabId: number) => {
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
};
const handleRunCommand = () => {
if (selectedTabIds.length && toolsCommand.trim()) {
let cmd = toolsCommand;
if (!cmd.endsWith("\n")) cmd += "\n";
runCommandOnTabs(selectedTabIds, cmd);
setToolsCommand("");
}
};
return (
<SidebarProvider>
<Sidebar className="h-full flex flex-col overflow-hidden">
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Terminal
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenuItem key="Homepage">
<Button
className="w-full mt-2 mb-2 h-8"
onClick={() => onSelectView("homepage")}
variant="outline"
>
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
<div
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by name, username, IP, folder, tags..."
className="w-full h-8 text-sm bg-background border border-border rounded"
autoComplete="off"
/>
</div>
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
{hostsError && (
<div className="px-2 py-1 mt-2">
<div
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
</div>
)}
<div className="flex-1 min-h-0">
<ScrollArea className="w-full h-full">
<Accordion key={`host-accordion-${sortedFolders.length}`}
type="multiple" className="w-full"
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<AccordionTrigger
className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 px-3 pb-2 pt-1">
{getSortedHosts(hostsByFolder[folder]).map(host => (
<div key={host.id}
className="w-full overflow-hidden">
<HostMenuItem
host={host}
onHostConnect={onHostConnect}
/>
</div>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div
style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</ScrollArea>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
<div className="bg-sidebar">
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
<SheetTrigger asChild>
<Button
className="w-full h-8 mt-2"
variant="outline"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="mr-2 h-4 w-4"/>
Tools
</Button>
</SheetTrigger>
<SheetContent side="left"
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5">
<SheetTitle>Tools</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-2 pt-2">
<Accordion type="single" collapsible defaultValue="multiwindow">
<AccordionItem value="multiwindow">
<AccordionTrigger className="text-base font-semibold">Run multiwindow
commands</AccordionTrigger>
<AccordionContent>
<textarea
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
placeholder="Enter command(s) to run on selected tabs..."
value={toolsCommand}
onChange={e => setToolsCommand(e.target.value)}
style={{
fontFamily: 'monospace',
marginBottom: 8,
background: '#141416'
}}
/>
<div className="flex flex-wrap gap-2 mb-2">
{allTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
size="sm"
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
<Button
className="w-full"
variant="outline"
onClick={handleRunCommand}
disabled={!toolsCommand.trim() || !selectedTabIds.length}
>
Run Command
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</SheetContent>
</Sheet>
</div>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
}
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
host: SSHHost;
onHostConnect: (hostConfig: any) => void
}) {
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
return (
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
<div className="flex w-full h-10">
<div
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)}
>
<div className="flex items-center w-full">
{host.pin &&
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
}
<span className="font-medium truncate">{host.name || host.ip}</span>
</div>
</div>
</div>
{hasTags && (
<div
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{height: 30}}>
{tags.map((tag: string) => (
<span key={tag}
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
{tag}
</span>
))}
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,76 @@
import React from "react";
import {Button} from "@/components/ui/button.tsx";
import {X, SeparatorVertical} from "lucide-react"
interface TerminalTab {
id: number;
title: string;
}
interface SSHTabListProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
}
export function SSHTabList({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab = [],
setSplitScreenTab,
setCloseTab,
}: SSHTabListProps): React.ReactElement {
const isSplitScreenActive = allSplitScreenTab.length > 0;
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
{allTabs.map((terminal, index) => {
const isActive = terminal.id === currentTab;
const isSplit = allSplitScreenTab.includes(terminal.id);
const isSplitButtonDisabled =
(isActive && !isSplitScreenActive) ||
(allSplitScreenTab.length >= 3 && !isSplit);
return (
<div
key={terminal.id}
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(terminal.id)}
disabled={isSplit}
variant="outline"
className={`rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{terminal.title}
</Button>
<Button
onClick={() => setSplitScreenTab(terminal.id)}
disabled={isSplitButtonDisabled || isActive}
variant="outline"
className="rounded-none p-0 !w-9 !h-9"
>
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
<Button
onClick={() => setCloseTab(terminal.id)}
disabled={(isSplitScreenActive && isActive) || isSplit}
variant="outline"
className="rounded-l-none p-0 !w-9 !h-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,258 @@
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';
import {Unicode11Addon} from '@xterm/addon-unicode11';
import {WebLinksAddon} from '@xterm/addon-web-links';
interface SSHTerminalProps {
hostConfig: any;
isVisible: boolean;
title?: string;
showTitle?: boolean;
splitScreen?: boolean;
}
export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{hostConfig, isVisible, splitScreen = false},
ref
) {
const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
disconnect: () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
},
fit: () => {
fitAddonRef.current?.fit();
},
sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
}
}
}), []);
useEffect(() => {
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
function handleWindowResize() {
fitAddonRef.current?.fit();
}
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
terminal.options = {
cursorBlink: true,
cursorStyle: 'bar',
scrollback: 10000,
fontSize: 15,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: {
background: '#09090b',
foreground: '#f7f7f7',
},
allowTransparency: true,
convertEol: true,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
fastScrollModifier: 'alt',
fastScrollSensitivity: 5,
allowProposedApi: true,
};
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon);
terminal.open(xtermRef.current);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
fitAddonRef.current?.fit();
const cols = terminal.cols;
const rows = terminal.rows;
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}}));
}
}, 100);
});
resizeObserver.observe(xtermRef.current);
setTimeout(() => {
fitAddon.fit();
setVisible(true);
const cols = terminal.cols + 1;
const rows = terminal.rows;
const wsUrl = window.location.hostname === 'localhost'
? 'ws://localhost:8082'
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
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') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) {
terminal.writeln('\r\n[Connection closed]');
}
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300);
return () => {
resizeObserver.disconnect();
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
fitAddonRef.current.fit();
}
}, [isVisible]);
return (
<div
ref={xtermRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
marginLeft: 2,
opacity: visible && isVisible ? 1 : 0,
overflow: 'hidden',
}}
/>
);
});
const style = document.createElement('style');
style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
/* Load NerdFonts locally */
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
.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;
}
.xterm {
font-feature-settings: "liga" 1, "calt" 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.xterm .xterm-screen {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
font-variant-ligatures: contextual;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 1, "calt" 1;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
`;
document.head.appendChild(style);

View File

@@ -0,0 +1,44 @@
import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx";
import React from "react";
interface TerminalTab {
id: number;
title: string;
}
interface SSHTopbarProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
}
export function SSHTopbar({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab,
setSplitScreenTab,
setCloseTab
}: SSHTopbarProps): React.ReactElement {
return (
<div className="flex h-11.5 z-100" style={{
position: 'absolute',
left: 0,
width: '100%',
backgroundColor: '#18181b',
borderBottom: '1px solid #222224',
}}>
<SSHTabList
allTabs={allTabs}
currentTab={currentTab}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
)
}

View File

@@ -0,0 +1,160 @@
import React, {useState, useEffect, useCallback} from "react";
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
const fetchHosts = useCallback(async () => {
try {
const hostsData = await getSSHHosts();
setHosts(hostsData);
} catch (err) {
}
}, []);
const fetchTunnelStatuses = useCallback(async () => {
try {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
} catch (err) {
}
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
}, [fetchHosts]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
try {
if (action === 'connect') {
const endpointHost = hosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
if (!endpointHost) {
throw new Error('Endpoint host not found');
}
const tunnelConfig = {
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.authType === 'password' ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin
};
await connectTunnel(tunnelConfig);
} else if (action === 'disconnect') {
await disconnectTunnel(tunnelName);
} else if (action === 'cancel') {
await cancelTunnel(tunnelName);
}
await fetchTunnelStatuses();
} catch (err) {
} finally {
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
}
};
return (
<div className="flex h-screen w-full">
<div className="w-64 flex-shrink-0">
<SSHTunnelSidebar
onSelectView={onSelectView}
/>
</div>
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
hosts={hosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,353 @@
import React from "react";
import {Button} from "@/components/ui/button.tsx";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {
Loader2,
Pin,
Terminal,
Network,
FileEdit,
Tag,
Play,
Square,
AlertCircle,
Clock,
Wifi,
WifiOff,
Zap,
X
} from "lucide-react";
import {Badge} from "@/components/ui/badge.tsx";
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting"
};
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelObjectProps {
host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export function SSHTunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction
}: SSHTunnelObjectProps): React.ReactElement {
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName];
};
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return {
icon: <WifiOff className="h-4 w-4"/>,
text: 'Unknown',
color: 'text-muted-foreground',
bgColor: 'bg-muted/50',
borderColor: 'border-border'
};
const statusValue = status.status || 'DISCONNECTED';
switch (statusValue.toUpperCase()) {
case 'CONNECTED':
return {
icon: <Wifi className="h-4 w-4"/>,
text: 'Connected',
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
borderColor: 'border-green-500/20 dark:border-green-400/20'
};
case 'CONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
text: 'Connecting...',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'DISCONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
text: 'Disconnecting...',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
};
case 'DISCONNECTED':
return {
icon: <WifiOff className="h-4 w-4"/>,
text: 'Disconnected',
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
case 'WAITING':
return {
icon: <Clock className="h-4 w-4"/>,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'ERROR':
case 'FAILED':
return {
icon: <AlertCircle className="h-4 w-4"/>,
text: status.reason || 'Error',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
borderColor: 'border-red-500/20 dark:border-red-400/20'
};
default:
return {
icon: <WifiOff className="h-4 w-4"/>,
text: statusValue,
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
}
};
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{/* Host Header */}
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
</div>
</div>
</div>
{/* Tags */}
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/>
{tag}
</Badge>
))}
{host.tags.length > 3 && (
<Badge variant="outline" className="text-xs px-1 py-0">
+{host.tags.length - 3}
</Badge>
)}
</div>
)}
<Separator className="mb-3"/>
{/* Tunnel Connections */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4"/>
Tunnel Connections ({host.tunnelConnections.length})
</h4>
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
const isConnected = statusValue === 'CONNECTED';
const isConnecting = statusValue === 'CONNECTING';
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
Port {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div className={`text-xs ${statusDisplay.color} font-medium`}>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-2 py-1">
<Zap className="h-3 w-3 mr-1"/>
Auto
</Badge>
)}
{/* Action Buttons */}
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1"/>
Disconnect
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1"/>
Cancel
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1"/>
Connect
</Button>
)}
</div>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
</Button>
)}
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">Error:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
Check your Docker logs for the error reason, join the <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</div>
</>
)}
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
</div>
<div>
Attempt {status.retryCount} of {status.maxRetries}
{status.nextRetryIn && (
<span> Next retry in {status.nextRetryIn} seconds</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
<p className="text-sm">No tunnel connections configured</p>
</div>
)}
</div>
</div>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,184 @@
import React from "react";
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Search} from "lucide-react";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelViewerProps {
hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export function SSHTunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(handler);
}, [searchQuery]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const query = debouncedSearch.trim().toLowerCase();
return hosts.filter(host => {
const searchableText = [
host.name || '',
host.username,
host.ip,
host.folder || '',
...(host.tags || []),
host.authType,
host.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
}, [hosts, debouncedSearch]);
const tunnelHosts = React.useMemo(() => {
return filteredHosts.filter(host =>
host.enableTunnel &&
host.tunnelConnections &&
host.tunnelConnections.length > 0
);
}, [filteredHosts]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
tunnelHosts.forEach(host => {
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
if (!map[folder]) map[folder] = [];
map[folder].push(host);
});
return map;
}, [tunnelHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
return (
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels
</h1>
<p className="text-muted-foreground">
Manage your SSH tunnel connections
</p>
</div>
<div className="relative mb-3">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{tunnelHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2">
No SSH Tunnels
</h3>
<p className="text-muted-foreground max-w-md">
{searchQuery.trim() ?
"No hosts match your search criteria." :
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
}
</p>
</div>
) : (
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`}
className={idx === 0 ? "mt-0" : "mt-2"}>
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>
{folder}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
<div className="grid grid-cols-4 gap-6 w-full">
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
<div key={host.id} className="w-full">
<SSHTunnelObject
host={host}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={onTunnelAction}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
);
}

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

@@ -0,0 +1,520 @@
import axios from 'axios';
interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: 'password' | 'key';
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableConfigEditor?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface ConfigEditorFile {
name: string;
path: string;
type?: 'file' | 'directory';
isSSH?: boolean;
sshSessionId?: string;
}
interface ConfigEditorShortcut {
name: string;
path: string;
}
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const sshHostApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin,
headers: {
'Content-Type': 'application/json',
},
});
const tunnelApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin,
headers: {
'Content-Type': 'application/json',
},
});
const configEditorApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin,
headers: {
'Content-Type': 'application/json',
}
})
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
sshHostApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
tunnelApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
configEditorApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export async function getSSHHosts(): Promise<SSHHost[]> {
try {
const response = await sshHostApi.get('/ssh/db/host');
return response.data;
} catch (error) {
throw error;
}
}
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = {...submitData};
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await sshHostApi.post('/ssh/db/host', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await sshHostApi.post('/ssh/db/host', submitData);
return response.data;
}
} catch (error) {
throw error;
}
}
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = {...submitData};
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
return response.data;
}
} catch (error) {
throw error;
}
}
export async function deleteSSHHost(hostId: number): Promise<any> {
try {
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
throw error;
}
}
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try {
const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
throw error;
}
}
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try {
const response = await tunnelApi.get('/ssh/tunnel/status');
return response.data || {};
} catch (error) {
throw error;
}
}
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try {
const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
return response.data;
} catch (error) {
throw error;
}
}
export async function disconnectTunnel(tunnelName: string): Promise<any> {
try {
const response = await tunnelApi.post('/ssh/tunnel/disconnect', {tunnelName});
return response.data;
} catch (error) {
throw error;
}
}
export async function cancelTunnel(tunnelName: string): Promise<any> {
try {
const response = await tunnelApi.post('/ssh/tunnel/cancel', {tunnelName});
return response.data;
} catch (error) {
throw error;
}
}
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
try {
const response = await sshHostApi.get(`/ssh/config_editor/recent?hostId=${hostId}`);
return response.data || [];
} catch (error) {
return [];
}
}
export async function addConfigEditorRecent(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.post('/ssh/config_editor/recent', file);
return response.data;
} catch (error) {
throw error;
}
}
export async function removeConfigEditorRecent(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.delete('/ssh/config_editor/recent', {data: file});
return response.data;
} catch (error) {
throw error;
}
}
export async function getConfigEditorPinned(hostId: number): Promise<ConfigEditorFile[]> {
try {
const response = await sshHostApi.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
return response.data || [];
} catch (error) {
return [];
}
}
export async function addConfigEditorPinned(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.post('/ssh/config_editor/pinned', file);
return response.data;
} catch (error) {
throw error;
}
}
export async function removeConfigEditorPinned(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.delete('/ssh/config_editor/pinned', {data: file});
return response.data;
} catch (error) {
throw error;
}
}
export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEditorShortcut[]> {
try {
const response = await sshHostApi.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
return response.data || [];
} catch (error) {
return [];
}
}
export async function addConfigEditorShortcut(shortcut: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.post('/ssh/config_editor/shortcuts', shortcut);
return response.data;
} catch (error) {
throw error;
}
}
export async function removeConfigEditorShortcut(shortcut: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await sshHostApi.delete('/ssh/config_editor/shortcuts', {data: shortcut});
return response.data;
} catch (error) {
throw error;
}
}
export async function connectSSH(sessionId: string, config: {
ip: string;
port: number;
username: string;
password?: string;
sshKey?: string;
keyPassword?: string;
}): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
sessionId,
...config
});
return response.data;
} catch (error) {
throw error;
}
}
export async function disconnectSSH(sessionId: string): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', {sessionId});
return response.data;
} catch (error) {
throw error;
}
}
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
params: {sessionId}
});
return response.data;
} catch (error) {
throw error;
}
}
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
params: {sessionId, path}
});
return response.data || [];
} catch (error) {
throw error;
}
}
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
params: {sessionId, path}
});
return response.data;
} catch (error) {
throw error;
}
}
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
sessionId,
path,
content
});
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
return response.data;
} else {
throw new Error('File write operation did not return success status');
}
} catch (error) {
throw error;
}
}
export {sshHostApi, tunnelApi, configEditorApi};

View File

@@ -0,0 +1,18 @@
import React from "react";
import {TemplateSidebar} from "@/apps/Template/TemplateSidebar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function Template({onSelectView}: ConfigEditorProps): React.ReactElement {
return (
<div>
<TemplateSidebar
onSelectView={onSelectView}
/>
Template
</div>
)
}

View File

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

View File

@@ -0,0 +1,439 @@
import express from 'express';
import cors from 'cors';
import {Client as SSHClient} from 'ssh2';
import chalk from "chalk";
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
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));
}
}
};
interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
}
const sshSessions: Record<string, SSHSession> = {};
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
try {
session.client.end();
} catch {
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
}
}
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
if (!sessionId || !ip || !username || !port) {
return res.status(400).json({error: 'Missing SSH connection parameters'});
}
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient();
const config: any = {
host: ip,
port: port || 22,
username,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (sshKey && sshKey.trim()) {
config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword;
} else if (password && password.trim()) {
config.password = password;
} else {
return res.status(400).json({error: 'Either password or SSH key must be provided'});
}
let responseSent = false;
client.on('ready', () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
scheduleSessionCleanup(sessionId);
res.json({status: 'success', message: 'SSH connection established'});
});
client.on('error', (err) => {
if (responseSent) return;
responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
res.status(500).json({status: 'error', message: err.message});
});
client.on('close', () => {
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.connect(config);
});
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const {sessionId} = req.body;
cleanupSession(sessionId);
res.json({status: 'success', message: 'SSH connection disconnected'});
});
app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({status: 'success', connected: isConnected});
});
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = decodeURIComponent((req.query.path as string) || '/');
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH listFiles error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
const lines = data.split('\n').filter(line => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
if (name === '.' || name === '..') continue;
files.push({
name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
});
}
}
res.json(files);
});
});
});
app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const filePath = decodeURIComponent(req.query.path as string);
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
return res.status(400).json({error: 'File path is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH readFile error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
res.json({content: data, path: filePath});
});
});
});
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
const {sessionId, path: filePath, content} = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
return res.status(400).json({error: 'File path is required'});
}
if (content === undefined) {
return res.status(400).json({error: 'File content is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
const base64Content = Buffer.from(content, 'utf8').toString('base64');
const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'});
}
}, 15000);
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
}
let checkResult = '';
checkStream.on('data', (chunk: Buffer) => {
checkResult += chunk.toString();
});
checkStream.on('close', (checkCode) => {
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
return;
}
let outputData = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
clearTimeout(commandTimeout);
logger.error(`Permission denied writing to file: ${filePath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
});
}
return;
}
});
stream.on('close', (code) => {
clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) {
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
if (verifyErr) {
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
return;
}
let verifyResult = '';
verifyStream.on('data', (chunk: Buffer) => {
verifyResult += chunk.toString();
});
verifyStream.on('close', (verifyCode) => {
const fileSize = Number(verifyResult.trim());
if (fileSize > 0) {
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
} else {
if (!res.headersSent) {
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
}
}
});
});
return;
}
if (code !== 0) {
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
return;
}
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
});
stream.on('error', (streamErr) => {
clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
});
});
});
});
});
process.on('SIGINT', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on('SIGTERM', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
const PORT = 8084;
app.listen(PORT, () => {
});

View File

@@ -0,0 +1,243 @@
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';
import fetch from 'node-fetch';
import 'dotenv/config';
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
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));
}
}
};
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
}
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION
});
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
}
const githubCache = new GitHubCache();
const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'LukeGus';
const REPO_NAME = 'Termix';
interface GitHubRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
html_url: string;
assets: Array<{
id: number;
name: string;
size: number;
download_count: number;
browser_download_url: string;
}>;
prerelease: boolean;
draft: boolean;
}
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
return {
data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp
};
}
try {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'TermixUpdateChecker/1.0',
'X-GitHub-Api-Version': '2022-11-28'
}
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
githubCache.set(cacheKey, data);
return {
data: data,
cached: false
};
} catch (error) {
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
throw error;
}
}
app.use(bodyParser.json());
app.get('/health', (req, res) => {
res.json({status: 'ok'});
});
app.get('/version', async (req, res) => {
const localVersion = process.env.VERSION;
if (!localVersion) {
return res.status(401).send('Local Version Not Set');
}
try {
const cacheKey = 'latest_release';
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return res.status(401).send('Remote Version Not Found');
}
const response = {
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
version: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url
},
cached: releaseData.cached,
cache_age: releaseData.cache_age
};
res.json(response);
} catch (err) {
logger.error('Version check failed', err);
res.status(500).send('Fetch Error');
}
});
app.get('/releases/rss', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map(asset => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url
}))
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString()
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age
};
res.json(response);
} catch (error) {
logger.error('Failed to generate RSS format', error)
res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' });
}
});
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'});
});
const PORT = 8081;
app.listen(PORT, () => {});

View File

@@ -0,0 +1,197 @@
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 dataDir = process.env.DATA_DIR || './db/data';
const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, {recursive: true});
}
const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath);
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,
is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
issuer_url TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
redirect_uri TEXT,
identifier_path TEXT NOT NULL,
name_path TEXT NOT NULL,
scopes TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_config_editor INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS config_editor_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
`);
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
try {
sqlite.prepare(`SELECT ${column}
FROM ${table} LIMIT 1`).get();
} catch (e) {
try {
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
} catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
}
}
};
const migrateSchema = () => {
logger.info('Checking for schema updates...');
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
addColumnIfNotExists('users', 'client_id', 'TEXT');
addColumnIfNotExists('users', 'client_secret', 'TEXT');
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
logger.info('Removed redirect_uri column from users table');
} catch (e) {
}
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT');
addColumnIfNotExists('users', 'scopes', 'TEXT');
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_config_editor', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
logger.success('Schema migration completed');
};
migrateSchema();
try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (!row) {
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
}
} catch (e) {
logger.warn('Could not initialize default settings');
}
export const db = drizzle(sqlite, {schema});

View File

@@ -0,0 +1,76 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import {sql} from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull(),
password_hash: text('password_hash').notNull(),
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
oidc_identifier: text('oidc_identifier'),
client_id: text('client_id'),
client_secret: text('client_secret'),
issuer_url: text('issuer_url'),
authorization_url: text('authorization_url'),
token_url: text('token_url'),
identifier_path: text('identifier_path'),
name_path: text('name_path'),
scopes: text().default("openid email profile"),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username').notNull(),
folder: text('folder'),
tags: text('tags'),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
authType: text('auth_type').notNull(),
password: text('password'),
key: text('key', {length: 8192}),
keyPassword: text('key_password'),
keyType: text('key_type'),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
tunnelConnections: text('tunnel_connections'),
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
defaultPath: text('default_path'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorRecent = sqliteTable('config_editor_recent', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorPinned = sqliteTable('config_editor_pinned', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -0,0 +1,700 @@
import express from 'express';
import {db} from '../db/index.js';
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
import {eq, and, desc} from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import multer from 'multer';
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;
}
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
if (file.fieldname === 'key') {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
});
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'});
}
}
function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress;
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
}
// Internal-only endpoint for autostart (no JWT)
router.get('/db/host/internal', async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
return res.status(403).json({error: 'Forbidden'});
}
try {
const data = await db.select().from(sshData);
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({
...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
pin: !!row.pin,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableConfigEditor: !!row.enableConfigEditor,
}));
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH data (internal)', err);
res.status(500).json({error: 'Failed to fetch SSH data'});
}
});
// Route: Create SSH data (requires JWT)
// POST /ssh/host
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) {
try {
hostData = JSON.parse(req.body.data);
} catch (err) {
logger.warn('Invalid JSON data in multipart request');
return res.status(400).json({error: 'Invalid JSON data'});
}
} else {
logger.warn('Missing data field in multipart request');
return res.status(400).json({error: 'Missing data field'});
}
// Add the file data if present
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
// Regular JSON request
hostData = req.body;
}
const {
name,
folder,
tags,
ip,
port,
username,
password,
authMethod,
key,
keyPassword,
keyType,
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
defaultPath,
tunnelConnections
} = hostData;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input');
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,
authType: authMethod,
pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
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('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
if (req.headers['content-type']?.includes('multipart/form-data')) {
if (req.body.data) {
try {
hostData = JSON.parse(req.body.data);
} catch (err) {
logger.warn('Invalid JSON data in multipart request');
return res.status(400).json({error: 'Invalid JSON data'});
}
} else {
logger.warn('Missing data field in multipart request');
return res.status(400).json({error: 'Missing data field'});
}
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
hostData = req.body;
}
const {
name,
folder,
tags,
ip,
port,
username,
password,
authMethod,
key,
keyPassword,
keyType,
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
defaultPath,
tunnelConnections
} = hostData;
const {id} = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
logger.warn('Invalid SSH data input for update');
return res.status(400).json({error: 'Invalid SSH data'});
}
const sshDataObj: any = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip,
port,
username,
authType: authMethod,
pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
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('/db/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));
const result = data.map((row: any) => ({
...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
pin: !!row.pin,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableConfigEditor: !!row.enableConfigEditor,
}));
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH data', err);
res.status(500).json({error: 'Failed to fetch SSH data'});
}
});
// Route: Get SSH host by ID (requires JWT)
// GET /ssh/host/:id
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const {id} = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for SSH host fetch');
return res.status(400).json({error: 'Invalid request'});
}
try {
const data = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
if (data.length === 0) {
return res.status(404).json({error: 'SSH host not found'});
}
const host = data[0];
const result = {
...host,
tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [],
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableConfigEditor: !!host.enableConfigEditor,
};
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH host', err);
res.status(500).json({error: 'Failed to fetch SSH host'});
}
});
// Route: Get all unique folders for the authenticated user (requires JWT)
// GET /ssh/folders
router.get('/db/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('/db/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'});
}
});
// Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for recent files fetch');
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for recent files fetch');
return res.status(400).json({error: 'Host ID is required'});
}
try {
const recentFiles = await db
.select()
.from(configEditorRecent)
.where(and(
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.hostId, hostId)
))
.orderBy(desc(configEditorRecent.lastOpened));
res.json(recentFiles);
} catch (err) {
logger.error('Failed to fetch recent files', err);
res.status(500).json({error: 'Failed to fetch recent files'});
}
});
// Route: Add file to recent (requires JWT)
// POST /ssh/config_editor/recent
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding recent file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorRecent)
.where(and(...conditions));
if (existing.length > 0) {
await db
.update(configEditorRecent)
.set({lastOpened: new Date().toISOString()})
.where(and(...conditions));
} else {
// Add new recent file
await db.insert(configEditorRecent).values({
userId,
hostId,
name,
path,
lastOpened: new Date().toISOString()
});
}
res.json({message: 'File added to recent'});
} catch (err) {
logger.error('Failed to add recent file', err);
res.status(500).json({error: 'Failed to add recent file'});
}
});
// Route: Remove file from recent (requires JWT)
// DELETE /ssh/config_editor/recent
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing recent file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const result = await db
.delete(configEditorRecent)
.where(and(...conditions));
res.json({message: 'File removed from recent'});
} catch (err) {
logger.error('Failed to remove recent file', err);
res.status(500).json({error: 'Failed to remove recent file'});
}
});
// Route: Get pinned files (requires JWT)
// GET /ssh/config_editor/pinned
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for pinned files fetch');
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for pinned files fetch');
return res.status(400).json({error: 'Host ID is required'});
}
try {
const pinnedFiles = await db
.select()
.from(configEditorPinned)
.where(and(
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.hostId, hostId)
))
.orderBy(configEditorPinned.pinnedAt);
res.json(pinnedFiles);
} catch (err) {
logger.error('Failed to fetch pinned files', err);
res.status(500).json({error: 'Failed to fetch pinned files'});
}
});
// Route: Add file to pinned (requires JWT)
// POST /ssh/config_editor/pinned
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding pinned file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorPinned)
.where(and(...conditions));
if (existing.length === 0) {
await db.insert(configEditorPinned).values({
userId,
hostId,
name,
path,
pinnedAt: new Date().toISOString()
});
}
res.json({message: 'File pinned successfully'});
} catch (err) {
logger.error('Failed to pin file', err);
res.status(500).json({error: 'Failed to pin file'});
}
});
// Route: Remove file from pinned (requires JWT)
// DELETE /ssh/config_editor/pinned
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing pinned file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const result = await db
.delete(configEditorPinned)
.where(and(...conditions));
res.json({message: 'File unpinned successfully'});
} catch (err) {
logger.error('Failed to unpin file', err);
res.status(500).json({error: 'Failed to unpin file'});
}
});
// Route: Get folder shortcuts (requires JWT)
// GET /ssh/config_editor/shortcuts
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
return res.status(400).json({error: 'Host ID is required'});
}
try {
const shortcuts = await db
.select()
.from(configEditorShortcuts)
.where(and(
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.hostId, hostId)
))
.orderBy(configEditorShortcuts.createdAt);
res.json(shortcuts);
} catch (err) {
logger.error('Failed to fetch shortcuts', err);
res.status(500).json({error: 'Failed to fetch shortcuts'});
}
});
// Route: Add folder shortcut (requires JWT)
// POST /ssh/config_editor/shortcuts
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorShortcuts)
.where(and(...conditions));
if (existing.length === 0) {
await db.insert(configEditorShortcuts).values({
userId,
hostId,
name,
path,
createdAt: new Date().toISOString()
});
}
res.json({message: 'Shortcut added successfully'});
} catch (err) {
logger.error('Failed to add shortcut', err);
res.status(500).json({error: 'Failed to add shortcut'});
}
});
// Route: Remove folder shortcut (requires JWT)
// DELETE /ssh/config_editor/shortcuts
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const result = await db
.delete(configEditorShortcuts)
.where(and(...conditions));
res.json({message: 'Shortcut removed successfully'});
} catch (err) {
logger.error('Failed to remove shortcut', err);
res.status(500).json({error: 'Failed to remove shortcut'});
}
});
export default router;

View File

@@ -0,0 +1,642 @@
import express from 'express';
import {db} from '../db/index.js';
import {users, settings} from '../db/schema.js';
import {eq, and} 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';
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
try {
let jwksUrl: string | null = null;
const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl;
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
if (discoveryResponse.ok) {
const discovery = await discoveryResponse.json() as any;
if (discovery.jwks_uri) {
jwksUrl = discovery.jwks_uri;
} else {
logger.warn('OIDC discovery document does not contain jwks_uri');
}
} else {
logger.warn(`OIDC discovery failed with status: ${discoveryResponse.status}`);
}
} catch (discoveryError) {
logger.warn(`OIDC discovery failed: ${discoveryError}`);
}
if (!jwksUrl) {
jwksUrl = `${normalizedIssuerUrl}/.well-known/jwks.json`;
}
if (!jwksUrl) {
const authentikJwksUrl = `${normalizedIssuerUrl}/jwks/`;
try {
const jwksTestResponse = await fetch(authentikJwksUrl);
if (jwksTestResponse.ok) {
jwksUrl = authentikJwksUrl;
}
} catch (error) {
logger.warn(`Authentik JWKS URL also failed: ${error}`);
}
}
if (!jwksUrl) {
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
const rootJwksUrl = `${baseUrl}/.well-known/jwks.json`;
try {
const jwksTestResponse = await fetch(rootJwksUrl);
if (jwksTestResponse.ok) {
jwksUrl = rootJwksUrl;
}
} catch (error) {
logger.warn(`Authentik root JWKS URL also failed: ${error}`);
}
}
const jwksResponse = await fetch(jwksUrl);
if (!jwksResponse.ok) {
throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksResponse.status}`);
}
const jwks = await jwksResponse.json() as any;
const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString());
const keyId = header.kid;
const publicKey = jwks.keys.find((key: any) => key.kid === keyId);
if (!publicKey) {
throw new Error(`No matching public key found for key ID: ${keyId}`);
}
const { importJWK, jwtVerify } = await import('jose');
const key = await importJWK(publicKey);
const { payload } = await jwtVerify(idToken, key, {
issuer: issuerUrl,
audience: clientId,
});
return payload;
} catch (error) {
logger.error('OIDC token verification failed:', error);
throw error;
}
}
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 traditional user (username/password)
// 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 - missing username or password');
return res.status(400).json({ error: 'Username and password are required' });
}
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,
is_oidc: false,
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: '',
name_path: '',
scopes: 'openid email profile',
});
logger.success(`Traditional 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: Create OIDC provider configuration (admin only)
// POST /users/oidc-config
router.post('/oidc-config', 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 {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
identifier_path,
name_path,
scopes
} = req.body;
if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) ||
!isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) ||
!isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) ||
!isNonEmptyString(name_path)) {
return res.status(400).json({error: 'All OIDC configuration fields are required'});
}
const config = {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
identifier_path,
name_path,
scopes: scopes || 'openid email profile'
};
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config));
res.json({message: 'OIDC configuration updated'});
} catch (err) {
logger.error('Failed to update OIDC config', err);
res.status(500).json({error: 'Failed to update OIDC config'});
}
});
// Route: Get OIDC configuration
// GET /users/oidc-config
router.get('/oidc-config', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!row) {
return res.status(404).json({error: 'OIDC not configured'});
}
res.json(JSON.parse((row as any).value));
} catch (err) {
logger.error('Failed to get OIDC config', err);
res.status(500).json({error: 'Failed to get OIDC config'});
}
});
// Route: Get OIDC authorization URL
// GET /users/oidc/authorize
router.get('/oidc/authorize', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!row) {
return res.status(404).json({error: 'OIDC not configured'});
}
const config = JSON.parse((row as any).value);
const state = nanoid();
const nonce = nanoid();
let origin = req.get('Origin') || req.get('Referer')?.replace(/\/[^\/]*$/, '') || 'http://localhost:5173';
if (origin.includes('localhost')) {
origin = 'http://localhost:8081';
}
const redirectUri = `${origin}/users/oidc/callback`;
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_state_${state}`, nonce);
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_redirect_${state}`, redirectUri);
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set('client_id', config.client_id);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', config.scopes);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce);
res.json({auth_url: authUrl.toString(), state, nonce});
} catch (err) {
logger.error('Failed to generate OIDC auth URL', err);
res.status(500).json({error: 'Failed to generate authorization URL'});
}
});
// Route: OIDC callback - exchange code for token and create/login user
// GET /users/oidc/callback
router.get('/oidc/callback', async (req, res) => {
const {code, state} = req.query;
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
return res.status(400).json({error: 'Code and state are required'});
}
const storedRedirectRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_redirect_${state}`);
if (!storedRedirectRow) {
return res.status(400).json({error: 'Invalid state parameter - redirect URI not found'});
}
const redirectUri = (storedRedirectRow as any).value;
try {
const storedNonce = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_state_${state}`);
if (!storedNonce) {
return res.status(400).json({error: 'Invalid state parameter'});
}
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`oidc_state_${state}`);
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`oidc_redirect_${state}`);
const configRow = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!configRow) {
return res.status(500).json({error: 'OIDC not configured'});
}
const config = JSON.parse((configRow as any).value);
const tokenResponse = await fetch(config.token_url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.client_id,
client_secret: config.client_secret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
logger.error('OIDC token exchange failed', await tokenResponse.text());
return res.status(400).json({error: 'Failed to exchange authorization code'});
}
const tokenData = await tokenResponse.json() as any;
let userInfo;
if (tokenData.id_token) {
try {
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
} catch (error) {
logger.error('OIDC token verification failed, falling back to userinfo endpoint', error);
if (tokenData.access_token) {
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
const userInfoUrl = `${baseUrl}/userinfo/`;
const userInfoResponse = await fetch(userInfoUrl, {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
},
});
if (userInfoResponse.ok) {
userInfo = await userInfoResponse.json();
} else {
logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`);
}
}
}
} else if (tokenData.access_token) {
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
const userInfoUrl = `${baseUrl}/userinfo/`;
const userInfoResponse = await fetch(userInfoUrl, {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
},
});
if (userInfoResponse.ok) {
userInfo = await userInfoResponse.json();
} else {
logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`);
}
}
if (!userInfo) {
return res.status(400).json({error: 'Failed to get user information'});
}
const identifier = userInfo[config.identifier_path];
const name = userInfo[config.name_path] || identifier;
if (!identifier) {
logger.error(`Identifier not found at path: ${config.identifier_path}`);
logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`);
return res.status(400).json({error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(', ')}`});
}
let user = await db
.select()
.from(users)
.where(and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)));
let isFirstUser = false;
if (!user || user.length === 0) {
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 id = nanoid();
await db.insert(users).values({
id,
username: name,
password_hash: '',
is_admin: isFirstUser,
is_oidc: true,
oidc_identifier: identifier,
client_id: config.client_id,
client_secret: config.client_secret,
issuer_url: config.issuer_url,
authorization_url: config.authorization_url,
token_url: config.token_url,
identifier_path: config.identifier_path,
name_path: config.name_path,
scopes: config.scopes,
});
user = await db
.select()
.from(users)
.where(eq(users.id, id));
} else {
await db.update(users)
.set({ username: name })
.where(eq(users.id, user[0].id));
user = await db
.select()
.from(users)
.where(eq(users.id, user[0].id));
}
const userRecord = user[0];
const jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
expiresIn: '50d',
});
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
if (frontendUrl.includes('localhost')) {
frontendUrl = 'http://localhost:5173';
}
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set('success', 'true');
redirectUrl.searchParams.set('token', token);
res.redirect(redirectUrl.toString());
} catch (err) {
logger.error('OIDC callback failed', err);
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
if (frontendUrl.includes('localhost')) {
frontendUrl = 'http://localhost:5173';
}
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set('error', 'OIDC authentication failed');
res.redirect(redirectUrl.toString());
}
});
// Route: Get user JWT by username and password (traditional login)
// POST /users/login
router.post('/login', async (req, res) => {
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid traditional login 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];
if (userRecord.is_oidc) {
return res.status(403).json({ error: 'This user uses external authentication' });
}
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',
});
return res.json({
token,
is_admin: !!userRecord.is_admin,
username: userRecord.username
});
} catch (err) {
logger.error('Failed to log in user', err);
return res.status(500).json({ error: 'Login failed' });
}
});
// Route: Get current user's info 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,
is_oidc: !!user[0].is_oidc
});
} 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;

363
src/backend/ssh/ssh.ts Normal file
View File

@@ -0,0 +1,363 @@
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;
let pingInterval: NodeJS.Timeout | 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) {
if (data === '\t') {
sshStream.write(data);
} else if (data.startsWith('\x1b')) {
sshStream.write(data);
} else {
sshStream.write(Buffer.from(data, 'utf8'));
}
}
break;
case 'ping':
ws.send(JSON.stringify({type: 'pong'}));
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;
keyPassword?: string;
keyType?: string;
authType?: string;
};
}) {
const {cols, rows, hostConfig} = data;
const {ip, port, username, password, key, keyPassword, keyType, authType} = 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();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
logger.error('SSH connection timeout');
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
cleanupSSH(connectionTimeout);
}
}, 15000);
sshConn.on('ready', () => {
clearTimeout(connectionTimeout);
const pseudoTtyOpts: PseudoTtyOptions = {
term: 'xterm-256color',
cols,
rows,
modes: {
ECHO: 1,
ECHOCTL: 0,
ICANON: 1,
ISIG: 1,
ICRNL: 1,
IXON: 1,
IXOFF: 0,
ISTRIP: 0,
OPOST: 1,
ONLCR: 1,
OCRNL: 0,
ONOCR: 0,
ONLRET: 0,
CS7: 0,
CS8: 1,
PARENB: 0,
PARODD: 0,
TTY_OP_ISPEED: 38400,
TTY_OP_OSPEED: 38400,
}
};
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) => {
let data: string;
try {
data = chunk.toString('utf8');
} catch (e) {
data = chunk.toString('binary');
}
ws.send(JSON.stringify({type: 'data', data}));
});
stream.on('close', () => {
cleanupSSH(connectionTimeout);
});
stream.on('error', (err: Error) => {
logger.error('SSH stream error: ' + err.message);
const isConnectionError = err.message.includes('ECONNRESET') ||
err.message.includes('EPIPE') ||
err.message.includes('ENOTCONN') ||
err.message.includes('ETIMEDOUT');
if (isConnectionError) {
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
} else {
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
}
});
setupPingInterval();
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
});
});
sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout);
logger.error('SSH connection error: ' + err.message);
let errorMessage = 'SSH error: ' + err.message;
if (err.message.includes('No matching key exchange algorithm')) {
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('No matching cipher')) {
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('No matching MAC')) {
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
} else if (err.message.includes('ECONNREFUSED')) {
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
} else if (err.message.includes('ETIMEDOUT')) {
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.';
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) {
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.';
}
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
cleanupSSH(connectionTimeout);
});
sshConn.on('close', () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 10000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: 'xterm-256color',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
LC_CTYPE: 'en_US.UTF-8',
LC_MESSAGES: 'en_US.UTF-8',
LC_MONETARY: 'en_US.UTF-8',
LC_NUMERIC: 'en_US.UTF-8',
LC_TIME: 'en_US.UTF-8',
LC_COLLATE: 'en_US.UTF-8',
COLORTERM: 'truecolor',
},
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (authType === 'key' && key) {
connectConfig.privateKey = key;
if (keyPassword) {
connectConfig.passphrase = keyPassword;
}
if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType;
}
} else if (authType === 'key') {
logger.error('SSH key authentication requested but no key provided');
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
return;
} 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(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
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;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write('\x00');
} catch (e: any) {
logger.error('SSH keepalive failed: ' + e.message);
cleanupSSH();
}
}
}, 60000);
}
});

File diff suppressed because it is too large Load Diff

55
src/backend/starter.ts Normal file
View File

@@ -0,0 +1,55 @@
// npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js
import './database/database.js'
import './ssh/ssh.js';
import './ssh_tunnel/ssh_tunnel.js';
import './config_editor/config_editor.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);
}
})();

View File

@@ -0,0 +1,73 @@
import {createContext, useContext, useEffect, useState} from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

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

View 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 font-medium tracking-tight whitespace-normal break-words",
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 }

View File

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

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

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

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

165
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

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

View File

@@ -0,0 +1,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] transition-colors duration-150",
className
)}
{...props}
>
{withHandle && (
<div className="bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

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

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

137
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,725 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
// eslint-disable-next-line react-refresh/only-export-components
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

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

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

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

133
src/index.css Normal file
View File

@@ -0,0 +1,133 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #09090b;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {ThemeProvider} from "@/components/theme-provider"
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App/>
</ThemeProvider>
</StrictMode>,
)

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />