diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 8310ed6c..09d37809 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -456,6 +456,11 @@ const migrateSchema = () => { "credential_id", "INTEGER REFERENCES ssh_credentials(id)", ); + addColumnIfNotExists( + "ssh_data", + "override_credential_username", + "INTEGER", + ); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 86af0d02..45528c01 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -72,6 +72,9 @@ export const sshData = sqliteTable("ssh_data", { autostartKeyPassword: text("autostart_key_password"), credentialId: integer("credential_id").references(() => sshCredentials.id), + overrideCredentialUsername: integer("override_credential_username", { + mode: "boolean", + }), enableTerminal: integer("enable_terminal", { mode: "boolean" }) .notNull() .default(true), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8e9cf570..b0e5067e 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -8,6 +8,7 @@ import { fileManagerRecent, fileManagerPinned, fileManagerShortcuts, + recentActivity, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -225,6 +226,7 @@ router.post( authMethod, authType, credentialId, + overrideCredentialUsername, key, keyPassword, keyType, @@ -264,6 +266,7 @@ router.post( username, authType: effectiveAuthType, credentialId: credentialId || null, + overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, @@ -323,6 +326,7 @@ router.post( : [] : [], pin: !!createdHost.pin, + overrideCredentialUsername: !!createdHost.overrideCredentialUsername, enableTerminal: !!createdHost.enableTerminal, enableTunnel: !!createdHost.enableTunnel, tunnelConnections: createdHost.tunnelConnections @@ -349,6 +353,27 @@ router.post( }, ); + try { + const fetch = (await import("node-fetch")).default; + const token = + req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); + await fetch("http://localhost:30005/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Cookie: `jwt=${token}` }), + }, + }); + } catch (refreshError) { + sshLogger.warn("Failed to refresh server stats polling", { + operation: "stats_refresh_after_create", + error: + refreshError instanceof Error + ? refreshError.message + : "Unknown error", + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -415,6 +440,7 @@ router.put( authMethod, authType, credentialId, + overrideCredentialUsername, key, keyPassword, keyType, @@ -455,6 +481,7 @@ router.put( username, authType: effectiveAuthType, credentialId: credentialId || null, + overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, @@ -532,6 +559,7 @@ router.put( : [] : [], pin: !!updatedHost.pin, + overrideCredentialUsername: !!updatedHost.overrideCredentialUsername, enableTerminal: !!updatedHost.enableTerminal, enableTunnel: !!updatedHost.enableTunnel, tunnelConnections: updatedHost.tunnelConnections @@ -558,6 +586,27 @@ router.put( }, ); + try { + const fetch = (await import("node-fetch")).default; + const token = + req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); + await fetch("http://localhost:30005/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Cookie: `jwt=${token}` }), + }, + }); + } catch (refreshError) { + sshLogger.warn("Failed to refresh server stats polling", { + operation: "stats_refresh_after_update", + error: + refreshError instanceof Error + ? refreshError.message + : "Unknown error", + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { @@ -585,6 +634,18 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { }); return res.status(400).json({ error: "Invalid userId" }); } + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + sshLogger.warn("User data not unlocked for SSH host fetch", { + operation: "host_fetch", + userId, + }); + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + try { const data = await SimpleDBOps.select( db.select().from(sshData).where(eq(sshData.userId, userId)), @@ -603,6 +664,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { : [] : [], pin: !!row.pin, + overrideCredentialUsername: !!row.overrideCredentialUsername, enableTerminal: !!row.enableTerminal, enableTunnel: !!row.enableTunnel, tunnelConnections: row.tunnelConnections @@ -649,6 +711,19 @@ router.get( }); return res.status(400).json({ error: "Invalid userId or hostId" }); } + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + sshLogger.warn("User data not unlocked for SSH host fetch by ID", { + operation: "host_fetch_by_id", + hostId: parseInt(hostId), + userId, + }); + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + try { const data = await db .select() @@ -674,6 +749,7 @@ router.get( : [] : [], pin: !!host.pin, + overrideCredentialUsername: !!host.overrideCredentialUsername, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, tunnelConnections: host.tunnelConnections @@ -848,6 +924,15 @@ router.delete( ), ); + await db + .delete(recentActivity) + .where( + and( + eq(recentActivity.userId, userId), + eq(recentActivity.hostId, numericHostId), + ), + ); + await db .delete(sshData) .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); @@ -1267,7 +1352,9 @@ async function resolveHostCredentials( const credential = credentials[0]; return { ...host, - username: credential.username, + username: host.overrideCredentialUsername + ? host.username + : credential.username, authType: credential.auth_type || credential.authType, password: credential.password, key: credential.key, @@ -1446,8 +1533,10 @@ router.post( username: hostData.username, password: hostData.authType === "password" ? hostData.password : null, authType: hostData.authType, - credentialId: - hostData.authType === "credential" ? hostData.credentialId : null, + credentialId: hostData.credentialId || null, + overrideCredentialUsername: hostData.overrideCredentialUsername + ? 1 + : 0, key: hostData.authType === "key" ? hostData.key : null, keyPassword: hostData.authType === "key" diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c2b2ac03..4c1c8685 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -226,6 +226,16 @@ router.post("/create", async (req, res) => { }); } + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist user to disk", saveError, { + operation: "user_create_save_failed", + userId: id, + }); + } + authLogger.success( `Traditional user created: ${username} (is_admin: ${isFirstUser})`, { @@ -785,6 +795,16 @@ router.get("/oidc/callback", async (req, res) => { }); } + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist OIDC user to disk", saveError, { + operation: "oidc_user_create_save_failed", + userId: id, + }); + } + user = await db.select().from(users).where(eq(users.id, id)); } else { await db @@ -836,6 +856,9 @@ router.get("/oidc/callback", async (req, res) => { ? 30 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000; + // Clear any existing JWT cookie first to prevent conflicts + res.clearCookie("jwt", authManager.getSecureCookieOptions(req)); + return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); @@ -1653,6 +1676,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => { .set({ is_admin: true }) .where(eq(users.username, username)); + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist admin promotion to disk", saveError, { + operation: "make_admin_save_failed", + username, + }); + } + authLogger.success( `User ${username} made admin by ${adminUser[0].username}`, ); @@ -1702,6 +1735,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { .set({ is_admin: false }) .where(eq(users.username, username)); + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist admin removal to disk", saveError, { + operation: "remove_admin_save_failed", + username, + }); + } + authLogger.success( `Admin status removed from ${username} by ${adminUser[0].username}`, ); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index bf30c2de..98f8a8a6 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -845,7 +845,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { sshConn.lastActive = Date.now(); const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { + sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => { if (err) { fileLogger.error("SSH listFiles error:", err); return res.status(500).json({ error: err.message }); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index cc155e49..7a104a8f 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -455,6 +455,13 @@ class PollingManager { } } + if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) { + this.pollingConfigs.delete(host.id); + this.statusStore.delete(host.id); + this.metricsStore.delete(host.id); + return; + } + const config: HostPollingConfig = { host, statsConfig, @@ -514,7 +521,7 @@ class PollingManager { } catch (error) {} } - stopPollingForHost(hostId: number): void { + stopPollingForHost(hostId: number, clearData = true): void { const config = this.pollingConfigs.get(hostId); if (config) { if (config.statusTimer) { @@ -524,8 +531,10 @@ class PollingManager { clearInterval(config.metricsTimer); } this.pollingConfigs.delete(hostId); - this.statusStore.delete(hostId); - this.metricsStore.delete(hostId); + if (clearData) { + this.statusStore.delete(hostId); + this.metricsStore.delete(hostId); + } } } @@ -554,11 +563,23 @@ class PollingManager { } async refreshHostPolling(userId: string): Promise { + const hosts = await fetchAllHosts(userId); + const currentHostIds = new Set(hosts.map((h) => h.id)); + for (const hostId of this.pollingConfigs.keys()) { - this.stopPollingForHost(hostId); + this.stopPollingForHost(hostId, false); } - await this.initializePolling(userId); + for (const hostId of this.statusStore.keys()) { + if (!currentHostIds.has(hostId)) { + this.statusStore.delete(hostId); + this.metricsStore.delete(hostId); + } + } + + for (const host of hosts) { + await this.startPollingForHost(host); + } } destroy(): void { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index a040ee91..3e9930e2 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -152,6 +152,9 @@ wss.on("connection", async (ws: WebSocket, req) => { let totpPromptSent = false; let isKeyboardInteractive = false; let keyboardInteractiveResponded = false; + let isConnecting = false; + let isConnected = false; + let isCleaningUp = false; ws.on("close", () => { const userWs = userConnections.get(userId); @@ -417,10 +420,21 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + if (isConnecting || isConnected) { + sshLogger.warn("Connection already in progress or established", { + operation: "ssh_connect", + hostId: id, + isConnecting, + isConnected, + }); + return; + } + + isConnecting = true; sshConn = new Client(); const connectionTimeout = setTimeout(() => { - if (sshConn) { + if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { operation: "ssh_connect", hostId: id, @@ -433,7 +447,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); cleanupSSH(connectionTimeout); } - }, 60000); + }, 120000); let resolvedCredentials = { password, key, keyPassword, keyType, authType }; let authMethodNotAvailable = false; @@ -498,7 +512,9 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("ready", () => { clearTimeout(connectionTimeout); - if (!sshConn) { + const conn = sshConn; + + if (!conn || isCleaningUp) { sshLogger.warn( "SSH connection was cleaned up before shell could be created", { @@ -507,6 +523,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ip, port, username, + isCleaningUp, }, ); ws.send( @@ -519,7 +536,10 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - sshConn.shell( + isConnecting = false; + isConnected = true; + + conn.shell( { rows: data.rows, cols: data.cols, @@ -836,9 +856,10 @@ wss.on("connection", async (ws: WebSocket, req) => { tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, - readyTimeout: 60000, + readyTimeout: 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, + timeout: 120000, env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", @@ -982,6 +1003,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } function cleanupSSH(timeoutId?: NodeJS.Timeout) { + if (isCleaningUp) { + return; + } + isCleaningUp = true; + if (timeoutId) { clearTimeout(timeoutId); } @@ -1019,6 +1045,12 @@ wss.on("connection", async (ws: WebSocket, req) => { isKeyboardInteractive = false; keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; + isConnecting = false; + isConnected = false; + + setTimeout(() => { + isCleaningUp = false; + }, 100); } function setupPingInterval() { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8012ad05..deb430af 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -109,6 +109,7 @@ "orCreateNewFolder": "Or create new folder", "addTag": "Add tag", "saving": "Saving...", + "credentialId": "Credential ID", "overview": "Overview", "security": "Security", "usage": "Usage", @@ -782,7 +783,9 @@ "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.", "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)." + "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).", + "overrideCredentialUsername": "Override Credential Username", + "overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames." }, "terminal": { "title": "Terminal", diff --git a/src/types/index.ts b/src/types/index.ts index 027de232..c19b57e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,7 @@ export interface SSHHost { autostartKeyPassword?: string; credentialId?: number; + overrideCredentialUsername?: boolean; userId?: string; enableTerminal: boolean; enableTunnel: boolean; @@ -52,6 +53,7 @@ export interface SSHHostData { keyPassword?: string; keyType?: string; credentialId?: number | null; + overrideCredentialUsername?: boolean; enableTerminal?: boolean; enableTunnel?: boolean; enableFileManager?: boolean; diff --git a/src/ui/desktop/apps/credentials/CredentialsManager.tsx b/src/ui/desktop/apps/credentials/CredentialsManager.tsx index aedae3d5..7b3e02fa 100644 --- a/src/ui/desktop/apps/credentials/CredentialsManager.tsx +++ b/src/ui/desktop/apps/credentials/CredentialsManager.tsx @@ -640,6 +640,9 @@ export function CredentialsManager({

{credential.username}

+

+ ID: {credential.id} +

{credential.authType === "password" ? t("credentials.password") diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 2070153b..dde80098 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -527,41 +527,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const reader = new FileReader(); reader.onerror = () => reject(reader.error); - const isTextFile = - file.type.startsWith("text/") || - file.type === "application/json" || - file.type === "application/javascript" || - file.type === "application/xml" || - file.type === "image/svg+xml" || - file.name.match( - /\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i, - ); - - if (isTextFile) { - reader.onload = () => { - if (reader.result) { - resolve(reader.result as string); - } else { - reject(new Error("Failed to read text file content")); + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + const bytes = new Uint8Array(reader.result); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); } - }; - reader.readAsText(file); - } else { - reader.onload = () => { - if (reader.result instanceof ArrayBuffer) { - const bytes = new Uint8Array(reader.result); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64 = btoa(binary); - resolve(base64); - } else { - reject(new Error("Failed to read binary file")); - } - }; - reader.readAsArrayBuffer(file); - } + const base64 = btoa(binary); + resolve(base64); + } else { + reject(new Error("Failed to read file")); + } + }; + reader.readAsArrayBuffer(file); }); await uploadSSHFile( diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e32ccf9b..a322148b 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -217,6 +217,7 @@ export function HostManagerEditor({ pin: z.boolean().default(false), authType: z.enum(["password", "key", "credential", "none"]), credentialId: z.number().optional().nullable(), + overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), key: z.any().optional().nullable(), keyPassword: z.string().optional(), @@ -389,6 +390,7 @@ export function HostManagerEditor({ pin: false, authType: "password" as const, credentialId: null, + overrideCredentialUsername: false, password: "", key: null, keyPassword: "", @@ -407,7 +409,8 @@ export function HostManagerEditor({ useEffect(() => { if (authTab === "credential") { const currentCredentialId = form.getValues("credentialId"); - if (currentCredentialId) { + const overrideUsername = form.getValues("overrideCredentialUsername"); + if (currentCredentialId && !overrideUsername) { const selectedCredential = credentials.find( (c) => c.id === currentCredentialId, ); @@ -464,6 +467,9 @@ export function HostManagerEditor({ pin: Boolean(cleanedHost.pin), authType: defaultAuthType as "password" | "key" | "credential" | "none", credentialId: null, + overrideCredentialUsername: Boolean( + cleanedHost.overrideCredentialUsername, + ), password: "", key: null, keyPassword: "", @@ -512,6 +518,7 @@ export function HostManagerEditor({ pin: false, authType: "password" as const, credentialId: null, + overrideCredentialUsername: false, password: "", key: null, keyPassword: "", @@ -574,6 +581,7 @@ export function HostManagerEditor({ tags: data.tags || [], pin: Boolean(data.pin), authType: data.authType, + overrideCredentialUsername: Boolean(data.overrideCredentialUsername), enableTerminal: Boolean(data.enableTerminal), enableTunnel: Boolean(data.enableTunnel), enableFileManager: Boolean(data.enableFileManager), @@ -882,17 +890,28 @@ export function HostManagerEditor({ ( - - {t("hosts.username")} - - - - - )} + render={({ field }) => { + const isCredentialAuth = authTab === "credential"; + const hasCredential = !!form.watch("credentialId"); + const overrideEnabled = !!form.watch( + "overrideCredentialUsername", + ); + const shouldDisable = + isCredentialAuth && hasCredential && !overrideEnabled; + + return ( + + {t("hosts.username")} + + + + + ); + }} /> @@ -1263,29 +1282,60 @@ export function HostManagerEditor({ - ( - - { - if (credential) { - form.setValue( - "username", - credential.username, - ); - } - }} - /> - - {t("hosts.credentialDescription")} - - +

+ ( + + { + if ( + credential && + !form.getValues( + "overrideCredentialUsername", + ) + ) { + form.setValue( + "username", + credential.username, + ); + } + }} + /> + + {t("hosts.credentialDescription")} + + + )} + /> + {form.watch("credentialId") && ( + ( + +
+ + {t("hosts.overrideCredentialUsername")} + + + {t("hosts.overrideCredentialUsernameDesc")} + +
+ + + +
+ )} + /> )} - /> +
diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 4a35f9f8..a65baaaa 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -112,6 +112,7 @@ export const Terminal = forwardRef( const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = useState(false); const isVisibleRef = useRef(false); + const isReadyRef = useRef(false); const isFittingRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -157,6 +158,10 @@ export const Terminal = forwardRef( isVisibleRef.current = isVisible; }, [isVisible]); + useEffect(() => { + isReadyRef.current = isReady; + }, [isReady]); + useEffect(() => { const checkAuth = () => { const jwtToken = getCookie("jwt"); @@ -507,6 +512,9 @@ export const Terminal = forwardRef( }), ); terminal.onData((data) => { + if (data === "\x00" || data === "\u0000") { + return; + } ws.send(JSON.stringify({ type: "input", data })); }); @@ -915,15 +923,21 @@ export const Terminal = forwardRef( element?.addEventListener("keydown", handleMacKeyboard, true); - const resizeObserver = new ResizeObserver(() => { + const handleResize = () => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current || !isReady) return; + if (!isVisibleRef.current || !isReadyRef.current) return; performFit(); - }, 50); - }); + }, 100); + }; - resizeObserver.observe(xtermRef.current); + const resizeObserver = new ResizeObserver(handleResize); + + if (xtermRef.current) { + resizeObserver.observe(xtermRef.current); + } + + window.addEventListener("resize", handleResize); setVisible(true); @@ -936,6 +950,7 @@ export const Terminal = forwardRef( setIsReady(false); isFittingRef.current = false; resizeObserver.disconnect(); + window.removeEventListener("resize", handleResize); element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("keydown", handleMacKeyboard, true); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 997d712b..51ccf418 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -558,65 +558,61 @@ export function Auth({ if (success) { setOidcLoading(true); - getUserInfo() - .then((meRes) => { - if (isInElectronWebView()) { - const token = getCookie("jwt") || localStorage.getItem("jwt"); - if (token) { - try { - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - token: token, - source: "oidc_callback", - platform: "desktop", - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - setTimeout(() => window.location.reload(), 100); - setOidcLoading(false); - return; - } catch (e) { - console.error("Error posting auth success message:", e); + // Clear the success parameter first to prevent re-processing + window.history.replaceState({}, document.title, window.location.pathname); + + setTimeout(() => { + getUserInfo() + .then((meRes) => { + if (isInElectronWebView()) { + const token = getCookie("jwt") || localStorage.getItem("jwt"); + if (token) { + try { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + token: token, + source: "oidc_callback", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + setTimeout(() => window.location.reload(), 100); + setOidcLoading(false); + return; + } catch (e) { + console.error("Error posting auth success message:", e); + } } } - } - setInternalLoggedIn(true); - setLoggedIn(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + setInternalLoggedIn(true); + }) + .catch((err) => { + console.error("Failed to get user info after OIDC callback:", err); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + }) + .finally(() => { + setOidcLoading(false); }); - setInternalLoggedIn(true); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .finally(() => { - setOidcLoading(false); - }); + }, 200); } }, [ onAuthSuccess, diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f27aa014..a524ed14 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -318,34 +318,37 @@ function createApiInstance( const errorMessage = (error.response?.data as Record) ?.error; const isSessionExpired = errorCode === "SESSION_EXPIRED"; + const isSessionNotFound = errorCode === "SESSION_NOT_FOUND"; const isInvalidToken = errorCode === "AUTH_REQUIRED" || errorMessage === "Invalid token" || errorMessage === "Authentication required"; - if (isElectron()) { - localStorage.removeItem("jwt"); - } else { - localStorage.removeItem("jwt"); - } + if (isSessionExpired || isSessionNotFound) { + if (isElectron()) { + localStorage.removeItem("jwt"); + } else { + localStorage.removeItem("jwt"); + } - if ( - (isSessionExpired || isInvalidToken) && - typeof window !== "undefined" - ) { + if (typeof window !== "undefined") { + console.warn("Session expired or not found - 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."); + window.location.reload(); + }); + + setTimeout(() => window.location.reload(), 1000); + } + } else if (isInvalidToken && typeof window !== "undefined") { console.warn( - "Session expired or invalid token - please log in again", + "Authentication error - token may be invalid", + errorMessage, ); - - 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."); - window.location.reload(); - }); - - setTimeout(() => window.location.reload(), 1000); } } @@ -792,6 +795,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), @@ -855,6 +859,7 @@ export async function updateSSHHost( keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 3751224b..792f72f7 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -504,55 +504,45 @@ export function Auth({ setOidcLoading(true); setError(null); - getUserInfo() - .then((meRes) => { - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - postJWTToWebView(); + window.history.replaceState({}, document.title, window.location.pathname); - if (isReactNativeWebView()) { - setMobileAuthSuccess(true); + setTimeout(() => { + getUserInfo() + .then((meRes) => { + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + postJWTToWebView(); + + if (isReactNativeWebView()) { + setMobileAuthSuccess(true); + setOidcLoading(false); + return; + } + + setLoggedIn(true); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + + setInternalLoggedIn(true); + }) + .catch((err) => { + console.error("Failed to get user info after OIDC callback:", err); + setError(t("errors.failedUserInfo")); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + }) + .finally(() => { setOidcLoading(false); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - return; - } - - setLoggedIn(true); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, }); - - setInternalLoggedIn(true); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - setError(t("errors.failedUserInfo")); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .finally(() => { - setOidcLoading(false); - }); + }, 200); } }, []);