diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 8ea5d66f..7b3b6138 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -180,6 +180,10 @@ async function initializeCompleteDatabase(): Promise { tunnel_connections TEXT, enable_file_manager INTEGER NOT NULL DEFAULT 1, default_path TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + force_keyboard_interactive TEXT, stats_config TEXT, terminal_config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -417,7 +421,10 @@ const migrateSchema = () => { "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", ); - + addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT"); + addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); + addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); + addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists( "ssh_data", "credential_id", diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 8a1f3c89..86af0d02 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -60,6 +60,7 @@ export const sshData = sqliteTable("ssh_data", { tags: text("tags"), pin: integer("pin", { mode: "boolean" }).notNull().default(false), authType: text("auth_type").notNull(), + forceKeyboardInteractive: text("force_keyboard_interactive"), password: text("password"), key: text("key", { length: 8192 }), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 091e892e..8e9cf570 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -236,6 +236,7 @@ router.post( tunnelConnections, statsConfig, terminalConfig, + forceKeyboardInteractive, } = hostData; if ( !isNonEmptyString(userId) || @@ -273,6 +274,7 @@ router.post( defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, + forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", }; if (effectiveAuthType === "password") { @@ -424,6 +426,7 @@ router.put( tunnelConnections, statsConfig, terminalConfig, + forceKeyboardInteractive, } = hostData; if ( !isNonEmptyString(userId) || @@ -462,6 +465,7 @@ router.put( defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, + forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", }; if (effectiveAuthType === "password") { @@ -611,6 +615,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { terminalConfig: row.terminalConfig ? JSON.parse(row.terminalConfig as string) : undefined, + forceKeyboardInteractive: row.forceKeyboardInteractive === "true", }; return (await resolveHostCredentials(baseHost)) || baseHost; @@ -681,6 +686,7 @@ router.get( terminalConfig: host.terminalConfig ? JSON.parse(host.terminalConfig) : undefined, + forceKeyboardInteractive: host.forceKeyboardInteractive === "true", }; res.json((await resolveHostCredentials(result)) || result); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 31a8403a..bf30c2de 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -173,6 +173,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { authType, credentialId, userProvidedPassword, + forceKeyboardInteractive, } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -257,39 +258,66 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const config: Record = { host: ip, - port: port || 22, + port, username, tryKeyboard: true, - readyTimeout: 60000, keepaliveInterval: 30000, keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + env: { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", + }, algorithms: { kex: [ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp521", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp256", + "diffie-hellman-group-exchange-sha256", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", - "diffie-hellman-group1-sha1", - "diffie-hellman-group-exchange-sha256", "diffie-hellman-group-exchange-sha1", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", + "diffie-hellman-group1-sha1", + ], + serverHostKey: [ + "ssh-ed25519", + "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp256", + "rsa-sha2-512", + "rsa-sha2-256", + "ssh-rsa", + "ssh-dss", ], cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm@openssh.com", + "chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", - "aes128-cbc", - "aes192-cbc", + "aes128-gcm@openssh.com", + "aes256-ctr", + "aes192-ctr", + "aes128-ctr", "aes256-cbc", + "aes192-cbc", + "aes128-cbc", "3des-cbc", ], hmac: [ - "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256", + "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512", + "hmac-sha2-256", "hmac-sha1", "hmac-md5", ], @@ -335,7 +363,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { .json({ error: "Password required for password authentication" }); } - if (userProvidedPassword) { + if (!forceKeyboardInteractive) { config.password = resolvedCredentials.password; } } else if (resolvedCredentials.authType === "none") { @@ -413,27 +441,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }); client.on("error", (err) => { - if ( - (err.message.includes("All configured authentication methods failed") || - err.message.includes("No authentication methods remaining")) && - resolvedCredentials.authType === "password" && - !config.password && - resolvedCredentials.password && - !userProvidedPassword - ) { - fileLogger.info( - "Retrying password auth with password method for file manager", - { - operation: "file_connect_retry", - sessionId, - hostId, - }, - ); - config.password = resolvedCredentials.password; - client.connect(config); - return; - } - if (responseSent) return; responseSent = true; fileLogger.error("SSH connection failed for file manager", { @@ -613,7 +620,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { return ""; }); - keyboardInteractiveResponded = true; finish(responses); } }, diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 152e941d..cc155e49 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -788,10 +788,26 @@ function addLegacyCredentials( function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { const base: ConnectConfig = { host: host.ip, - port: host.port || 22, - username: host.username || "root", + port: host.port, + username: host.username, tryKeyboard: true, - readyTimeout: 10_000, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + env: { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", + }, algorithms: { kex: [ "curve25519-sha256", diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index cf93f9f8..a040ee91 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -30,6 +30,7 @@ interface ConnectToHostData { authType?: string; credentialId?: number; userId?: string; + forceKeyboardInteractive?: boolean; }; initialPath?: string; executeCommand?: string; @@ -149,6 +150,7 @@ wss.on("connection", async (ws: WebSocket, req) => { let pingInterval: NodeJS.Timeout | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; + let isKeyboardInteractive = false; let keyboardInteractiveResponded = false; ws.on("close", () => { @@ -362,10 +364,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } }); - async function handleConnectToHost( - data: ConnectToHostData, - retryWithPassword = false, - ) { + async function handleConnectToHost(data: ConnectToHostData) { const { hostConfig, initialPath, executeCommand } = data; const { id, @@ -661,22 +660,6 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("error", (err: Error) => { clearTimeout(connectionTimeout); - if ( - (err.message.includes("All configured authentication methods failed") || - err.message.includes("No authentication methods remaining")) && - resolvedCredentials.authType === "password" && - !retryWithPassword && - !(hostConfig as any).userProvidedPassword - ) { - sshLogger.info("Retrying password auth with password method", { - operation: "ssh_connect_retry", - hostId: id, - }); - cleanupSSH(); - handleConnectToHost(data, true); - return; - } - if ( (authMethodNotAvailable && resolvedCredentials.authType === "none") || (resolvedCredentials.authType === "none" && @@ -756,6 +739,7 @@ wss.on("connection", async (ws: WebSocket, req) => { prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { + isKeyboardInteractive = true; const promptTexts = prompts.map((p) => p.prompt); const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( @@ -840,7 +824,6 @@ wss.on("connection", async (ws: WebSocket, req) => { return ""; }); - keyboardInteractiveResponded = true; finish(responses); } }, @@ -931,7 +914,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - if ((hostConfig as any).userProvidedPassword || retryWithPassword) { + if (!hostConfig.forceKeyboardInteractive) { connectConfig.password = resolvedCredentials.password; } } else if ( @@ -1033,6 +1016,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } totpPromptSent = false; + isKeyboardInteractive = false; keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; } diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 3dff10eb..d46a11d4 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -895,11 +895,24 @@ async function connectSSHTunnel( host: tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, + tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 15000, + tcpKeepAliveInitialDelay: 30000, + env: { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", + }, algorithms: { kex: [ "curve25519-sha256", diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 35b10e00..c407191b 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -701,7 +701,9 @@ "terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.", "noneAuthTitle": "Keyboard-Interactive-Authentifizierung", "noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.", - "noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern." + "noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.", + "forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen", + "forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden." }, "terminal": { "title": "Terminal", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9c2085e1..e160f094 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -780,7 +780,9 @@ "terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.", "noneAuthTitle": "Keyboard-Interactive Authentication", "noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.", - "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally." + "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.", + "forceKeyboardInteractive": "Force Keyboard-Interactive", + "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)." }, "terminal": { "title": "Terminal", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 9d69f483..c656e9cb 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -718,7 +718,9 @@ "terminalCustomizationNotice": "Nota: As personalizações do terminal funcionam apenas na versão Desktop Website. Aplicativos Mobile e Electron usam as configurações padrão do terminal do sistema.", "noneAuthTitle": "Autenticação Interativa por Teclado", "noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.", - "noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica." + "noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica.", + "forceKeyboardInteractive": "Forçar Interativo com Teclado", + "forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA)." }, "terminal": { "title": "Terminal", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 81541787..e9c7c14e 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -792,7 +792,9 @@ "terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。", "noneAuthTitle": "键盘交互式认证", "noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。", - "noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。" + "noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。", + "forceKeyboardInteractive": "强制键盘交互式认证", + "forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。" }, "terminal": { "title": "终端", diff --git a/src/types/index.ts b/src/types/index.ts index 868ec4a8..027de232 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,7 @@ export interface SSHHost { key?: string; keyPassword?: string; keyType?: string; + forceKeyboardInteractive?: boolean; autostartPassword?: string; autostartKey?: string; @@ -55,6 +56,7 @@ export interface SSHHostData { enableTunnel?: boolean; enableFileManager?: boolean; defaultPath?: string; + forceKeyboardInteractive?: boolean; tunnelConnections?: TunnelConnection[]; statsConfig?: string | Record; terminalConfig?: TerminalConfig; diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index f1d0c0ec..2070153b 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -298,8 +298,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { [systemDrag, clearSelection], ); + const isConnectingRef = useRef(false); + async function initializeSSHConnection() { - if (!currentHost) return; + if (!currentHost || isConnectingRef.current) return; + + isConnectingRef.current = true; try { setIsLoading(true); @@ -318,6 +322,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { authType: currentHost.authType, credentialId: currentHost.credentialId, userId: currentHost.userId, + forceKeyboardInteractive: currentHost.forceKeyboardInteractive, }); if (result?.requires_totp) { @@ -359,6 +364,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); } finally { setIsLoading(false); + isConnectingRef.current = false; } } diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index 0a3a6a92..e32ccf9b 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -311,6 +311,7 @@ export function HostManagerEditor({ moshCommand: z.string(), }) .optional(), + forceKeyboardInteractive: z.boolean().optional(), }) .superRefine((data, ctx) => { if (data.authType === "none") { @@ -399,6 +400,7 @@ export function HostManagerEditor({ tunnelConnections: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, + forceKeyboardInteractive: false, }, }); @@ -473,6 +475,7 @@ export function HostManagerEditor({ tunnelConnections: cleanedHost.tunnelConnections || [], statsConfig: parsedStatsConfig, terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG, + forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), }; if (defaultAuthType === "password") { @@ -520,6 +523,7 @@ export function HostManagerEditor({ tunnelConnections: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, + forceKeyboardInteractive: false, }; form.reset(defaultFormData); @@ -577,6 +581,7 @@ export function HostManagerEditor({ tunnelConnections: data.tunnelConnections || [], statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, + forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), }; submitData.credentialId = null; @@ -1296,6 +1301,28 @@ export function HostManagerEditor({ + ( + +
+ + {t("hosts.forceKeyboardInteractive")} + + + {t("hosts.forceKeyboardInteractiveDesc")} + +
+ + + +
+ )} + /> ( localStorage.removeItem("jwt"); - toast.error("Authentication failed. Please log in again."); + setTimeout(() => { + window.location.reload(); + }, 1000); return; } diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 2afa54a8..8bf22fbe 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -1,5 +1,12 @@ import React, { useState } from "react"; -import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react"; +import { + ChevronUp, + User2, + HardDrive, + Menu, + ChevronRight, + RotateCcw, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { isElectron, logoutUser } from "@/ui/main-axios.ts"; @@ -241,10 +248,9 @@ export function LeftSidebar({ localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen)); }, [isSidebarOpen]); - // Sidebar width state for resizing const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("leftSidebarWidth"); - return saved !== null ? parseInt(saved, 10) : 320; + return saved !== null ? parseInt(saved, 10) : 250; }); const [isResizing, setIsResizing] = useState(false); @@ -350,151 +356,171 @@ export function LeftSidebar({ return (
-
- - - - Termix - - - - - - - - - - -
- setSearch(e.target.value)} - placeholder={t("placeholders.searchHostsAny")} - className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md" - autoComplete="off" - /> -
- - {hostsError && ( -
-
- {t("leftSidebar.failedToLoadHosts")} -
-
- )} - - {hostsLoading && ( -
-
- {t("hosts.loadingHosts")} -
-
- )} - - {sortedFolders.map((folder, idx) => ( - - ))} -
-
- - - - - - - - {username ? username : t("common.logout")} - - - - + + + Termix +
+ + +
+
+
+ + + + + + + +
+ setSearch(e.target.value)} + placeholder={t("placeholders.searchHostsAny")} + className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md" + autoComplete="off" + /> +
+ + {hostsError && ( +
+
+ {t("leftSidebar.failedToLoadHosts")} +
+
+ )} + + {hostsLoading && ( +
+
+ {t("hosts.loadingHosts")} +
+
+ )} + + {sortedFolders.map((folder, idx) => ( + + ))} +
+
+ + + + + + + + {username ? username : t("common.logout")} + + + + - {t("profile.title")} - - {isAdmin && ( { - if (isAdmin) openAdminTab(); + openUserProfileTab(); }} > - {t("admin.title")} + {t("profile.title")} - )} - - {t("common.logout")} - - - - - - -
- - {/* Resizable divider */} - {isSidebarOpen && ( -
-
{ + if (isAdmin) openAdminTab(); + }} + > + {t("admin.title")} + + )} + + {t("common.logout")} + + + + + + + {isSidebarOpen && ( +
{ + if (!isResizing) { + e.currentTarget.style.backgroundColor = + "var(--dark-border-hover)"; + } + }} + onMouseLeave={(e) => { + if (!isResizing) { + e.currentTarget.style.backgroundColor = "transparent"; + } + }} + title="Drag to resize sidebar" /> -
- )} + )} + {children}
diff --git a/src/ui/desktop/navigation/SSHAuthDialog.tsx b/src/ui/desktop/navigation/SSHAuthDialog.tsx index cadd2c94..fb254288 100644 --- a/src/ui/desktop/navigation/SSHAuthDialog.tsx +++ b/src/ui/desktop/navigation/SSHAuthDialog.tsx @@ -42,7 +42,7 @@ export function SSHAuthDialog({ onSubmit, onCancel, hostInfo, - backgroundColor = "#1e1e1e", + backgroundColor = "#18181b", }: SSHAuthDialogProps) { const { t } = useTranslation(); const [authTab, setAuthTab] = useState<"password" | "key">("password"); @@ -136,7 +136,10 @@ export function SSHAuthDialog({ : `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`; return ( -
+
diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index b5fa7700..7f377bd3 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -50,8 +50,8 @@ export function TopNavbar({ allSplitScreenTab: number[]; reorderTabs: (fromIndex: number, toIndex: number) => void; }; - // Use CSS variable for dynamic sidebar width + divider width (4px) + some padding - const leftPosition = state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 20px)"; + const leftPosition = + state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)"; const { t } = useTranslation(); const [toolsSheetOpen, setToolsSheetOpen] = useState(false); @@ -301,7 +301,7 @@ export function TopNavbar({ top: isTopbarOpen ? "0.5rem" : "-3rem", left: leftPosition, right: "17px", - backgroundColor: "#1e1e21", + backgroundColor: "#18181b", }} >
setIsTopbarOpen(true)} - className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md" - style={{ backgroundColor: "#1e1e21" }} + className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md bg-dark" >
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index d367b886..2fd2b184 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -316,7 +316,13 @@ function createApiInstance( if (status === 401) { const errorCode = (error.response?.data as Record) ?.code; + const errorMessage = (error.response?.data as Record) + ?.error; const isSessionExpired = errorCode === "SESSION_EXPIRED"; + const isInvalidToken = + errorCode === "AUTH_REQUIRED" || + errorMessage === "Invalid token" || + errorMessage === "Authentication required"; if (isElectron()) { localStorage.removeItem("jwt"); @@ -324,17 +330,22 @@ function createApiInstance( localStorage.removeItem("jwt"); } - if (isSessionExpired && typeof window !== "undefined") { - console.warn("Session expired - please log in again"); + if ( + (isSessionExpired || isInvalidToken) && + typeof window !== "undefined" + ) { + console.warn( + "Session expired or invalid token - please log in again", + ); document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; import("sonner").then(({ toast }) => { - toast.warning("Session expired - please log in again"); + toast.warning("Session expired. Please log in again."); }); - setTimeout(() => window.location.reload(), 100); + setTimeout(() => window.location.reload(), 1000); } } @@ -792,6 +803,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { : JSON.stringify(hostData.statsConfig) : null, terminalConfig: hostData.terminalConfig || null, + forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; if (!submitData.enableTunnel) { @@ -854,6 +866,7 @@ export async function updateSSHHost( : JSON.stringify(hostData.statsConfig) : null, terminalConfig: hostData.terminalConfig || null, + forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; if (!submitData.enableTunnel) { @@ -1164,6 +1177,7 @@ export async function connectSSH( authType?: string; credentialId?: number; userId?: string; + forceKeyboardInteractive?: boolean; }, ): Promise> { try {