diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 45455b81..c7c6f976 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -201,6 +201,18 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ssh/quick-connect { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ssh/ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 9e884581..792cc0f8 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -190,6 +190,18 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ssh/quick-connect { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ssh/ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 76566363..0db8e218 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -543,6 +543,195 @@ router.post( }, ); +/** + * @openapi + * /ssh/quick-connect: + * post: + * summary: Create a temporary SSH connection without saving to database + * description: Returns a temporary host configuration for immediate use + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - ip + * - port + * - username + * - authType + * properties: + * ip: + * type: string + * description: SSH server IP or hostname + * port: + * type: number + * description: SSH server port + * username: + * type: string + * description: SSH username + * authType: + * type: string + * enum: [password, key, credential] + * description: Authentication method + * password: + * type: string + * description: Password (required if authType is password) + * key: + * type: string + * description: SSH private key (required if authType is key) + * keyPassword: + * type: string + * description: SSH key password (optional) + * keyType: + * type: string + * description: SSH key type + * credentialId: + * type: number + * description: Credential ID (required if authType is credential) + * overrideCredentialUsername: + * type: boolean + * description: Use provided username instead of credential username + * responses: + * 200: + * description: Temporary host configuration created successfully + * 400: + * description: Invalid request data + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Credential not found + * 500: + * description: Server error + */ +router.post( + "/quick-connect", + authenticateJWT, + requireDataAccess, + async (req: AuthenticatedRequest, res: Response) => { + const userId = req.userId; + const { + ip, + port, + username, + authType, + password, + key, + keyPassword, + keyType, + credentialId, + overrideCredentialUsername, + } = req.body; + + if ( + !isNonEmptyString(ip) || + !isValidPort(port) || + !isNonEmptyString(username) || + !authType + ) { + return res.status(400).json({ error: "Missing required fields" }); + } + + try { + let resolvedPassword = password; + let resolvedKey = key; + let resolvedKeyPassword = keyPassword; + let resolvedKeyType = keyType; + let resolvedAuthType = authType; + let resolvedUsername = username; + + if (authType === "credential" && credentialId) { + const credentials = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (!credentials || credentials.length === 0) { + return res.status(404).json({ error: "Credential not found" }); + } + + const cred = credentials[0]; + + resolvedPassword = cred.password as string | undefined; + resolvedKey = (cred.private_key || cred.privateKey || cred.key) as + | string + | undefined; + resolvedKeyPassword = (cred.key_password || cred.keyPassword) as + | string + | undefined; + resolvedKeyType = (cred.key_type || cred.keyType) as string | undefined; + resolvedAuthType = (cred.auth_type || cred.authType) as + | string + | undefined; + + if (!overrideCredentialUsername) { + resolvedUsername = cred.username as string; + } + } + + const tempHost: Record = { + id: -Date.now(), + userId: userId, + name: `${resolvedUsername}@${ip}:${port}`, + ip, + port: Number(port), + username: resolvedUsername, + folder: "", + tags: [], + pin: false, + authType: resolvedAuthType || authType, + password: resolvedPassword, + key: resolvedKey, + keyPassword: resolvedKeyPassword, + keyType: resolvedKeyType, + enableTerminal: true, + enableTunnel: false, + enableFileManager: true, + enableDocker: false, + showTerminalInSidebar: true, + showFileManagerInSidebar: false, + showTunnelInSidebar: false, + showDockerInSidebar: false, + showServerStatsInSidebar: false, + defaultPath: "/", + tunnelConnections: [], + jumpHosts: [], + quickActions: [], + statsConfig: {}, + notes: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + return res.status(200).json(tempHost); + } catch (error) { + sshLogger.error("Quick connect failed", error, { + operation: "quick_connect", + userId, + ip, + port, + authType, + }); + return res + .status(500) + .json({ error: "Failed to create quick connection" }); + } + }, +); + /** * @openapi * /ssh/db/host/{id}: diff --git a/src/locales/en.json b/src/locales/en.json index 8882636b..20263698 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -186,6 +186,32 @@ "renameFolder": "Rename folder", "idLabel": "ID:" }, + "quickConnect": { + "title": "Quick Connect", + "description": "Connect directly to a terminal or file manager session without saving a host configuration", + "ipAddress": "IP Address or Hostname", + "port": "Port", + "username": "Username", + "password": "Password", + "key": "SSH Private Key", + "keyPassword": "Key Password (Optional)", + "keyType": "Key Type", + "uploadFile": "Upload File", + "pasteKey": "Paste Key", + "credential": "Credential", + "overrideUsername": "Override Credential Username", + "overrideUsernameDesc": "Use a different username than the one stored in the credential", + "connectTerminal": "Connect to Terminal", + "connectFileManager": "Connect to File Manager", + "cancel": "Cancel", + "passwordRequired": "Password is required", + "keyRequired": "SSH key is required", + "credentialRequired": "Credential selection is required", + "connectionEstablished": "Connection established successfully", + "connectionFailed": "Failed to establish connection", + "autoDetect": "Auto Detect", + "authentication": "Authentication" + }, "dragIndicator": { "error": "Error: {{error}}", "dragging": "Dragging {{fileName}}", diff --git a/src/ui/desktop/navigation/QuickConnectDialog.tsx b/src/ui/desktop/navigation/QuickConnectDialog.tsx new file mode 100644 index 00000000..4a2ca3f6 --- /dev/null +++ b/src/ui/desktop/navigation/QuickConnectDialog.tsx @@ -0,0 +1,664 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { PasswordInput } from "@/components/ui/password-input.tsx"; +import { Label } from "@/components/ui/label.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs.tsx"; +import { Switch } from "@/components/ui/switch.tsx"; +import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; +import { quickConnect, getCredentials } from "@/ui/main-axios.ts"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +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 { SSHHost, Credential } from "@/types"; + +interface QuickConnectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const keyTypeOptions = [ + { value: "auto", label: "Auto Detect" }, + { value: "ssh-rsa", label: "RSA" }, + { value: "ssh-ed25519", label: "Ed25519" }, + { value: "ecdsa-sha2-nistp256", label: "ECDSA NIST P-256" }, + { value: "ecdsa-sha2-nistp384", label: "ECDSA NIST P-384" }, + { value: "ecdsa-sha2-nistp521", label: "ECDSA NIST P-521" }, + { value: "ssh-dss", label: "DSA" }, + { value: "ssh-rsa-sha2-256", label: "RSA SHA2-256" }, + { value: "ssh-rsa-sha2-512", label: "RSA SHA2-512" }, +]; + +export function QuickConnectDialog({ + open, + onOpenChange, +}: QuickConnectDialogProps) { + const { t } = useTranslation(); + const { theme: appTheme } = useTheme(); + const { addTab, setCurrentTab } = useTabs(); + const [authTab, setAuthTab] = useState<"password" | "key" | "credential">( + "password", + ); + const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">( + "upload", + ); + const [credentials, setCredentials] = useState([]); + const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); + const keyTypeButtonRef = useRef(null); + const keyTypeDropdownRef = useRef(null); + const [isConnecting, setIsConnecting] = useState(false); + + const isDarkMode = + appTheme === "dark" || + (appTheme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + const editorTheme = isDarkMode ? oneDark : githubLight; + + const formSchema = z + .object({ + ip: z.string().min(1, t("quickConnect.ipAddress")), + port: z.coerce.number().min(1).max(65535).default(22), + username: z.string().min(1, t("quickConnect.username")), + authType: z.enum(["password", "key", "credential"]), + password: z.string().optional(), + key: z.any().optional(), + 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(), + credentialId: z.number().optional().nullable(), + overrideCredentialUsername: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + if (data.authType === "password") { + if ( + !data.password || + (typeof data.password === "string" && data.password.trim() === "") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("quickConnect.passwordRequired"), + path: ["password"], + }); + } + } else if (data.authType === "key") { + if ( + !data.key || + (typeof data.key === "string" && data.key.trim() === "") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("quickConnect.keyRequired"), + path: ["key"], + }); + } + if (!data.keyType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("hosts.keyTypeRequired"), + path: ["keyType"], + }); + } + } else if (data.authType === "credential") { + if (!data.credentialId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("quickConnect.credentialRequired"), + path: ["credentialId"], + }); + } + } + }); + + type FormData = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema) as any, + mode: "all", + defaultValues: { + ip: "", + port: 22, + username: "", + authType: "password" as const, + password: "", + key: null, + keyPassword: "", + keyType: "auto" as const, + credentialId: null, + overrideCredentialUsername: false, + }, + }); + + useEffect(() => { + const fetchCredentials = async () => { + try { + const data = await getCredentials(); + setCredentials((data as Credential[]) || []); + } catch (error) { + console.error("Failed to fetch credentials:", error); + } + }; + + if (open) { + fetchCredentials(); + } + }, [open]); + + useEffect(() => { + 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 }); + } + }, [authTab, form]); + + 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 handleConnect = async (connectionType: "terminal" | "file_manager") => { + const formData = form.getValues(); + + const isValid = await form.trigger(); + if (!isValid) { + return; + } + + setIsConnecting(true); + + try { + let keyContent: string | undefined; + if (formData.authType === "key" && formData.key) { + if (formData.key instanceof File) { + keyContent = await formData.key.text(); + } else if (typeof formData.key === "string") { + keyContent = formData.key; + } + } + + const hostConfig = await quickConnect({ + ip: formData.ip, + port: formData.port, + username: formData.username, + authType: formData.authType, + password: + formData.authType === "password" ? formData.password : undefined, + key: formData.authType === "key" ? keyContent : undefined, + keyPassword: + formData.authType === "key" ? formData.keyPassword : undefined, + keyType: formData.authType === "key" ? formData.keyType : undefined, + credentialId: + formData.authType === "credential" + ? formData.credentialId + : undefined, + overrideCredentialUsername: + formData.authType === "credential" + ? formData.overrideCredentialUsername + : undefined, + }); + + const tabId = addTab({ + type: connectionType, + title: `${formData.username}@${formData.ip}:${formData.port}`, + hostConfig: hostConfig as SSHHost, + }); + setCurrentTab(tabId); + + form.reset(); + setAuthTab("password"); + setKeyInputMethod("upload"); + onOpenChange(false); + } catch (error) { + console.error("Quick connect failed:", error); + toast.error( + t("quickConnect.connectionFailed") + + ": " + + (error instanceof Error ? error.message : String(error)), + ); + } finally { + setIsConnecting(false); + } + }; + + return ( + + + + {t("quickConnect.title")} + + {t("quickConnect.description")} + + + +
+
+
+ ( + + + {t("quickConnect.ipAddress")} + + + { + field.onChange(e.target.value.trim()); + field.onBlur(); + }} + /> + + + )} + /> + + ( + + + {t("quickConnect.port")} + + + + + + )} + /> +
+ + { + const isCredentialAuth = authTab === "credential"; + const hasCredential = !!form.watch("credentialId"); + const overrideEnabled = !!form.watch( + "overrideCredentialUsername", + ); + const shouldDisable = + isCredentialAuth && hasCredential && !overrideEnabled; + + return ( + + + {t("quickConnect.username")} + + + { + field.onChange(e.target.value.trim()); + field.onBlur(); + }} + /> + + + ); + }} + /> + +
+ + + setAuthTab(value as "password" | "key" | "credential") + } + className="w-full" + > + + + {t("hosts.password")} + + {t("hosts.key")} + + {t("quickConnect.credential")} + + + + + ( + + {t("quickConnect.password")} + + + + + )} + /> + + + + + setKeyInputMethod(value as "upload" | "paste") + } + className="w-full" + > + + + {t("quickConnect.uploadFile")} + + + {t("quickConnect.pasteKey")} + + + + + ( + + {t("quickConnect.key")} + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> +
+ + + ( + + {t("quickConnect.key")} + + field.onChange(value)} + placeholder={t("placeholders.pastePrivateKey")} + theme={editorTheme} + className="border border-input rounded-md overflow-hidden" + minHeight="120px" + basicSetup={{ + lineNumbers: true, + foldGutter: false, + dropCursor: false, + allowMultipleSelections: false, + highlightSelectionMatches: false, + }} + extensions={[ + EditorView.theme({ + ".cm-scroller": { + overflow: "auto", + scrollbarWidth: "thin", + scrollbarColor: + "var(--scrollbar-thumb) var(--scrollbar-track)", + }, + }), + ]} + /> + + + )} + /> + +
+ +
+ ( + + {t("quickConnect.keyPassword")} + + + + + )} + /> + + ( + + {t("quickConnect.keyType")} + +
+ + {keyTypeDropdownOpen && ( +
+
+ {keyTypeOptions.map((opt) => ( + + ))} +
+
+ )} +
+
+
+ )} + /> +
+
+ + +
+ ( + + { + if ( + credential && + !form.getValues("overrideCredentialUsername") + ) { + form.setValue("username", credential.username); + } + }} + /> + + )} + /> + + {form.watch("credentialId") && ( + ( + +
+ + {t("quickConnect.overrideUsername")} + +

+ {t("quickConnect.overrideUsernameDesc")} +

+
+ + + +
+ )} + /> + )} +
+
+
+
+
+ + + + + + +
+
+
+ ); +} diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 33929a15..316528ea 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -2,13 +2,14 @@ import React, { useState } from "react"; import { flushSync } from "react-dom"; import { useSidebar } from "@/components/ui/sidebar.tsx"; import { Button } from "@/components/ui/button.tsx"; -import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react"; +import { ChevronDown, ChevronUpIcon, Hammer, Zap } from "lucide-react"; import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTranslation } from "react-i18next"; import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx"; import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx"; import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx"; +import { QuickConnectDialog } from "@/ui/desktop/navigation/QuickConnectDialog.tsx"; interface TabData { id: number; @@ -61,6 +62,7 @@ export function TopNavbar({ const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false); const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false); const [splitScreenTabActive, setSplitScreenTabActive] = useState(false); + const [quickConnectOpen, setQuickConnectOpen] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(() => { const saved = localStorage.getItem("rightSidebarWidth"); const defaultWidth = 400; @@ -536,6 +538,15 @@ export function TopNavbar({ + +