diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index dd155da5..76566363 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -35,6 +35,7 @@ import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { DatabaseSaveTrigger } from "../db/index.js"; +import { parseSSHKey } from "../../utils/ssh-key-utils.js"; const router = express.Router(); @@ -396,6 +397,39 @@ router.post( sshDataObj.key_password = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { + if (key && typeof key === "string") { + if (!key.includes("-----BEGIN") || !key.includes("-----END")) { + sshLogger.warn("Invalid SSH key format provided", { + operation: "host_create", + userId, + name, + ip, + port, + }); + return res.status(400).json({ + error: "Invalid SSH key format. Key must be in PEM format.", + }); + } + + const keyValidation = parseSSHKey( + key, + typeof keyPassword === "string" ? keyPassword : undefined, + ); + if (!keyValidation.success) { + sshLogger.warn("SSH key validation failed", { + operation: "host_create", + userId, + name, + ip, + port, + error: keyValidation.error, + }); + return res.status(400).json({ + error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`, + }); + } + } + sshDataObj.key = key || null; sshDataObj.key_password = keyPassword || null; sshDataObj.keyType = keyType; @@ -685,7 +719,40 @@ router.put( sshDataObj.key_password = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { - if (key) { + if (key && typeof key === "string") { + if (!key.includes("-----BEGIN") || !key.includes("-----END")) { + sshLogger.warn("Invalid SSH key format provided", { + operation: "host_update", + hostId: parseInt(hostId), + userId, + name, + ip, + port, + }); + return res.status(400).json({ + error: "Invalid SSH key format. Key must be in PEM format.", + }); + } + + const keyValidation = parseSSHKey( + key, + typeof keyPassword === "string" ? keyPassword : undefined, + ); + if (!keyValidation.success) { + sshLogger.warn("SSH key validation failed", { + operation: "host_update", + hostId: parseInt(hostId), + userId, + name, + ip, + port, + error: keyValidation.error, + }); + return res.status(400).json({ + error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`, + }); + } + sshDataObj.key = key; } if (keyPassword !== undefined) { diff --git a/src/locales/en.json b/src/locales/en.json index bc9eb6cd..8882636b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -492,7 +492,12 @@ "hostManager": "Host Manager", "cannotSplitTab": "Cannot split this tab", "tabNavigation": "Tab Navigation", - "hostTabTitle": "{{username}}@{{ip}}:{{port}}" + "hostTabTitle": "{{username}}@{{ip}}:{{port}}", + "copyPassword": "Copy Password", + "copySudoPassword": "Copy Sudo Password", + "passwordCopied": "Password copied to clipboard", + "sudoPasswordCopied": "Sudo password copied to clipboard", + "noPasswordAvailable": "No password available" }, "admin": { "title": "Admin Settings", @@ -1381,6 +1386,7 @@ "enterSudoPassword": "Enter sudo password to continue this operation", "sudoPassword": "Sudo password", "sudoOperationFailed": "Sudo operation failed", + "sudoAuthFailed": "Sudo authentication failed", "deleteOperation": "Delete files/folders", "dragFilesToUpload": "Drop files here to upload", "emptyFolder": "This folder is empty", diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index 89c19b00..969d1aee 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -1164,7 +1164,7 @@ export const Terminal = forwardRef( fontSize: config.fontSize, fontFamily, allowTransparency: true, - convertEol: true, + convertEol: false, windowsMode: false, macOptionIsMeta: false, macOptionClickForcesSelection: false, @@ -1360,7 +1360,10 @@ export const Terminal = forwardRef( const ctrlCode = key.charCodeAt(0) - 96; if (webSocketRef.current?.readyState === 1) { webSocketRef.current.send( - JSON.stringify({ type: "input", data: String.fromCharCode(ctrlCode) }), + JSON.stringify({ + type: "input", + data: String.fromCharCode(ctrlCode), + }), ); } return false; diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx index f44fadb2..47b2e1be 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx @@ -113,10 +113,22 @@ export function HostManager({ if (activeTab === "add_host" && value !== "add_host") { setEditingHost(null); lastProcessedHostIdRef.current = undefined; + + // Clear hostConfig from tab data when leaving add_host tab + if (updateTab && currentTabId !== undefined) { + updateTab(currentTabId, { hostConfig: null }); + } } if (activeTab === "add_credential" && value !== "add_credential") { setEditingCredential(null); } + + // Clear editing state when switching TO add_host tab (to ensure fresh state) + if (value === "add_host" && activeTab !== "add_host") { + setEditingHost(null); + lastProcessedHostIdRef.current = undefined; + } + setActiveTab(value); if (updateTab && currentTabId !== undefined) { diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 3c5bb8f1..33929a15 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -517,6 +517,7 @@ export function TopNavbar({ disableClose={disableClose} isDragging={isDraggingThisTab} isDragOver={false} + hostConfig={tab.hostConfig} /> ); diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index 63f81e4d..06e5c5e8 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -13,7 +13,9 @@ import { Network, ArrowDownUp as TunnelIcon, Container as DockerIcon, + Key, } from "lucide-react"; +import type { SSHHost } from "@/types"; interface TabProps { tabType: string; @@ -32,6 +34,7 @@ interface TabProps { isDragOver?: boolean; isValidDropTarget?: boolean; isHoveredDropTarget?: boolean; + hostConfig?: SSHHost; } export function Tab({ @@ -51,9 +54,58 @@ export function Tab({ isDragOver = false, isValidDropTarget = false, isHoveredDropTarget = false, + hostConfig, }: TabProps): React.ReactElement { const { t } = useTranslation(); + const handleCopyPassword = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!hostConfig) return; + + const hasSshPassword = + hostConfig.authType === "password" && hostConfig.password; + const hasSudoPassword = hostConfig.sudoPassword; + + if (!hasSshPassword && !hasSudoPassword) { + return; + } + + try { + let passwordToCopy = ""; + + if (hasSshPassword) { + passwordToCopy = hostConfig.password || ""; + } else if (hasSudoPassword) { + passwordToCopy = hostConfig.sudoPassword; + } + + await navigator.clipboard.writeText(passwordToCopy); + } catch { + // Silent fail - clipboard copy errors are not critical + } + }; + + const hasPassword = + hostConfig && + ((hostConfig.authType === "password" && hostConfig.password) || + hostConfig.sudoPassword); + + const getPasswordButtonTitle = () => { + if (!hostConfig) return ""; + + const hasSshPassword = + hostConfig.authType === "password" && hostConfig.password; + const hasSudoPassword = hostConfig.sudoPassword; + + if (hasSshPassword) { + return t("nav.copyPassword"); + } else if (hasSudoPassword) { + return t("nav.copySudoPassword"); + } + return t("nav.noPasswordAvailable"); + }; + const tabBaseClasses = cn( "relative flex items-center gap-1.5 px-3 w-full min-w-0", "rounded-t-lg border-t-2 border-l-2 border-r-2", @@ -176,6 +228,18 @@ export function Tab({ {suffix && {suffix}} + {hasPassword && ( + + )} + {canSplit && (