mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
e9e30cd318
* Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes * Merge 2.0.0 with 2.0.0 that includes bug fixes (#620) * Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: allow customizing guacd backened url * fix: ssh route mistmatching and guacamole url not changing * chore: increment ver * feat: change default to work with default compose, added splits creen support, updated readmes * fix: linux app not starting due to better sqlite isuses, improved copy/paste system so no context menu, added oidc remember me toggle, improved OS detection for sessions, flatpak invalid key, and sharing hosts with other users errors * fix: global settings not setting * chore: update compose * feat: improve the global status input * chore: cleanup files * chore: update export/improt with new host fields * fix: file manager and docker not loading properly --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1567 lines
52 KiB
TypeScript
1567 lines
52 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
import { cn } from "@/lib/utils.ts";
|
|
|
|
import { Button } from "@/components/ui/button.tsx";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
} from "@/components/ui/form.tsx";
|
|
import { Input } from "@/components/ui/input.tsx";
|
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
|
import { Badge } from "@/components/ui/badge.tsx";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table.tsx";
|
|
import { Textarea } from "@/components/ui/textarea.tsx";
|
|
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
|
import { Separator } from "@/components/ui/separator.tsx";
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "@/components/ui/tabs.tsx";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { Switch } from "@/components/ui/switch.tsx";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
|
import { toast } from "sonner";
|
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
|
import {
|
|
createSSHHost,
|
|
getCredentials,
|
|
getSSHHosts,
|
|
updateSSHHost,
|
|
enableAutoStart,
|
|
disableAutoStart,
|
|
getSnippets,
|
|
getRoles,
|
|
getUserList,
|
|
getUserInfo,
|
|
shareHost,
|
|
getHostAccess,
|
|
revokeHostAccess,
|
|
getSSHHostById,
|
|
notifyHostCreatedOrUpdated,
|
|
getGuacamoleSettings,
|
|
type Role,
|
|
type AccessRecord,
|
|
} from "@/ui/main-axios.ts";
|
|
import { useTranslation } from "react-i18next";
|
|
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
|
import CodeMirror from "@uiw/react-codemirror";
|
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
import { githubLight } from "@uiw/codemirror-theme-github";
|
|
import { EditorView } from "@codemirror/view";
|
|
import { useTheme } from "@/components/theme-provider.tsx";
|
|
import type { StatsConfig } from "@/types/stats-widgets.ts";
|
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
|
|
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select.tsx";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
} from "@/components/ui/command.tsx";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover.tsx";
|
|
import { Slider } from "@/components/ui/slider.tsx";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@/components/ui/accordion.tsx";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog.tsx";
|
|
import {
|
|
TERMINAL_THEMES,
|
|
TERMINAL_FONTS,
|
|
CURSOR_STYLES,
|
|
BELL_STYLES,
|
|
FAST_SCROLL_MODIFIERS,
|
|
DEFAULT_TERMINAL_CONFIG,
|
|
} from "@/constants/terminal-themes.ts";
|
|
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
|
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
|
import {
|
|
Plus,
|
|
X,
|
|
Check,
|
|
ChevronsUpDown,
|
|
Save,
|
|
AlertCircle,
|
|
Trash2,
|
|
Users,
|
|
Shield,
|
|
Clock,
|
|
UserCircle,
|
|
ArrowLeft,
|
|
} from "lucide-react";
|
|
import { HostGeneralTab } from "./tabs/HostGeneralTab";
|
|
import { HostTerminalTab } from "./tabs/HostTerminalTab";
|
|
import { HostDockerTab } from "./tabs/HostDockerTab";
|
|
import { HostTunnelTab } from "./tabs/HostTunnelTab";
|
|
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
|
|
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
|
|
import { HostStatusTab } from "./tabs/HostStatusTab";
|
|
import { HostSharingTab } from "./tabs/HostSharingTab";
|
|
import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab";
|
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
|
|
|
interface User {
|
|
id: string;
|
|
username: string;
|
|
is_admin: boolean;
|
|
}
|
|
|
|
interface SSHManagerHostEditorProps {
|
|
editingHost?: SSHHost | null;
|
|
onFormSubmit?: (updatedHost?: SSHHost) => void;
|
|
onBack?: () => void;
|
|
}
|
|
|
|
export function HostManagerEditor({
|
|
editingHost,
|
|
onFormSubmit,
|
|
onBack,
|
|
}: SSHManagerHostEditorProps) {
|
|
const { t } = useTranslation();
|
|
const { theme: appTheme } = useTheme();
|
|
|
|
const isDarkMode =
|
|
appTheme === "dark" ||
|
|
(appTheme === "system" &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
const editorTheme = isDarkMode ? oneDark : githubLight;
|
|
const [folders, setFolders] = useState<string[]>([]);
|
|
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
const [snippets, setSnippets] = useState<
|
|
Array<{ id: number; name: string; content: string }>
|
|
>([]);
|
|
const [proxyMode, setProxyMode] = useState<"single" | "chain">("single");
|
|
|
|
const [authTab, setAuthTab] = useState<
|
|
"password" | "key" | "credential" | "none" | "opkssh"
|
|
>("password");
|
|
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
|
"upload",
|
|
);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [activeTab, setActiveTab] = useState("general");
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [guacEnabled, setGuacEnabled] = useState(true);
|
|
|
|
useEffect(() => {
|
|
setFormError(null);
|
|
}, [activeTab]);
|
|
|
|
useEffect(() => {
|
|
getGuacamoleSettings()
|
|
.then((data) => setGuacEnabled(data.enabled))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
|
"seconds" | "minutes"
|
|
>("seconds");
|
|
const [metricsIntervalUnit, setMetricsIntervalUnit] = useState<
|
|
"seconds" | "minutes"
|
|
>("seconds");
|
|
|
|
const ipInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const [hostsData, credentialsData, snippetsData] = await Promise.all([
|
|
getSSHHosts(),
|
|
getCredentials(),
|
|
getSnippets(),
|
|
]);
|
|
setHosts(hostsData);
|
|
setCredentials(credentialsData as Credential[]);
|
|
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
|
|
|
const uniqueFolders = [
|
|
...new Set(
|
|
hostsData
|
|
.filter((host) => host.folder && host.folder.trim() !== "")
|
|
.map((host) => host.folder),
|
|
),
|
|
].sort();
|
|
|
|
const uniqueConfigurations = [
|
|
...new Set(
|
|
hostsData
|
|
.filter((host) => host.name && host.name.trim() !== "")
|
|
.map((host) => host.name),
|
|
),
|
|
].sort();
|
|
|
|
setFolders(uniqueFolders);
|
|
setSshConfigurations(uniqueConfigurations);
|
|
} catch (error) {
|
|
console.error("Host manager operation failed:", error);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleCredentialChange = async () => {
|
|
try {
|
|
const hostsData = await getSSHHosts();
|
|
|
|
const uniqueFolders = [
|
|
...new Set(
|
|
hostsData
|
|
.filter((host) => host.folder && host.folder.trim() !== "")
|
|
.map((host) => host.folder),
|
|
),
|
|
].sort();
|
|
|
|
const uniqueConfigurations = [
|
|
...new Set(
|
|
hostsData
|
|
.filter((host) => host.name && host.name.trim() !== "")
|
|
.map((host) => host.name),
|
|
),
|
|
].sort();
|
|
|
|
setFolders(uniqueFolders);
|
|
setSshConfigurations(uniqueConfigurations);
|
|
} catch (error) {
|
|
console.error("Host manager operation failed:", error);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("credentials:changed", handleCredentialChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("credentials:changed", handleCredentialChange);
|
|
};
|
|
}, []);
|
|
|
|
const formSchema = z
|
|
.object({
|
|
connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
|
|
name: z.string().optional(),
|
|
ip: z.string().min(1, t("hosts.ipRequired", "IP address is required")),
|
|
port: z.coerce.number().min(1).max(65535),
|
|
username: z.string().optional(),
|
|
folder: z.string().optional(),
|
|
tags: z.array(z.string().min(1)).default([]),
|
|
pin: z.boolean().default(false),
|
|
authType: z.enum(["password", "key", "credential", "none", "opkssh"]),
|
|
credentialId: z.number().optional().nullable(),
|
|
overrideCredentialUsername: z.boolean().optional(),
|
|
password: z.string().optional(),
|
|
key: z.any().optional().nullable(),
|
|
keyPassword: z.string().optional(),
|
|
keyType: z
|
|
.enum([
|
|
"auto",
|
|
"ssh-rsa",
|
|
"ssh-ed25519",
|
|
"ecdsa-sha2-nistp256",
|
|
"ecdsa-sha2-nistp384",
|
|
"ecdsa-sha2-nistp521",
|
|
"ssh-dss",
|
|
"ssh-rsa-sha2-256",
|
|
"ssh-rsa-sha2-512",
|
|
])
|
|
.optional(),
|
|
enableTerminal: z.boolean().default(true),
|
|
enableTunnel: z.boolean().default(true),
|
|
tunnelConnections: z
|
|
.array(
|
|
z.object({
|
|
tunnelType: z
|
|
.enum(["local", "remote"])
|
|
.default("remote")
|
|
.optional(),
|
|
sourcePort: z.coerce.number().min(1).max(65535),
|
|
endpointPort: z.coerce.number().min(1).max(65535),
|
|
endpointHost: z.string().min(1),
|
|
endpointPassword: z.string().optional(),
|
|
endpointKey: z.string().optional(),
|
|
endpointKeyPassword: z.string().optional(),
|
|
endpointAuthType: z.string().optional(),
|
|
endpointKeyType: z.string().optional(),
|
|
maxRetries: z.coerce.number().min(0).max(100).default(3),
|
|
retryInterval: z.coerce.number().min(1).max(3600).default(10),
|
|
autoStart: z.boolean().default(false),
|
|
}),
|
|
)
|
|
.default([]),
|
|
enableFileManager: z.boolean().default(true),
|
|
defaultPath: z.string().optional(),
|
|
statsConfig: z
|
|
.object({
|
|
enabledWidgets: z
|
|
.array(
|
|
z.enum([
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"network",
|
|
"uptime",
|
|
"processes",
|
|
"system",
|
|
"login_stats",
|
|
"ports",
|
|
"firewall",
|
|
]),
|
|
)
|
|
.default([
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"network",
|
|
"uptime",
|
|
"system",
|
|
"login_stats",
|
|
"ports",
|
|
"firewall",
|
|
]),
|
|
statusCheckEnabled: z.boolean().default(true),
|
|
statusCheckInterval: z.number().min(5).max(3600).default(30),
|
|
useGlobalStatusInterval: z.boolean().default(true),
|
|
metricsEnabled: z.boolean().default(true),
|
|
metricsInterval: z.number().min(5).max(3600).default(30),
|
|
useGlobalMetricsInterval: z.boolean().default(true),
|
|
disableTcpPing: z.boolean().default(false),
|
|
})
|
|
.default({
|
|
enabledWidgets: [
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"network",
|
|
"uptime",
|
|
"system",
|
|
"login_stats",
|
|
"ports",
|
|
"firewall",
|
|
],
|
|
statusCheckEnabled: true,
|
|
statusCheckInterval: 30,
|
|
useGlobalStatusInterval: true,
|
|
metricsEnabled: true,
|
|
metricsInterval: 30,
|
|
useGlobalMetricsInterval: true,
|
|
disableTcpPing: false,
|
|
}),
|
|
terminalConfig: z
|
|
.object({
|
|
cursorBlink: z.boolean(),
|
|
cursorStyle: z.enum(["block", "underline", "bar"]),
|
|
fontSize: z.number().min(8).max(24),
|
|
fontFamily: z.string(),
|
|
letterSpacing: z.number().min(-2).max(10),
|
|
lineHeight: z.number().min(1.0).max(2.0),
|
|
theme: z.string(),
|
|
scrollback: z.number().min(1000).max(50000),
|
|
bellStyle: z.enum(["none", "sound", "visual", "both"]),
|
|
rightClickSelectsWord: z.boolean(),
|
|
fastScrollModifier: z.enum(["alt", "ctrl", "shift"]),
|
|
fastScrollSensitivity: z.number().min(1).max(10),
|
|
minimumContrastRatio: z.number().min(1).max(21),
|
|
backspaceMode: z.enum(["normal", "control-h"]),
|
|
agentForwarding: z.boolean(),
|
|
environmentVariables: z.array(
|
|
z.object({
|
|
key: z.string(),
|
|
value: z.string(),
|
|
}),
|
|
),
|
|
startupSnippetId: z.number().nullable(),
|
|
autoMosh: z.boolean(),
|
|
moshCommand: z.string(),
|
|
sudoPasswordAutoFill: z.boolean(),
|
|
sudoPassword: z.string().optional(),
|
|
keepaliveInterval: z.number().min(0).max(300000).optional(),
|
|
keepaliveCountMax: z.number().min(0).max(100).optional(),
|
|
})
|
|
.optional(),
|
|
forceKeyboardInteractive: z.boolean().optional(),
|
|
jumpHosts: z
|
|
.array(
|
|
z.object({
|
|
hostId: z.number().min(1),
|
|
}),
|
|
)
|
|
.default([]),
|
|
quickActions: z
|
|
.array(
|
|
z.object({
|
|
name: z.string().min(1),
|
|
snippetId: z.number().min(1),
|
|
}),
|
|
)
|
|
.default([]),
|
|
notes: z.string().optional(),
|
|
useSocks5: z.boolean().optional(),
|
|
socks5Host: z.string().optional(),
|
|
socks5Port: z.coerce.number().min(1).max(65535).optional(),
|
|
socks5Username: z.string().optional(),
|
|
socks5Password: z.string().optional(),
|
|
socks5ProxyChain: z
|
|
.array(
|
|
z.object({
|
|
host: z.string().min(1),
|
|
port: z.number().min(1).max(65535),
|
|
type: z.union([z.literal(4), z.literal(5)]),
|
|
username: z.string().optional(),
|
|
password: z.string().optional(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
enableDocker: z.boolean().default(false),
|
|
domain: z.string().optional(),
|
|
security: z.string().optional(),
|
|
ignoreCert: z.boolean().default(true),
|
|
guacamoleConfig: z.record(z.string(), z.unknown()).optional(),
|
|
showTerminalInSidebar: z.boolean().default(true),
|
|
showFileManagerInSidebar: z.boolean().default(false),
|
|
showTunnelInSidebar: z.boolean().default(false),
|
|
showDockerInSidebar: z.boolean().default(false),
|
|
showServerStatsInSidebar: z.boolean().default(false),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.connectionType !== "ssh") {
|
|
return;
|
|
}
|
|
|
|
if (!data.username || data.username.trim() === "") {
|
|
if (data.authType !== "credential") {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("hosts.usernameRequired", "Username is required"),
|
|
path: ["username"],
|
|
});
|
|
}
|
|
}
|
|
|
|
if (data.authType === "none") {
|
|
return;
|
|
}
|
|
|
|
if (data.authType === "opkssh") {
|
|
return;
|
|
}
|
|
|
|
if (data.authType === "password") {
|
|
if (
|
|
!data.password ||
|
|
(typeof data.password === "string" && data.password.trim() === "")
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("hosts.passwordRequired", "Password is required"),
|
|
path: ["password"],
|
|
});
|
|
}
|
|
} else if (data.authType === "key") {
|
|
if (
|
|
!data.key ||
|
|
(typeof data.key === "string" &&
|
|
data.key.trim() === "" &&
|
|
data.key !== "existing_key")
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("hosts.sshKeyRequired", "SSH key is required"),
|
|
path: ["key"],
|
|
});
|
|
}
|
|
if (!data.keyType) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("hosts.keyTypeRequired", "Key type is required"),
|
|
path: ["keyType"],
|
|
});
|
|
}
|
|
} else if (data.authType === "credential") {
|
|
if (!data.credentialId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("hosts.credentialRequired", "Credential is required"),
|
|
path: ["credentialId"],
|
|
});
|
|
}
|
|
}
|
|
|
|
data.tunnelConnections.forEach((connection, index) => {
|
|
if (
|
|
connection.endpointHost &&
|
|
!sshConfigurations.includes(connection.endpointHost)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("hosts.mustSelectValidSshConfig"),
|
|
path: ["tunnelConnections", index, "endpointHost"],
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
type FormData = z.infer<typeof formSchema>;
|
|
|
|
const form = useForm<FormData>({
|
|
resolver: zodResolver(formSchema) as any,
|
|
mode: "all",
|
|
defaultValues: {
|
|
connectionType: "ssh" as const,
|
|
name: "",
|
|
ip: "",
|
|
port: 22,
|
|
username: "",
|
|
folder: "",
|
|
tags: [],
|
|
pin: false,
|
|
authType: "password" as const,
|
|
credentialId: null,
|
|
overrideCredentialUsername: false,
|
|
password: "",
|
|
key: null,
|
|
keyPassword: "",
|
|
keyType: "auto" as const,
|
|
enableTerminal: true,
|
|
enableTunnel: true,
|
|
enableFileManager: true,
|
|
showTerminalInSidebar: true,
|
|
showFileManagerInSidebar: false,
|
|
showTunnelInSidebar: false,
|
|
showDockerInSidebar: false,
|
|
showServerStatsInSidebar: false,
|
|
defaultPath: "/",
|
|
tunnelConnections: [],
|
|
jumpHosts: [],
|
|
quickActions: [],
|
|
statsConfig: DEFAULT_STATS_CONFIG,
|
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
|
forceKeyboardInteractive: false,
|
|
notes: "",
|
|
useSocks5: false,
|
|
socks5Host: "",
|
|
socks5Port: 1080,
|
|
socks5Username: "",
|
|
socks5Password: "",
|
|
socks5ProxyChain: [],
|
|
enableDocker: false,
|
|
domain: "",
|
|
security: "any",
|
|
ignoreCert: true,
|
|
guacamoleConfig: {},
|
|
},
|
|
});
|
|
|
|
const watchedFields = form.watch();
|
|
const formState = form.formState;
|
|
const watchedConnectionType = form.watch("connectionType") || "ssh";
|
|
|
|
const prevConnectionTypeRef = useRef<string>("ssh");
|
|
useEffect(() => {
|
|
const prev = prevConnectionTypeRef.current;
|
|
const current = watchedConnectionType;
|
|
if (prev === current) return;
|
|
prevConnectionTypeRef.current = current;
|
|
|
|
const portDefaults: Record<string, number> = {
|
|
ssh: 22,
|
|
rdp: 3389,
|
|
vnc: 5900,
|
|
telnet: 23,
|
|
};
|
|
const currentPort = form.getValues("port");
|
|
const oldDefault = portDefaults[prev] || 22;
|
|
if (currentPort === oldDefault) {
|
|
form.setValue("port", portDefaults[current] || 22);
|
|
}
|
|
|
|
if (current !== "ssh") {
|
|
const currentStatsConfig = form.getValues("statsConfig");
|
|
form.setValue("statsConfig", {
|
|
...currentStatsConfig,
|
|
metricsEnabled: false,
|
|
disableTcpPing: false,
|
|
});
|
|
}
|
|
|
|
if (activeTab !== "general" && current !== "ssh") {
|
|
setActiveTab("general");
|
|
}
|
|
}, [watchedConnectionType]);
|
|
|
|
const isFormValid = React.useMemo(() => {
|
|
const errors = formState.errors;
|
|
|
|
if (!watchedFields.ip) return false;
|
|
|
|
if (watchedFields.connectionType !== "ssh") {
|
|
const port = Number(watchedFields.port);
|
|
return !errors.ip && port >= 1 && port <= 65535;
|
|
}
|
|
|
|
if (!watchedFields.username || watchedFields.username.trim() === "")
|
|
return false;
|
|
|
|
if (authTab === "password") {
|
|
if (!watchedFields.password || watchedFields.password.trim() === "")
|
|
return false;
|
|
} else if (authTab === "key") {
|
|
if (!watchedFields.key || !watchedFields.keyType) return false;
|
|
} else if (authTab === "credential") {
|
|
if (!watchedFields.credentialId) return false;
|
|
} else if (authTab === "none") {
|
|
// No auth required
|
|
} else if (authTab === "opkssh") {
|
|
// No auth required
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
return Object.keys(errors).length === 0;
|
|
}, [watchedFields, authTab, formState.errors]);
|
|
|
|
useEffect(() => {
|
|
const updateAuthFields = async () => {
|
|
form.setValue("authType", authTab, { shouldValidate: true });
|
|
|
|
if (authTab === "password") {
|
|
form.setValue("key", null, { shouldValidate: true });
|
|
form.setValue("keyPassword", "", { shouldValidate: true });
|
|
form.setValue("keyType", "auto", { shouldValidate: true });
|
|
form.setValue("credentialId", null, { shouldValidate: true });
|
|
} else if (authTab === "key") {
|
|
form.setValue("password", "", { shouldValidate: true });
|
|
form.setValue("credentialId", null, { shouldValidate: true });
|
|
} else if (authTab === "credential") {
|
|
form.setValue("password", "", { shouldValidate: true });
|
|
form.setValue("key", null, { shouldValidate: true });
|
|
form.setValue("keyPassword", "", { shouldValidate: true });
|
|
form.setValue("keyType", "auto", { shouldValidate: true });
|
|
|
|
const currentCredentialId = form.getValues("credentialId");
|
|
const overrideUsername = form.getValues("overrideCredentialUsername");
|
|
if (currentCredentialId && !overrideUsername) {
|
|
const selectedCredential = credentials.find(
|
|
(c) => c.id === currentCredentialId,
|
|
);
|
|
if (selectedCredential?.username) {
|
|
form.setValue("username", selectedCredential.username, {
|
|
shouldValidate: true,
|
|
});
|
|
}
|
|
}
|
|
} else if (authTab === "none") {
|
|
const connectionType = form.getValues("connectionType");
|
|
if (connectionType === "ssh") {
|
|
form.setValue("password", "", { shouldValidate: true });
|
|
}
|
|
form.setValue("key", null, { shouldValidate: true });
|
|
form.setValue("keyPassword", "", { shouldValidate: true });
|
|
form.setValue("keyType", "auto", { shouldValidate: true });
|
|
form.setValue("credentialId", null, { shouldValidate: true });
|
|
} else if (authTab === "opkssh") {
|
|
form.setValue("password", "", { shouldValidate: true });
|
|
form.setValue("key", null, { shouldValidate: true });
|
|
form.setValue("keyPassword", "", { shouldValidate: true });
|
|
form.setValue("keyType", "auto", { shouldValidate: true });
|
|
form.setValue("credentialId", null, { shouldValidate: true });
|
|
}
|
|
|
|
await form.trigger();
|
|
};
|
|
|
|
updateAuthFields();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [authTab, credentials]);
|
|
|
|
useEffect(() => {
|
|
if (editingHost) {
|
|
const cleanedHost = { ...editingHost };
|
|
if ((cleanedHost as any).connectionType === "ssh") {
|
|
if (cleanedHost.credentialId && cleanedHost.key) {
|
|
cleanedHost.key = undefined;
|
|
cleanedHost.keyPassword = undefined;
|
|
cleanedHost.keyType = undefined;
|
|
} else if (cleanedHost.credentialId && cleanedHost.password) {
|
|
cleanedHost.password = undefined;
|
|
} else if (cleanedHost.key && cleanedHost.password) {
|
|
cleanedHost.password = undefined;
|
|
}
|
|
}
|
|
|
|
const defaultAuthType = (cleanedHost.authType ||
|
|
(cleanedHost.credentialId
|
|
? "credential"
|
|
: cleanedHost.key
|
|
? "key"
|
|
: cleanedHost.password
|
|
? "password"
|
|
: "none")) as
|
|
| "password"
|
|
| "key"
|
|
| "credential"
|
|
| "none"
|
|
| "opkssh";
|
|
|
|
setAuthTab(defaultAuthType);
|
|
|
|
let parsedStatsConfig: StatsConfig = DEFAULT_STATS_CONFIG;
|
|
try {
|
|
if (cleanedHost.statsConfig) {
|
|
parsedStatsConfig =
|
|
typeof cleanedHost.statsConfig === "string"
|
|
? JSON.parse(cleanedHost.statsConfig)
|
|
: (cleanedHost.statsConfig as StatsConfig);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to parse statsConfig:", error);
|
|
}
|
|
|
|
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
|
|
|
const formData: Partial<FormData> = {
|
|
connectionType: (cleanedHost as any).connectionType || "ssh",
|
|
name: cleanedHost.name || "",
|
|
ip: cleanedHost.ip || "",
|
|
port: cleanedHost.port || 22,
|
|
username: cleanedHost.username || "",
|
|
folder: cleanedHost.folder || "",
|
|
tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [],
|
|
pin: Boolean(cleanedHost.pin),
|
|
authType: defaultAuthType as
|
|
| "password"
|
|
| "key"
|
|
| "credential"
|
|
| "none"
|
|
| "opkssh",
|
|
credentialId: cleanedHost.credentialId,
|
|
overrideCredentialUsername: Boolean(
|
|
cleanedHost.overrideCredentialUsername,
|
|
),
|
|
password: "",
|
|
key: null,
|
|
keyPassword: "",
|
|
keyType: "auto" as const,
|
|
enableTerminal: Boolean(cleanedHost.enableTerminal),
|
|
enableTunnel: Boolean(cleanedHost.enableTunnel),
|
|
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
|
defaultPath: cleanedHost.defaultPath || "/",
|
|
tunnelConnections: Array.isArray(cleanedHost.tunnelConnections)
|
|
? cleanedHost.tunnelConnections.map((conn: any) => ({
|
|
...conn,
|
|
tunnelType: conn.tunnelType || "remote",
|
|
}))
|
|
: [],
|
|
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
|
|
? cleanedHost.jumpHosts
|
|
: [],
|
|
quickActions: Array.isArray(cleanedHost.quickActions)
|
|
? cleanedHost.quickActions
|
|
: [],
|
|
statsConfig: parsedStatsConfig,
|
|
terminalConfig: {
|
|
...DEFAULT_TERMINAL_CONFIG,
|
|
...(cleanedHost.terminalConfig || {}),
|
|
environmentVariables: Array.isArray(
|
|
cleanedHost.terminalConfig?.environmentVariables,
|
|
)
|
|
? cleanedHost.terminalConfig.environmentVariables
|
|
: [],
|
|
sudoPassword:
|
|
cleanedHost.sudoPassword ||
|
|
cleanedHost.terminalConfig?.sudoPassword ||
|
|
"",
|
|
sudoPasswordAutoFill:
|
|
cleanedHost.terminalConfig?.sudoPasswordAutoFill ??
|
|
Boolean(cleanedHost.sudoPassword),
|
|
},
|
|
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
|
notes: cleanedHost.notes || "",
|
|
useSocks5: Boolean(cleanedHost.useSocks5),
|
|
socks5Host: cleanedHost.socks5Host || "",
|
|
socks5Port: cleanedHost.socks5Port || 1080,
|
|
socks5Username: cleanedHost.socks5Username || "",
|
|
socks5Password: cleanedHost.socks5Password || "",
|
|
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
|
|
? cleanedHost.socks5ProxyChain
|
|
: [],
|
|
enableDocker: Boolean(cleanedHost.enableDocker),
|
|
domain: (cleanedHost as any).domain || "",
|
|
security: (cleanedHost as any).security || "any",
|
|
ignoreCert: (cleanedHost as any).ignoreCert ?? true,
|
|
guacamoleConfig: (() => {
|
|
const cfg = (cleanedHost as any).guacamoleConfig;
|
|
if (!cfg) return {};
|
|
if (typeof cfg === "string") {
|
|
try {
|
|
return JSON.parse(cfg);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return cfg;
|
|
})(),
|
|
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
|
|
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
|
|
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
|
|
showDockerInSidebar: cleanedHost.showDockerInSidebar ?? false,
|
|
showServerStatsInSidebar: cleanedHost.showServerStatsInSidebar ?? false,
|
|
};
|
|
|
|
if (
|
|
Array.isArray(cleanedHost.socks5ProxyChain) &&
|
|
cleanedHost.socks5ProxyChain.length > 0
|
|
) {
|
|
setProxyMode("chain");
|
|
} else {
|
|
setProxyMode("single");
|
|
}
|
|
|
|
if (cleanedHost.connectionType !== "ssh") {
|
|
if (cleanedHost.password) {
|
|
formData.password = cleanedHost.password;
|
|
}
|
|
} else if (defaultAuthType === "password") {
|
|
formData.password = cleanedHost.password || "";
|
|
} else if (defaultAuthType === "key") {
|
|
formData.key = editingHost.id ? "existing_key" : editingHost.key;
|
|
formData.keyPassword = cleanedHost.keyPassword || "";
|
|
formData.keyType =
|
|
(cleanedHost.keyType as
|
|
| "auto"
|
|
| "ssh-rsa"
|
|
| "ssh-ed25519"
|
|
| "ecdsa-sha2-nistp256"
|
|
| "ecdsa-sha2-nistp384"
|
|
| "ecdsa-sha2-nistp521"
|
|
| "ssh-dss"
|
|
| "ssh-rsa-sha2-256"
|
|
| "ssh-rsa-sha2-512") || "auto";
|
|
} else if (defaultAuthType === "credential") {
|
|
formData.credentialId = cleanedHost.credentialId;
|
|
}
|
|
|
|
form.reset(formData as FormData);
|
|
} else {
|
|
setAuthTab("password");
|
|
const defaultFormData: Partial<FormData> = {
|
|
connectionType: "ssh" as const,
|
|
name: "",
|
|
ip: "",
|
|
port: 22,
|
|
username: "",
|
|
folder: "",
|
|
tags: [],
|
|
pin: false,
|
|
authType: "password" as const,
|
|
credentialId: null,
|
|
overrideCredentialUsername: false,
|
|
password: "",
|
|
key: null,
|
|
keyPassword: "",
|
|
keyType: "auto" as const,
|
|
enableTerminal: true,
|
|
enableTunnel: true,
|
|
enableFileManager: true,
|
|
defaultPath: "/",
|
|
tunnelConnections: [],
|
|
jumpHosts: [],
|
|
quickActions: [],
|
|
statsConfig: DEFAULT_STATS_CONFIG,
|
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
|
forceKeyboardInteractive: false,
|
|
enableDocker: false,
|
|
domain: "",
|
|
security: "any",
|
|
ignoreCert: true,
|
|
guacamoleConfig: {},
|
|
showTerminalInSidebar: true,
|
|
showFileManagerInSidebar: false,
|
|
showTunnelInSidebar: false,
|
|
showDockerInSidebar: false,
|
|
showServerStatsInSidebar: false,
|
|
};
|
|
|
|
form.reset(defaultFormData as FormData);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [editingHost]);
|
|
|
|
useEffect(() => {
|
|
const focusTimer = setTimeout(() => {
|
|
if (ipInputRef.current) {
|
|
ipInputRef.current.focus();
|
|
}
|
|
}, 300);
|
|
|
|
return () => clearTimeout(focusTimer);
|
|
}, [editingHost]);
|
|
|
|
const onSubmit = async (data: FormData) => {
|
|
try {
|
|
setIsSubmitting(true);
|
|
setFormError(null);
|
|
|
|
if (!data.name || data.name.trim() === "") {
|
|
data.name = `${data.username}@${data.ip}`;
|
|
}
|
|
|
|
const submitData: Partial<SSHHost> = {
|
|
...data,
|
|
};
|
|
|
|
(submitData as any).connectionType = data.connectionType;
|
|
(submitData as any).domain = data.domain;
|
|
(submitData as any).security = data.security;
|
|
(submitData as any).ignoreCert = data.ignoreCert;
|
|
(submitData as any).guacamoleConfig = data.guacamoleConfig;
|
|
|
|
if (data.connectionType !== "ssh") {
|
|
submitData.authType = "none";
|
|
submitData.key = undefined;
|
|
submitData.keyPassword = undefined;
|
|
submitData.keyType = undefined;
|
|
submitData.credentialId = undefined;
|
|
submitData.tunnelConnections = [];
|
|
submitData.jumpHosts = [];
|
|
(submitData as any).useSocks5 = false;
|
|
(submitData as any).socks5ProxyChain = [];
|
|
submitData.forceKeyboardInteractive = false;
|
|
submitData.enableTunnel = false;
|
|
submitData.enableFileManager = false;
|
|
submitData.enableDocker = false;
|
|
submitData.enableTerminal = true;
|
|
} else {
|
|
if (
|
|
data.terminalConfig?.sudoPasswordAutoFill &&
|
|
data.terminalConfig?.sudoPassword
|
|
) {
|
|
submitData.sudoPassword = data.terminalConfig.sudoPassword;
|
|
}
|
|
|
|
if (data.authType !== "credential") {
|
|
submitData.credentialId = undefined;
|
|
}
|
|
if (data.authType !== "password") {
|
|
submitData.password = undefined;
|
|
}
|
|
if (data.authType !== "key") {
|
|
submitData.key = undefined;
|
|
submitData.keyPassword = undefined;
|
|
submitData.keyType = undefined;
|
|
}
|
|
}
|
|
|
|
if (data.authType === "key") {
|
|
if (data.key instanceof File) {
|
|
submitData.key = await data.key.text();
|
|
} else if (data.key === "existing_key") {
|
|
delete submitData.key;
|
|
}
|
|
}
|
|
|
|
let savedHost;
|
|
if (editingHost && editingHost.id) {
|
|
savedHost = await updateSSHHost(editingHost.id, submitData as any);
|
|
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
|
|
} else {
|
|
savedHost = await createSSHHost(submitData as any);
|
|
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
|
}
|
|
|
|
if (savedHost && savedHost.id && data.tunnelConnections) {
|
|
const hasAutoStartTunnels = data.tunnelConnections.some(
|
|
(tunnel) => tunnel.autoStart,
|
|
);
|
|
|
|
if (hasAutoStartTunnels) {
|
|
try {
|
|
await enableAutoStart(savedHost.id);
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
|
|
error,
|
|
);
|
|
toast.warning(
|
|
t("hosts.autoStartEnableFailed", { name: data.name }),
|
|
);
|
|
}
|
|
} else {
|
|
try {
|
|
await disableAutoStart(savedHost.id);
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (onFormSubmit) {
|
|
onFormSubmit(savedHost);
|
|
}
|
|
|
|
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
|
|
|
if (savedHost?.id) {
|
|
notifyHostCreatedOrUpdated(savedHost.id);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
|
|
console.error("Failed to save host:", error);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const TAB_PRIORITY = [
|
|
"general",
|
|
"terminal",
|
|
"tunnel",
|
|
"file_manager",
|
|
"docker",
|
|
"statistics",
|
|
] as const;
|
|
|
|
const FIELD_TO_TAB_MAP: Record<string, string> = {
|
|
ip: "general",
|
|
port: "general",
|
|
username: "general",
|
|
name: "general",
|
|
folder: "general",
|
|
tags: "general",
|
|
pin: "general",
|
|
password: "general",
|
|
key: "general",
|
|
keyPassword: "general",
|
|
keyType: "general",
|
|
credentialId: "general",
|
|
overrideCredentialUsername: "general",
|
|
forceKeyboardInteractive: "general",
|
|
jumpHosts: "general",
|
|
authType: "general",
|
|
notes: "general",
|
|
useSocks5: "general",
|
|
socks5Host: "general",
|
|
socks5Port: "general",
|
|
socks5Username: "general",
|
|
socks5Password: "general",
|
|
socks5ProxyChain: "general",
|
|
quickActions: "general",
|
|
enableTerminal: "terminal",
|
|
terminalConfig: "terminal",
|
|
enableDocker: "docker",
|
|
domain: "general",
|
|
security: "general",
|
|
ignoreCert: "general",
|
|
guacamoleConfig: "remote_desktop",
|
|
connectionType: "general",
|
|
enableTunnel: "tunnel",
|
|
tunnelConnections: "tunnel",
|
|
enableFileManager: "file_manager",
|
|
defaultPath: "file_manager",
|
|
statsConfig: "statistics",
|
|
};
|
|
|
|
const handleFormError = async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const errors = form.formState.errors;
|
|
const errorFields = Object.keys(errors);
|
|
|
|
if (errorFields.length === 0) return;
|
|
|
|
const firstErrorField = errorFields[0];
|
|
const firstError = errors[firstErrorField as keyof typeof errors];
|
|
const errorMessage =
|
|
firstError && typeof firstError === "object" && "message" in firstError
|
|
? (firstError.message as string)
|
|
: t("hosts.failedToSaveHost");
|
|
toast.error(errorMessage);
|
|
|
|
for (const tab of TAB_PRIORITY) {
|
|
const hasErrorInTab = errorFields.some((field) => {
|
|
const baseField = field.split(".")[0].split("[")[0];
|
|
return FIELD_TO_TAB_MAP[baseField] === tab;
|
|
});
|
|
|
|
if (hasErrorInTab) {
|
|
setActiveTab(tab);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
const [tagInput, setTagInput] = useState("");
|
|
|
|
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
|
const folderDropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
const folderValue = form.watch("folder");
|
|
const filteredFolders = React.useMemo(() => {
|
|
if (!folderValue) return folders;
|
|
return folders.filter((f) =>
|
|
f.toLowerCase().includes(folderValue.toLowerCase()),
|
|
);
|
|
}, [folderValue, folders]);
|
|
|
|
const handleFolderClick = (folder: string) => {
|
|
form.setValue("folder", folder);
|
|
setFolderDropdownOpen(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (
|
|
folderDropdownRef.current &&
|
|
!folderDropdownRef.current.contains(event.target as Node) &&
|
|
folderInputRef.current &&
|
|
!folderInputRef.current.contains(event.target as Node)
|
|
) {
|
|
setFolderDropdownOpen(false);
|
|
}
|
|
}
|
|
|
|
if (folderDropdownOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
} else {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [folderDropdownOpen]);
|
|
|
|
const keyTypeOptions = [
|
|
{ value: "auto", label: t("hosts.autoDetect") },
|
|
{ value: "ssh-rsa", label: t("hosts.rsa") },
|
|
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
|
|
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
|
|
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
|
|
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
|
|
{ value: "ssh-dss", label: t("hosts.dsa") },
|
|
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
|
|
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
|
|
];
|
|
|
|
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
|
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
|
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
function onClickOutside(event: MouseEvent) {
|
|
if (
|
|
keyTypeDropdownOpen &&
|
|
keyTypeDropdownRef.current &&
|
|
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
|
keyTypeButtonRef.current &&
|
|
!keyTypeButtonRef.current.contains(event.target as Node)
|
|
) {
|
|
setKeyTypeDropdownOpen(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("mousedown", onClickOutside);
|
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
|
}, [keyTypeDropdownOpen]);
|
|
|
|
const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{
|
|
[key: number]: boolean;
|
|
}>({});
|
|
const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>(
|
|
{},
|
|
);
|
|
const sshConfigDropdownRefs = useRef<{
|
|
[key: number]: HTMLDivElement | null;
|
|
}>({});
|
|
|
|
const getFilteredSshConfigs = (index: number) => {
|
|
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
|
|
|
|
const currentHostId = editingHost?.id;
|
|
|
|
let filtered = sshConfigurations;
|
|
|
|
if (currentHostId) {
|
|
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
|
|
if (currentHostName) {
|
|
filtered = sshConfigurations.filter(
|
|
(config) => config !== currentHostName,
|
|
);
|
|
}
|
|
} else {
|
|
const currentHostName =
|
|
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
|
|
filtered = sshConfigurations.filter(
|
|
(config) => config !== currentHostName,
|
|
);
|
|
}
|
|
|
|
if (value) {
|
|
filtered = filtered.filter((config) =>
|
|
config.toLowerCase().includes(value.toLowerCase()),
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
};
|
|
|
|
const handleSshConfigClick = (config: string, index: number) => {
|
|
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
|
|
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
|
|
};
|
|
|
|
useEffect(() => {
|
|
function handleSshConfigClickOutside(event: MouseEvent) {
|
|
const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(
|
|
(key) => sshConfigDropdownOpen[parseInt(key)],
|
|
);
|
|
|
|
openDropdowns.forEach((indexStr: string) => {
|
|
const index = parseInt(indexStr);
|
|
if (
|
|
sshConfigDropdownRefs.current[index] &&
|
|
!sshConfigDropdownRefs.current[index]?.contains(
|
|
event.target as Node,
|
|
) &&
|
|
sshConfigInputRefs.current[index] &&
|
|
!sshConfigInputRefs.current[index]?.contains(event.target as Node)
|
|
) {
|
|
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
|
|
}
|
|
});
|
|
}
|
|
|
|
const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(
|
|
(open) => open,
|
|
);
|
|
|
|
if (hasOpenDropdowns) {
|
|
document.addEventListener("mousedown", handleSshConfigClickOutside);
|
|
} else {
|
|
document.removeEventListener("mousedown", handleSshConfigClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleSshConfigClickOutside);
|
|
};
|
|
}, [sshConfigDropdownOpen]);
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col h-full min-h-0 w-full relative">
|
|
<SimpleLoader
|
|
visible={isSubmitting}
|
|
message={
|
|
editingHost?.id
|
|
? t("hosts.updatingHost")
|
|
: editingHost
|
|
? t("hosts.cloningHost")
|
|
: t("hosts.savingHost")
|
|
}
|
|
backgroundColor="var(--bg-base)"
|
|
/>
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
|
|
className="flex flex-col flex-1 min-h-0 h-full"
|
|
>
|
|
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
|
<div className="pr-4">
|
|
{formError && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertDescription>{formError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="flex items-center gap-2 mb-3">
|
|
{onBack && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onBack}
|
|
className="flex-shrink-0"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
{t("common.back")}
|
|
</Button>
|
|
)}
|
|
<h3 className="text-lg font-semibold flex-shrink-0">
|
|
{editingHost
|
|
? editingHost.id
|
|
? t("hosts.editHost")
|
|
: t("hosts.cloneHost")
|
|
: t("hosts.addHost")}
|
|
</h3>
|
|
</div>
|
|
{guacEnabled && (
|
|
<FormField
|
|
control={form.control}
|
|
name="connectionType"
|
|
render={({ field }) => (
|
|
<FormItem className="mb-4">
|
|
<FormControl>
|
|
<Tabs
|
|
value={field.value || "ssh"}
|
|
onValueChange={field.onChange}
|
|
className="w-full"
|
|
>
|
|
<TabsList className="bg-button border border-edge-medium">
|
|
<TabsTrigger
|
|
value="ssh"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.ssh")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="rdp"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.rdp")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="vnc"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.vnc")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="telnet"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.telnet")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="w-full"
|
|
>
|
|
<TabsList className="bg-button border border-edge-medium">
|
|
<TabsTrigger
|
|
value="general"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.general")}
|
|
</TabsTrigger>
|
|
{watchedConnectionType === "ssh" && (
|
|
<>
|
|
<TabsTrigger
|
|
value="terminal"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.terminal")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="docker"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
Docker
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="tunnel"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.tunnel")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="file_manager"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.fileManager")}
|
|
</TabsTrigger>
|
|
</>
|
|
)}
|
|
<TabsTrigger
|
|
value="statistics"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{watchedConnectionType === "ssh"
|
|
? t("hosts.statistics")
|
|
: t("hosts.status")}
|
|
</TabsTrigger>
|
|
{watchedConnectionType !== "ssh" && (
|
|
<TabsTrigger
|
|
value="remote_desktop"
|
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
|
>
|
|
{t("hosts.remoteDesktop")}
|
|
</TabsTrigger>
|
|
)}
|
|
{watchedConnectionType === "ssh" &&
|
|
!editingHost?.isShared && (
|
|
<TabsTrigger value="sharing">
|
|
{t("rbac.sharing")}
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
<TabsContent value="general" className="pt-2">
|
|
<HostGeneralTab
|
|
form={form}
|
|
connectionType={
|
|
watchedConnectionType as "ssh" | "rdp" | "vnc" | "telnet"
|
|
}
|
|
authTab={authTab}
|
|
setAuthTab={setAuthTab}
|
|
keyInputMethod={keyInputMethod}
|
|
setKeyInputMethod={setKeyInputMethod}
|
|
proxyMode={proxyMode}
|
|
setProxyMode={setProxyMode}
|
|
tagInput={tagInput}
|
|
setTagInput={setTagInput}
|
|
folderDropdownOpen={folderDropdownOpen}
|
|
setFolderDropdownOpen={setFolderDropdownOpen}
|
|
folderInputRef={folderInputRef}
|
|
folderDropdownRef={folderDropdownRef}
|
|
filteredFolders={filteredFolders}
|
|
handleFolderClick={handleFolderClick}
|
|
keyTypeDropdownOpen={keyTypeDropdownOpen}
|
|
setKeyTypeDropdownOpen={setKeyTypeDropdownOpen}
|
|
keyTypeButtonRef={keyTypeButtonRef}
|
|
keyTypeDropdownRef={keyTypeDropdownRef}
|
|
keyTypeOptions={keyTypeOptions}
|
|
ipInputRef={ipInputRef}
|
|
editorTheme={editorTheme}
|
|
hosts={hosts}
|
|
editingHost={editingHost}
|
|
folders={folders}
|
|
credentials={credentials}
|
|
t={t}
|
|
/>
|
|
</TabsContent>
|
|
{watchedConnectionType === "ssh" && (
|
|
<>
|
|
<TabsContent value="terminal" className="space-y-1">
|
|
<HostTerminalTab form={form} snippets={snippets} t={t} />
|
|
</TabsContent>
|
|
<TabsContent value="docker" className="space-y-4">
|
|
<HostDockerTab form={form} t={t} />
|
|
</TabsContent>
|
|
<TabsContent value="tunnel">
|
|
<HostTunnelTab
|
|
form={form}
|
|
sshConfigDropdownOpen={sshConfigDropdownOpen}
|
|
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
|
|
sshConfigInputRefs={sshConfigInputRefs}
|
|
sshConfigDropdownRefs={sshConfigDropdownRefs}
|
|
getFilteredSshConfigs={getFilteredSshConfigs}
|
|
handleSshConfigClick={handleSshConfigClick}
|
|
t={t}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="file_manager">
|
|
<HostFileManagerTab form={form} t={t} />
|
|
</TabsContent>
|
|
</>
|
|
)}
|
|
<TabsContent value="statistics" className="space-y-6">
|
|
{watchedConnectionType === "ssh" ? (
|
|
<HostStatisticsTab
|
|
form={form}
|
|
statusIntervalUnit={statusIntervalUnit}
|
|
setStatusIntervalUnit={setStatusIntervalUnit}
|
|
metricsIntervalUnit={metricsIntervalUnit}
|
|
setMetricsIntervalUnit={setMetricsIntervalUnit}
|
|
snippets={snippets}
|
|
t={t}
|
|
/>
|
|
) : (
|
|
<HostStatusTab
|
|
form={form}
|
|
statusIntervalUnit={statusIntervalUnit}
|
|
setStatusIntervalUnit={setStatusIntervalUnit}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</TabsContent>
|
|
{watchedConnectionType !== "ssh" && (
|
|
<TabsContent value="remote_desktop" className="space-y-4">
|
|
<HostRemoteDesktopTab
|
|
form={form}
|
|
connectionType={
|
|
watchedConnectionType as "rdp" | "vnc" | "telnet"
|
|
}
|
|
t={t}
|
|
/>
|
|
</TabsContent>
|
|
)}
|
|
{watchedConnectionType === "ssh" && (
|
|
<TabsContent value="sharing" className="space-y-6">
|
|
<HostSharingTab
|
|
hostId={editingHost?.id}
|
|
isNewHost={!editingHost}
|
|
/>
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
</ScrollArea>
|
|
<footer className="shrink-0 w-full pb-0">
|
|
<Separator className="p-0.25" />
|
|
{!editingHost?.isShared && !isSubmitting && (
|
|
<Button
|
|
className="translate-y-2"
|
|
type="submit"
|
|
variant="outline"
|
|
disabled={!isFormValid}
|
|
>
|
|
{editingHost
|
|
? editingHost.id
|
|
? t("hosts.updateHost")
|
|
: t("hosts.cloneHost")
|
|
: t("hosts.addHost")}
|
|
</Button>
|
|
)}
|
|
</footer>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
);
|
|
}
|