diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 13003bf8..959634f3 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -417,6 +417,7 @@ router.get("/oidc-config", async (req, res) => { // Check if user is authenticated admin let isAuthenticatedAdmin = false; + let userId: string | null = null; const authHeader = req.headers["authorization"]; if (authHeader?.startsWith("Bearer ")) { const token = authHeader.split(" ")[1]; @@ -424,45 +425,11 @@ router.get("/oidc-config", async (req, res) => { const payload = await authManager.verifyJWTToken(token); if (payload) { - const userId = payload.userId; + userId = payload.userId; const user = await db.select().from(users).where(eq(users.id, userId)); if (user && user.length > 0 && user[0].is_admin) { isAuthenticatedAdmin = true; - - // Only decrypt for authenticated admins - if (config.client_secret?.startsWith("encrypted:")) { - try { - const adminDataKey = DataCrypto.getUserDataKey(userId); - if (adminDataKey) { - config = DataCrypto.decryptRecord( - "settings", - config, - userId, - adminDataKey, - ); - } else { - config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]"; - } - } catch { - authLogger.warn("Failed to decrypt OIDC config for admin", { - operation: "oidc_config_decrypt_failed", - userId, - }); - config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]"; - } - } else if (config.client_secret?.startsWith("encoded:")) { - // Decode for authenticated admins only - try { - const decoded = Buffer.from( - config.client_secret.substring(8), - "base64", - ).toString("utf8"); - config.client_secret = decoded; - } catch { - config.client_secret = "[ENCODING ERROR]"; - } - } } } } @@ -484,6 +451,46 @@ router.get("/oidc-config", async (req, res) => { return res.json(publicConfig); } + // For authenticated admins, decrypt sensitive fields + if (config.client_secret?.startsWith("encrypted:")) { + try { + const adminDataKey = DataCrypto.getUserDataKey(userId); + if (adminDataKey) { + config = DataCrypto.decryptRecord( + "settings", + config, + userId, + adminDataKey, + ); + } else { + // Admin is authenticated but data key is not available + // This can happen if they haven't unlocked their data yet + config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]"; + } + } catch (decryptError) { + authLogger.warn("Failed to decrypt OIDC config for admin", { + operation: "oidc_config_decrypt_failed", + userId, + }); + config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]"; + } + } else if (config.client_secret?.startsWith("encoded:")) { + // Decode for authenticated admins + try { + const decoded = Buffer.from( + config.client_secret.substring(8), + "base64", + ).toString("utf8"); + config.client_secret = decoded; + } catch (decodeError) { + authLogger.warn("Failed to decode OIDC config for admin", { + operation: "oidc_config_decode_failed", + userId, + }); + config.client_secret = "[ENCODING ERROR]"; + } + } + res.json(config); } catch (err) { authLogger.error("Failed to get OIDC config", err); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 64f9faa8..2d00ce15 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -350,7 +350,40 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } config.password = resolvedCredentials.password; } else if (resolvedCredentials.authType === "none") { - // Don't set password in config - rely on keyboard-interactive + // Use authHandler to control authentication flow + // This ensures we only try keyboard-interactive, not password auth + config.authHandler = ( + methodsLeft: string[], + partialSuccess: boolean, + callback: (nextMethod: string | false) => void, + ) => { + fileLogger.info("Auth handler called", { + operation: "ssh_auth_handler", + hostId, + sessionId, + methodsLeft, + partialSuccess, + }); + + // Only try keyboard-interactive + if (methodsLeft.includes("keyboard-interactive")) { + callback("keyboard-interactive"); + } else { + fileLogger.error("Server does not support keyboard-interactive auth", { + operation: "ssh_auth_handler_no_keyboard", + hostId, + sessionId, + methodsLeft, + }); + callback(false); // No more methods to try + } + }; + + fileLogger.info("Using keyboard-interactive auth (authType: none)", { + operation: "ssh_auth_config", + hostId, + sessionId, + }); } else { fileLogger.warn( "No valid authentication method provided for file manager", @@ -531,30 +564,38 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { // If no stored password (including authType "none"), prompt the user if (!hasStoredPassword && passwordPromptIndex !== -1) { if (responseSent) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); + // Connection is already being handled, don't send duplicate responses + fileLogger.info( + "Skipping duplicate password prompt - response already sent", + { + operation: "keyboard_interactive_skip", + hostId, + sessionId, + }, + ); return; } responseSent = true; if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; + // Session already waiting for TOTP, don't override + fileLogger.info("Skipping password prompt - TOTP session pending", { + operation: "keyboard_interactive_skip", + hostId, + sessionId, }); - finish(responses); return; } keyboardInteractiveResponded = true; + fileLogger.info("Requesting password from user (authType: none)", { + operation: "keyboard_interactive_password", + hostId, + sessionId, + prompt: prompts[passwordPromptIndex].prompt, + }); + pendingTOTPSessions[sessionId] = { client, finish, diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 96b6cb8b..7728f6a5 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -518,9 +518,6 @@ class PollingManager { }; this.statusStore.set(host.id, statusEntry); } catch (error) { - statsLogger.warn( - `Failed to poll status for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - ); const statusEntry: StatusEntry = { status: "offline", lastChecked: new Date().toISOString(), @@ -1088,10 +1085,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ const coresNum = Number((coresOut.stdout || "").trim()); cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; } catch (e) { - statsLogger.warn( - `Failed to collect CPU metrics for host ${host.id}`, - e, - ); cpuPercent = null; cores = null; loadTriplet = null; @@ -1118,10 +1111,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ totalGiB = kibToGiB(totalKb); } } catch (e) { - statsLogger.warn( - `Failed to collect memory metrics for host ${host.id}`, - e, - ); memPercent = null; usedGiB = null; totalGiB = null; @@ -1171,10 +1160,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ } } } catch (e) { - statsLogger.warn( - `Failed to collect disk metrics for host ${host.id}`, - e, - ); diskPercent = null; usedHuman = null; totalHuman = null; @@ -1238,12 +1223,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ txBytes: null, }); } - } catch (e) { - statsLogger.warn( - `Failed to collect network metrics for host ${host.id}`, - e, - ); - } + } catch (e) {} // Collect uptime let uptimeSeconds: number | null = null; @@ -1260,9 +1240,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ uptimeFormatted = `${days}d ${hours}h ${minutes}m`; } } - } catch (e) { - statsLogger.warn(`Failed to collect uptime for host ${host.id}`, e); - } + } catch (e) {} // Collect process information let totalProcesses: number | null = null; @@ -1305,12 +1283,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ ); totalProcesses = Number(procCount.stdout.trim()) - 1; runningProcesses = Number(runningCount.stdout.trim()); - } catch (e) { - statsLogger.warn( - `Failed to collect process info for host ${host.id}`, - e, - ); - } + } catch (e) {} // Collect system information let hostname: string | null = null; @@ -1327,12 +1300,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ hostname = hostnameOut.stdout.trim() || null; kernel = kernelOut.stdout.trim() || null; os = osOut.stdout.trim() || null; - } catch (e) { - statsLogger.warn( - `Failed to collect system info for host ${host.id}`, - e, - ); - } + } catch (e) {} const result = { cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 145c484e..5c79a728 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -793,11 +793,26 @@ wss.on("connection", async (ws: WebSocket, req) => { // If no stored password (including authType "none"), prompt the user if (!hasStoredPassword && passwordPromptIndex !== -1) { - if (keyboardInteractiveResponded) { + // Don't block duplicate password prompts - some servers (like Warpgate) may ask multiple times + if (keyboardInteractiveResponded && totpPromptSent) { + // Only block if we already sent a TOTP prompt + sshLogger.info( + "Skipping duplicate password prompt after TOTP sent", + { + operation: "keyboard_interactive_skip", + hostId: id, + }, + ); return; } keyboardInteractiveResponded = true; + sshLogger.info("Requesting password from user (authType: none)", { + operation: "keyboard_interactive_password", + hostId: id, + prompt: prompts[passwordPromptIndex].prompt, + }); + keyboardInteractiveFinish = (userResponses: string[]) => { const userInput = (userResponses[0] || "").trim(); @@ -916,7 +931,37 @@ wss.on("connection", async (ws: WebSocket, req) => { }; if (resolvedCredentials.authType === "none") { - // Don't set password in config - rely on keyboard-interactive + // Use authHandler to control authentication flow + // This ensures we only try keyboard-interactive, not password auth + connectConfig.authHandler = ( + methodsLeft: string[], + partialSuccess: boolean, + callback: (nextMethod: string | false) => void, + ) => { + sshLogger.info("Auth handler called", { + operation: "ssh_auth_handler", + hostId: id, + methodsLeft, + partialSuccess, + }); + + // Only try keyboard-interactive + if (methodsLeft.includes("keyboard-interactive")) { + callback("keyboard-interactive"); + } else { + sshLogger.error("Server does not support keyboard-interactive auth", { + operation: "ssh_auth_handler_no_keyboard", + hostId: id, + methodsLeft, + }); + callback(false); // No more methods to try + } + }; + + sshLogger.info("Using keyboard-interactive auth (authType: none)", { + operation: "ssh_auth_config", + hostId: id, + }); } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password) { sshLogger.error( diff --git a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx index d47fa7d4..3aa29e99 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx @@ -31,25 +31,55 @@ export function HostManager({ username: string; } | null>(null); const { state: sidebarState } = useSidebar(); - const prevHostConfigRef = useRef(hostConfig); + const ignoreNextHostConfigChangeRef = useRef(false); + const lastProcessedHostIdRef = useRef(undefined); - // Update editing host when hostConfig prop changes + // Update editing host when hostConfig prop changes (from sidebar edit button) useEffect(() => { - if (hostConfig && hostConfig !== prevHostConfigRef.current) { - setEditingHost(hostConfig); - setActiveTab(initialTab || "add_host"); - prevHostConfigRef.current = hostConfig; + // Skip if we should ignore this change + if (ignoreNextHostConfigChangeRef.current) { + ignoreNextHostConfigChangeRef.current = false; + return; + } + + // Only process if this is an external edit request (from sidebar) + if (hostConfig && initialTab === "add_host") { + const currentHostId = hostConfig.id; + + // Open editor if it's a different host OR same host but user is on viewer/credentials tabs + if (currentHostId !== lastProcessedHostIdRef.current) { + // Different host - always open + setEditingHost(hostConfig); + setActiveTab("add_host"); + lastProcessedHostIdRef.current = currentHostId; + } else if ( + activeTab === "host_viewer" || + activeTab === "credentials" || + activeTab === "add_credential" + ) { + // Same host but user manually navigated away - reopen + setEditingHost(hostConfig); + setActiveTab("add_host"); + } + // If same host and already on add_host tab, do nothing (don't block tab changes) } }, [hostConfig, initialTab]); const handleEditHost = (host: SSHHost) => { setEditingHost(host); setActiveTab("add_host"); + lastProcessedHostIdRef.current = host.id; }; const handleFormSubmit = () => { + // Ignore the next hostConfig change (which will come from ssh-hosts:changed event) + ignoreNextHostConfigChangeRef.current = true; setEditingHost(null); setActiveTab("host_viewer"); + // Clear after a delay so the same host can be edited again + setTimeout(() => { + lastProcessedHostIdRef.current = undefined; + }, 500); }; const handleEditCredential = (credential: { @@ -70,6 +100,7 @@ export function HostManager({ setActiveTab(value); if (value !== "add_host") { setEditingHost(null); + isEditingRef.current = false; } if (value !== "add_credential") { setEditingCredential(null); diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx index 426066da..a6d9186f 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx @@ -666,8 +666,6 @@ export function HostManagerEditor({ // Refresh backend polling to pick up new/updated host configuration const { refreshServerPolling } = await import("@/ui/main-axios.ts"); refreshServerPolling(); - - form.reset(); } catch { toast.error(t("hosts.failedToSaveHost")); } finally { diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 71183a32..cabce47b 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -452,7 +452,7 @@ export const Terminal = forwardRef( ) { ws.addEventListener("open", () => { connectionTimeoutRef.current = setTimeout(() => { - if (!isConnected && !totpRequired) { + if (!isConnected && !totpRequired && !isPasswordPrompt) { if (terminal) { terminal.clear(); } diff --git a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx index ced3f3fb..02010178 100644 --- a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx @@ -108,6 +108,8 @@ export function TabProvider({ children }: TabProviderProps) { t.id === existingTab.id ? { ...t, + // Keep the original title (Host Manager) + title: existingTab.title, hostConfig: tabData.hostConfig ? { ...tabData.hostConfig } : undefined, @@ -220,6 +222,15 @@ export function TabProvider({ children }: TabProviderProps) { setTabs((prev) => prev.map((tab) => { if (tab.hostConfig && tab.hostConfig.id === hostId) { + // Don't update the title for ssh_manager tabs - they should stay as "Host Manager" + if (tab.type === "ssh_manager") { + return { + ...tab, + hostConfig: newHostConfig, + }; + } + + // For other tabs (terminal, server, file_manager), update both config and title return { ...tab, hostConfig: newHostConfig, diff --git a/src/ui/Desktop/Navigation/TopNavbar.tsx b/src/ui/Desktop/Navigation/TopNavbar.tsx index 50bfc0f1..ea252709 100644 --- a/src/ui/Desktop/Navigation/TopNavbar.tsx +++ b/src/ui/Desktop/Navigation/TopNavbar.tsx @@ -350,7 +350,9 @@ export function TopNavbar({ tab.type === "admin" || tab.type === "user_profile") && isSplitScreenActive); - const disableClose = (isSplitScreenActive && isActive) || isSplit; + const isHome = tab.type === "home"; + const disableClose = + (isSplitScreenActive && isActive) || isSplit || isHome; const isDraggingThisTab = dragState.draggedIndex === index; const isTheDraggedTab = tab.id === dragState.draggedId; @@ -420,6 +422,14 @@ export function TopNavbar({ onDragOver={handleDragOver} onDrop={handleDrop} onDragEnd={handleDragEnd} + e + onMouseDown={(e) => { + // Middle mouse button (button === 1) + if (e.button === 1 && !disableClose) { + e.preventDefault(); + handleTabClose(tab.id); + } + }} style={{ transform, transition: