From 10794f1e8da79b762a17e5f47476abc0df416577 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Tue, 12 May 2026 21:55:14 -0500 Subject: [PATCH] v2.2.1 (#754) * fix: rdp, desktop app, and mobile app login issues and guacd issues * fix: general fixes * fix: mobile/desktop login isssues * fix: mobile/desktop login isssues * fix: mobile/desktop login isssues * chore: format --- electron-builder.json | 6 +- electron/main.cjs | 4 +- package-lock.json | 4 +- package.json | 2 +- src/backend/database/routes/host.ts | 201 ++++++++++++++++-- src/backend/database/routes/snippets.ts | 158 ++++++++++++-- src/backend/database/routes/users.ts | 36 ++++ src/backend/guacamole/guacamole-server.ts | 2 +- src/backend/guacamole/token-service.ts | 1 - src/backend/ssh/file-manager.ts | 2 +- src/backend/utils/lazy-field-encryption.ts | 28 +++ .../file-manager/FileManagerContextMenu.tsx | 50 +++-- .../features/guacamole/GuacamoleDisplay.tsx | 4 - .../apps/features/terminal/Terminal.tsx | 11 +- src/ui/desktop/authentication/Auth.tsx | 45 ++-- .../authentication/ElectronLoginForm.tsx | 63 +++--- .../navigation/dialogs/SSHAuthDialog.tsx | 4 +- src/ui/main-axios.ts | 17 ++ src/ui/mobile/authentication/Auth.tsx | 27 ++- 19 files changed, 530 insertions(+), 135 deletions(-) diff --git a/electron-builder.json b/electron-builder.json index 094c0ef8..845f808a 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -6,7 +6,7 @@ "output": "release" }, "asar": true, - "asarUnpack": ["dist/backend/**/*", "node_modules/**/*.node"], + "asarUnpack": ["dist/backend/**/*", "node_modules/**/*"], "files": [ "dist/**/*", "electron/**/*", @@ -117,8 +117,8 @@ "type": "distribution", "minimumSystemVersion": "10.15", "mergeASARs": false, - "singleArchFiles": "**/*.node", - "x64ArchFiles": "**/*.node" + "singleArchFiles": "**/*.{node,bare}", + "x64ArchFiles": "**/*.{node,bare}" }, "dmg": { "artifactName": "termix_macos_${arch}_dmg.${ext}", diff --git a/electron/main.cjs b/electron/main.cjs index 2538cbd4..847b8525 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -596,7 +596,7 @@ function startBackendServer() { logToFile("Starting embedded backend server..."); logToFile("Backend entry:", entryPath); logToFile("Data directory:", dataDir); - logToFile("Backend cwd:", appRoot); + logToFile("Backend cwd:", path.dirname(entryPath)); logToFile("Checking paths..."); logToFile(" entryPath exists:", fs.existsSync(entryPath)); @@ -613,7 +613,7 @@ function startBackendServer() { } backendProcess = fork(entryPath, [], { - cwd: appRoot, + cwd: path.dirname(entryPath), env: { ...process.env, DATA_DIR: dataDir, diff --git a/package-lock.json b/package-lock.json index ae152a35..87c0a961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.2.0", + "version": "2.2.1", "hasInstallScript": true, "dependencies": { "axios": "^1.15.2", diff --git a/package.json b/package.json index d098a454..aee21c22 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.2.0", + "version": "2.2.1", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index ef078467..5a5b371c 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -77,6 +77,172 @@ function isValidPort(port: unknown): port is number { return typeof port === "number" && port > 0 && port <= 65535; } +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function asPort(value: unknown): number | undefined { + const port = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : NaN; + + return isValidPort(port) ? port : undefined; +} + +function asInteger(value: unknown): number | undefined { + const number = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : NaN; + + return Number.isInteger(number) ? number : undefined; +} + +function asBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; + } + + return fallback; +} + +function normalizeImportTags(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((tag) => asString(tag)) + .filter((tag): tag is string => !!tag); + } + if (typeof value === "string") { + return value + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + } + + return []; +} + +type NormalizedImportedHost = Record & { + connectionType: string; + name?: string; + ip?: string; + port: number; + username?: string; + folder?: string; + tags: string[]; + authType?: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + credentialId?: number; + pin?: unknown; + enableTerminal?: unknown; + enableTunnel?: unknown; + enableFileManager?: unknown; + enableDocker?: unknown; + showTerminalInSidebar?: unknown; + showFileManagerInSidebar?: unknown; + showTunnelInSidebar?: unknown; + showDockerInSidebar?: unknown; + showServerStatsInSidebar?: unknown; + defaultPath?: unknown; + sudoPassword?: unknown; + tunnelConnections?: unknown; + jumpHosts?: unknown; + quickActions?: unknown; + statsConfig?: unknown; + dockerConfig?: unknown; + terminalConfig?: unknown; + forceKeyboardInteractive?: unknown; + notes?: unknown; + useSocks5?: unknown; + socks5Host?: unknown; + socks5Port?: unknown; + socks5Username?: unknown; + socks5Password?: unknown; + socks5ProxyChain?: unknown; + portKnockSequence?: unknown; + overrideCredentialUsername?: unknown; + domain?: unknown; + security?: unknown; + ignoreCert?: unknown; + guacamoleConfig?: unknown; + enableSsh: boolean; + enableRdp: boolean; + enableVnc: boolean; + enableTelnet: boolean; +}; + +function normalizeImportedHost( + hostData: Record, +): NormalizedImportedHost { + const connectionType = + asString(hostData.connectionType) || + (asBoolean(hostData.enableRdp) + ? "rdp" + : asBoolean(hostData.enableVnc) + ? "vnc" + : asBoolean(hostData.enableTelnet) + ? "telnet" + : "ssh"); + + const port = + asPort(hostData.port) || + (connectionType === "rdp" + ? asPort(hostData.rdpPort) || 3389 + : connectionType === "vnc" + ? asPort(hostData.vncPort) || 5900 + : connectionType === "telnet" + ? asPort(hostData.telnetPort) || 23 + : asPort(hostData.sshPort) || 22); + + return { + ...hostData, + connectionType, + name: asString(hostData.name) || asString(hostData.label), + ip: + asString(hostData.ip) || + asString(hostData.address) || + asString(hostData.host) || + asString(hostData.hostname), + port, + username: asString(hostData.username) || asString(hostData.user), + folder: asString(hostData.folder) || asString(hostData.group), + tags: normalizeImportTags(hostData.tags), + credentialId: asInteger(hostData.credentialId), + authType: + asString(hostData.authType) || + asString(hostData.authMethod) || + (hostData.credentialId ? "credential" : hostData.key ? "key" : undefined), + enableSsh: + hostData.enableSsh === undefined + ? connectionType === "ssh" + : asBoolean(hostData.enableSsh), + enableRdp: + hostData.enableRdp === undefined + ? connectionType === "rdp" + : asBoolean(hostData.enableRdp), + enableVnc: + hostData.enableVnc === undefined + ? connectionType === "vnc" + : asBoolean(hostData.enableVnc), + enableTelnet: + hostData.enableTelnet === undefined + ? connectionType === "telnet" + : asBoolean(hostData.enableTelnet), + }; +} + const SENSITIVE_FIELDS = [ "password", "key", @@ -1351,19 +1517,26 @@ router.get( const ownHosts = rawData.filter((row) => row.userId === userId); const sharedHosts = rawData.filter((row) => row.userId !== userId); - let decryptedOwnHosts: Record[] = []; - try { - decryptedOwnHosts = await SimpleDBOps.select( - Promise.resolve(ownHosts), - "ssh_data", - userId, - ); - } catch (decryptError) { - sshLogger.error("Failed to decrypt own hosts", decryptError, { - operation: "host_fetch_own_decrypt_failed", - userId, - }); - decryptedOwnHosts = []; + const decryptedOwnHosts: Record[] = []; + const userDataKey = DataCrypto.getUserDataKey(userId); + if (userDataKey) { + for (const host of ownHosts) { + try { + decryptedOwnHosts.push( + DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey), + ); + } catch (decryptError) { + sshLogger.warn("Skipping host with invalid encrypted fields", { + operation: "host_fetch_own_decrypt_failed", + userId, + hostId: host.id, + error: + decryptError instanceof Error + ? decryptError.message + : "Unknown error", + }); + } + } } const sanitizedSharedHosts = sharedHosts; @@ -3374,7 +3547,7 @@ router.post( } for (let i = 0; i < hostsToImport.length; i++) { - const hostData = hostsToImport[i]; + const hostData = normalizeImportedHost(hostsToImport[i]); try { const effectiveConnectionType = hostData.connectionType || "ssh"; diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index a3c2644e..306c9488 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -1,8 +1,14 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; -import { snippets, snippetFolders } from "../db/schema.js"; -import { eq, and, desc, asc, sql } from "drizzle-orm"; +import { + snippets, + snippetFolders, + snippetAccess, + users, + userRoles, +} from "../db/schema.js"; +import { eq, and, desc, asc, sql, or, isNull, gte } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -15,6 +21,92 @@ function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } +async function getUserRoleIds(userId: string): Promise { + const rows = await db + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + + return rows.map((row) => row.roleId); +} + +function roleIdFilter(roleIds: number[]) { + if (roleIds.length === 0) { + return undefined; + } + + return sql`${snippetAccess.roleId} IN (${sql.join( + roleIds.map((id) => sql`${id}`), + sql`, `, + )})`; +} + +function activeSnippetAccessFilter(userId: string, roleIds: number[]) { + const roleFilter = roleIdFilter(roleIds); + const targetFilter = roleFilter + ? or(eq(snippetAccess.userId, userId), roleFilter) + : eq(snippetAccess.userId, userId); + + return and( + targetFilter, + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, new Date().toISOString()), + ), + ); +} + +function sortSnippets< + T extends { folder: string | null; order: number; updatedAt: string }, +>(a: T, b: T) { + const aFolder = a.folder || ""; + const bFolder = b.folder || ""; + + if (!aFolder && bFolder) return -1; + if (aFolder && !bFolder) return 1; + if (aFolder !== bFolder) return aFolder.localeCompare(bFolder); + if (a.order !== b.order) return a.order - b.order; + + return b.updatedAt.localeCompare(a.updatedAt); +} + +async function getAccessibleSnippet(snippetId: number, userId: string) { + const owned = await db + .select() + .from(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) + .limit(1); + + if (owned.length > 0) { + return owned[0]; + } + + const roleIds = await getUserRoleIds(userId); + const shared = await db + .select({ + id: snippets.id, + userId: snippets.userId, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + order: snippets.order, + createdAt: snippets.createdAt, + updatedAt: snippets.updatedAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .where( + and( + eq(snippetAccess.snippetId, snippetId), + activeSnippetAccessFilter(userId, roleIds), + ), + ) + .limit(1); + + return shared[0] ?? null; +} + const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); @@ -618,22 +710,12 @@ router.post( } try { - const snippetResult = await db - .select() - .from(snippets) - .where( - and( - eq(snippets.id, parseInt(snippetId)), - eq(snippets.userId, userId), - ), - ); + const snippet = await getAccessibleSnippet(parseInt(snippetId), userId); - if (snippetResult.length === 0) { + if (!snippet) { return res.status(404).json({ error: "Snippet not found" }); } - const snippet = snippetResult[0]; - const { Client } = await import("ssh2"); const { hosts, sshCredentials } = await import("../db/schema.js"); @@ -869,7 +951,7 @@ router.get( } try { - const result = await db + const ownedSnippets = await db .select() .from(snippets) .where(eq(snippets.userId, userId)) @@ -880,6 +962,43 @@ router.get( desc(snippets.updatedAt), ); + const roleIds = await getUserRoleIds(userId); + const sharedSnippets = await db + .select({ + id: snippets.id, + userId: snippets.userId, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + order: snippets.order, + createdAt: snippets.createdAt, + updatedAt: snippets.updatedAt, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where(activeSnippetAccessFilter(userId, roleIds)); + + const visibleSnippets = new Map>(); + for (const snippet of ownedSnippets) { + visibleSnippets.set(snippet.id, { ...snippet, isShared: false }); + } + for (const snippet of sharedSnippets) { + if (visibleSnippets.has(snippet.id)) continue; + visibleSnippets.set(snippet.id, { ...snippet, isShared: true }); + } + + const result = Array.from(visibleSnippets.values()).sort((a, b) => + sortSnippets( + a as { folder: string | null; order: number; updatedAt: string }, + b as { folder: string | null; order: number; updatedAt: string }, + ), + ); + res.json(result); } catch (err) { authLogger.error("Failed to fetch snippets", err); @@ -930,16 +1049,13 @@ router.get( } try { - const result = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + const result = await getAccessibleSnippet(snippetId, userId); - if (result.length === 0) { + if (!result) { return res.status(404).json({ error: "Snippet not found" }); } - res.json(result[0]); + res.json(result); } catch (err) { authLogger.error("Failed to fetch snippet", err); res.status(500).json({ diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 5511e13c..cb4912cc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -224,6 +224,13 @@ function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } +function isNativeAppRequest(req: Request): boolean { + return ( + (req.get("User-Agent") || "").startsWith("Termix-Mobile/") || + req.get("X-Electron-App") === "true" + ); +} + const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); @@ -1575,6 +1582,7 @@ router.post("/login", async (req, res) => { success: true, is_admin: !!userRecord.isAdmin, username: userRecord.username, + ...(isNativeAppRequest(req) ? { token } : {}), }; const timeoutRow = db.$client @@ -1683,6 +1691,33 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { } }); +/** + * @openapi + * /users/me/token: + * get: + * summary: Get current session token + * description: Returns the JWT for the currently authenticated session. Intended for mobile WebView clients that cannot read HTTP-only cookies. + * tags: + * - Users + * responses: + * 200: + * description: Current session token. + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + * 401: + * description: Not authenticated. + */ +router.get("/me/token", authenticateJWT, (req: Request, res: Response) => { + const token = (req as Request & { cookies: Record }).cookies + ?.jwt; + res.json({ token: token || null }); +}); + /** * @openapi * /users/setup-required: @@ -3393,6 +3428,7 @@ router.post("/totp/verify-login", async (req, res) => { userId: userRecord.id, is_oidc: !!userRecord.isOidc, totp_enabled: !!userRecord.totpEnabled, + ...(isNativeAppRequest(req) ? { token } : {}), }; const timeoutRow = db.$client diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index a726765b..b57051ae 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -53,7 +53,7 @@ const clientOptions = { }, }, allowedUnencryptedConnectionSettings: { - rdp: ["width", "height", "dpi"], + rdp: ["width", "height"], vnc: ["width", "height"], telnet: ["width", "height"], }, diff --git a/src/backend/guacamole/token-service.ts b/src/backend/guacamole/token-service.ts index 83660cb4..83a2c93a 100644 --- a/src/backend/guacamole/token-service.ts +++ b/src/backend/guacamole/token-service.ts @@ -130,7 +130,6 @@ export class GuacamoleTokenService { username, password, port: 3389, - security: "nla", "ignore-cert": true, ...options, }, diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index ea4431c0..4bed2271 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -1033,7 +1033,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { .json({ error: "Invalid SSH key format", connectionLogs }); } } else if (resolvedCredentials.authType === "password") { - if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) { + if (!resolvedCredentials.password) { connectionLogs.push( createConnectionLog( "error", diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 62d72d64..5d59ba96 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -12,6 +12,14 @@ interface DatabaseInstance { export class LazyFieldEncryption { private static readonly LEGACY_FIELD_NAME_MAP: Record = { key_password: "keyPassword", + sudo_password: "sudoPassword", + autostart_password: "autostartPassword", + autostart_key: "autostartKey", + autostart_key_password: "autostartKeyPassword", + socks5_password: "socks5Password", + rdp_password: "rdpPassword", + vnc_password: "vncPassword", + telnet_password: "telnetPassword", private_key: "privateKey", public_key: "publicKey", password_hash: "passwordHash", @@ -21,6 +29,14 @@ export class LazyFieldEncryption { oidc_identifier: "oidcIdentifier", keyPassword: "key_password", + sudoPassword: "sudo_password", + autostartPassword: "autostart_password", + autostartKey: "autostart_key", + autostartKeyPassword: "autostart_key_password", + socks5Password: "socks5_password", + rdpPassword: "rdp_password", + vncPassword: "vnc_password", + telnetPassword: "telnet_password", privateKey: "private_key", publicKey: "public_key", passwordHash: "password_hash", @@ -93,6 +109,14 @@ export class LazyFieldEncryption { "password", "key", "keyPassword", + "sudoPassword", + "autostartPassword", + "autostartKey", + "autostartKeyPassword", + "socks5Password", + "rdpPassword", + "vncPassword", + "telnetPassword", "privateKey", "publicKey", "clientSecret", @@ -259,6 +283,10 @@ export class LazyFieldEncryption { "autostartPassword", "autostartKey", "autostartKeyPassword", + "socks5Password", + "rdpPassword", + "vncPassword", + "telnetPassword", ], ssh_credentials: [ "password", diff --git a/src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx b/src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx index 431f0f19..f300c89d 100644 --- a/src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx +++ b/src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils.ts"; import { Download, @@ -76,6 +76,29 @@ interface MenuItem { danger?: boolean; } +const VIEWPORT_PADDING = 10; + +function getClampedMenuPosition( + x: number, + y: number, + menuWidth: number, + menuHeight: number, +) { + const maxX = Math.max( + VIEWPORT_PADDING, + window.innerWidth - menuWidth - VIEWPORT_PADDING, + ); + const maxY = Math.max( + VIEWPORT_PADDING, + window.innerHeight - menuHeight - VIEWPORT_PADDING, + ); + + return { + x: Math.min(Math.max(VIEWPORT_PADDING, x), maxX), + y: Math.min(Math.max(VIEWPORT_PADDING, y), maxY), + }; +} + export function FileManagerContextMenu({ x, y, @@ -108,6 +131,7 @@ export function FileManagerContextMenu({ onCopyPath, }: ContextMenuProps) { const { t } = useTranslation(); + const menuRef = useRef(null); const [menuPosition, setMenuPosition] = useState({ x, y }); const [isMounted, setIsMounted] = useState(false); @@ -120,23 +144,9 @@ export function FileManagerContextMenu({ setIsMounted(true); const adjustPosition = () => { - const menuWidth = 200; - const menuHeight = 300; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let adjustedX = x; - let adjustedY = y; - - if (x + menuWidth > viewportWidth) { - adjustedX = viewportWidth - menuWidth - 10; - } - - if (y + menuHeight > viewportHeight) { - adjustedY = viewportHeight - menuHeight - 10; - } - - setMenuPosition({ x: adjustedX, y: adjustedY }); + const menuWidth = menuRef.current?.offsetWidth ?? 260; + const menuHeight = menuRef.current?.offsetHeight ?? 400; + setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight)); }; adjustPosition(); @@ -514,13 +524,15 @@ export function FileManagerContextMenu({ />
{finalMenuItems.map((item, index) => { diff --git a/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx index 372334f0..6a40613d 100644 --- a/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx @@ -140,7 +140,6 @@ export const GuacamoleDisplay = forwardRef< const width = connectionConfig.width ?? containerWidth ?? 1280; const height = connectionConfig.height ?? containerHeight ?? 720; - const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null; const wsBase = isDev ? `ws://localhost:30008` @@ -171,9 +170,6 @@ export const GuacamoleDisplay = forwardRef< width: String(width), height: String(height), }); - if (dpi !== null && dpi !== undefined) { - params.set("dpi", String(dpi)); - } return `${wsBase}?${params.toString()}`; } catch (error) { const errorMessage = diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index f493e460..1e3524fd 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -232,7 +232,6 @@ const TerminalInner = forwardRef( const isReconnectingRef = useRef(false); const isConnectingRef = useRef(false); const wasConnectedRef = useRef(false); - const closeAfterDisconnectRef = useRef(false); useEffect(() => { isUnmountingRef.current = false; @@ -242,7 +241,6 @@ const TerminalInner = forwardRef( reconnectAttempts.current = 0; wasConnectedRef.current = false; isAttachingSessionRef.current = false; - closeAfterDisconnectRef.current = false; return () => {}; }, [hostConfig.id]); @@ -1212,12 +1210,7 @@ const TerminalInner = forwardRef( setIsConnecting(false); if (wasConnectedRef.current) { wasConnectedRef.current = false; - setShowDisconnectedOverlay(false); - if (onClose && !closeAfterDisconnectRef.current) { - closeAfterDisconnectRef.current = true; - isUnmountingRef.current = true; - window.setTimeout(onClose, 0); - } + setShowDisconnectedOverlay(true); } else if (!connectionErrorRef.current) { updateConnectionError( msg.message || t("terminal.connectionRejected"), @@ -1978,7 +1971,7 @@ const TerminalInner = forwardRef( return; } - if (isElectron() && getUseRightClickCopyPaste()) { + if (getUseRightClickCopyPaste()) { e.preventDefault(); e.stopPropagation(); if (terminal.hasSelection()) { diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 15520875..aa7e5d08 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -149,20 +149,27 @@ export function Auth({ const [dbHealthChecking, setDbHealthChecking] = useState(false); const handleElectronAuthSuccess = useCallback( - async (previousJwt: string | null) => { + async (token: string | null) => { try { - const cookieReady = await window.electronAPI?.waitForSessionCookie?.( - "jwt", - currentServerUrl, - previousJwt, - 5000, - ); - if (cookieReady && !cookieReady.success) { - throw new Error( - cookieReady.error || "Authentication cookie not ready", - ); + // token was stored in localStorage by ElectronLoginForm before this runs, + // so getUserInfo() can authenticate via the cookie interceptor or localStorage jwt. + let retries = 5; + let meRes = null; + while (retries-- > 0) { + try { + meRes = await getUserInfo(); + break; + } catch (err: unknown) { + const isNoServer = + (err as { code?: string })?.code === "NO_SERVER_CONFIGURED" || + (err as Error)?.message?.includes("no-server-configured"); + if (isNoServer && retries > 0) { + await new Promise((r) => setTimeout(r, 500)); + } else { + throw err; + } + } } - const meRes = await getUserInfo(); if (!meRes) throw new Error("Failed to get user info"); setInternalLoggedIn(true); setLoggedIn(true); @@ -187,7 +194,6 @@ export function Auth({ setUserId, t, setInternalLoggedIn, - currentServerUrl, ], ); @@ -334,9 +340,10 @@ export function Auth({ type: "AUTH_SUCCESS", source: "auth_component", platform: "desktop", + token: res.token || null, timestamp: Date.now(), }, - window.location.origin, + "*", ); setWebviewAuthSuccess(true); return; @@ -537,9 +544,10 @@ export function Auth({ type: "AUTH_SUCCESS", source: "totp_auth_component", platform: "desktop", + token: res.token || null, timestamp: Date.now(), }, - window.location.origin, + "*", ); setWebviewAuthSuccess(true); setTotpLoading(false); @@ -665,14 +673,16 @@ export function Auth({ if (isInElectronWebView()) { try { + const urlToken = urlParams.get("token"); window.parent.postMessage( { type: "AUTH_SUCCESS", source: "oidc_callback", platform: "desktop", + token: urlToken || null, timestamp: Date.now(), }, - window.location.origin, + "*", ); setWebviewAuthSuccess(true); setOidcLoading(false); @@ -1100,7 +1110,10 @@ export function Auth({ void | Promise; + onAuthSuccess: (token: string | null) => void | Promise; onChangeServer: () => void; } @@ -27,60 +27,49 @@ export function ElectronLoginForm({ const isAuthenticatingRef = useRef(false); const iframeRef = useRef(null); const hasAuthenticatedRef = useRef(false); - const [cookieSnapshotReady, setCookieSnapshotReady] = useState(false); const [currentUrl, setCurrentUrl] = useState(serverUrl); const hasLoadedOnce = useRef(false); const onAuthSuccessRef = useRef(onAuthSuccess); - const initialJwtRef = useRef(undefined); useEffect(() => { onAuthSuccessRef.current = onAuthSuccess; }, [onAuthSuccess]); - useEffect(() => { - window.electronAPI - ?.getSessionCookie?.("jwt", serverUrl) - .then((value) => { - initialJwtRef.current = value; - }) - .catch(() => { - initialJwtRef.current = null; - }) - .finally(() => { - setCookieSnapshotReady(true); - }); - }, [serverUrl]); + const handleAuthSuccess = useCallback( + async (token: string | null) => { + if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return; + hasAuthenticatedRef.current = true; + isAuthenticatingRef.current = true; + setIsAuthenticating(true); - const handleAuthSuccess = useCallback(async () => { - if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return; - hasAuthenticatedRef.current = true; - isAuthenticatingRef.current = true; - setIsAuthenticating(true); - - try { - await onAuthSuccessRef.current(initialJwtRef.current ?? null); - } catch (_err) { - setError(t("errors.authTokenSaveFailed")); - isAuthenticatingRef.current = false; - setIsAuthenticating(false); - hasAuthenticatedRef.current = false; - } - }, [t]); + try { + if (token) { + localStorage.setItem("jwt", token); + } + await onAuthSuccessRef.current(token); + } catch (_err) { + setError(t("errors.authTokenSaveFailed")); + isAuthenticatingRef.current = false; + setIsAuthenticating(false); + hasAuthenticatedRef.current = false; + } + }, + [t], + ); // postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. + // Uses '*' as target origin because the iframe may be cross-origin (e.g. remote Docker server). useEffect(() => { const handleMessage = async (event: MessageEvent) => { try { - const expectedOrigin = new URL(serverUrl).origin; - if (event.origin !== expectedOrigin) return; if (event.source !== iframeRef.current?.contentWindow) return; if (!event.data || typeof event.data !== "object") return; - const { type, platform, source } = event.data; + const { type, platform, source, token } = event.data; if ( type === "AUTH_SUCCESS" && platform === "desktop" && AUTH_MESSAGE_SOURCES.has(source) ) { - await handleAuthSuccess(); + await handleAuthSuccess(token ?? null); } } catch { // ignore @@ -89,7 +78,7 @@ export function ElectronLoginForm({ window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [handleAuthSuccess, serverUrl]); + }, [handleAuthSuccess]); useEffect(() => { const iframe = iframeRef.current; @@ -202,7 +191,7 @@ export function ElectronLoginForm({ >