* 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
This commit is contained in:
Luke Gustafson
2026-05-12 21:55:14 -05:00
committed by GitHub
parent ada8a268bb
commit 10794f1e8d
19 changed files with 530 additions and 135 deletions
+3 -3
View File
@@ -6,7 +6,7 @@
"output": "release" "output": "release"
}, },
"asar": true, "asar": true,
"asarUnpack": ["dist/backend/**/*", "node_modules/**/*.node"], "asarUnpack": ["dist/backend/**/*", "node_modules/**/*"],
"files": [ "files": [
"dist/**/*", "dist/**/*",
"electron/**/*", "electron/**/*",
@@ -117,8 +117,8 @@
"type": "distribution", "type": "distribution",
"minimumSystemVersion": "10.15", "minimumSystemVersion": "10.15",
"mergeASARs": false, "mergeASARs": false,
"singleArchFiles": "**/*.node", "singleArchFiles": "**/*.{node,bare}",
"x64ArchFiles": "**/*.node" "x64ArchFiles": "**/*.{node,bare}"
}, },
"dmg": { "dmg": {
"artifactName": "termix_macos_${arch}_dmg.${ext}", "artifactName": "termix_macos_${arch}_dmg.${ext}",
+2 -2
View File
@@ -596,7 +596,7 @@ function startBackendServer() {
logToFile("Starting embedded backend server..."); logToFile("Starting embedded backend server...");
logToFile("Backend entry:", entryPath); logToFile("Backend entry:", entryPath);
logToFile("Data directory:", dataDir); logToFile("Data directory:", dataDir);
logToFile("Backend cwd:", appRoot); logToFile("Backend cwd:", path.dirname(entryPath));
logToFile("Checking paths..."); logToFile("Checking paths...");
logToFile(" entryPath exists:", fs.existsSync(entryPath)); logToFile(" entryPath exists:", fs.existsSync(entryPath));
@@ -613,7 +613,7 @@ function startBackendServer() {
} }
backendProcess = fork(entryPath, [], { backendProcess = fork(entryPath, [], {
cwd: appRoot, cwd: path.dirname(entryPath),
env: { env: {
...process.env, ...process.env,
DATA_DIR: dataDir, DATA_DIR: dataDir,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "2.2.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "2.2.0", "version": "2.2.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"axios": "^1.15.2", "axios": "^1.15.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "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", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+187 -14
View File
@@ -77,6 +77,172 @@ function isValidPort(port: unknown): port is number {
return typeof port === "number" && port > 0 && port <= 65535; 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<string, unknown> & {
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<string, unknown>,
): 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 = [ const SENSITIVE_FIELDS = [
"password", "password",
"key", "key",
@@ -1351,19 +1517,26 @@ router.get(
const ownHosts = rawData.filter((row) => row.userId === userId); const ownHosts = rawData.filter((row) => row.userId === userId);
const sharedHosts = rawData.filter((row) => row.userId !== userId); const sharedHosts = rawData.filter((row) => row.userId !== userId);
let decryptedOwnHosts: Record<string, unknown>[] = []; const decryptedOwnHosts: Record<string, unknown>[] = [];
try { const userDataKey = DataCrypto.getUserDataKey(userId);
decryptedOwnHosts = await SimpleDBOps.select( if (userDataKey) {
Promise.resolve(ownHosts), for (const host of ownHosts) {
"ssh_data", try {
userId, decryptedOwnHosts.push(
); DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey),
} catch (decryptError) { );
sshLogger.error("Failed to decrypt own hosts", decryptError, { } catch (decryptError) {
operation: "host_fetch_own_decrypt_failed", sshLogger.warn("Skipping host with invalid encrypted fields", {
userId, operation: "host_fetch_own_decrypt_failed",
}); userId,
decryptedOwnHosts = []; hostId: host.id,
error:
decryptError instanceof Error
? decryptError.message
: "Unknown error",
});
}
}
} }
const sanitizedSharedHosts = sharedHosts; const sanitizedSharedHosts = sharedHosts;
@@ -3374,7 +3547,7 @@ router.post(
} }
for (let i = 0; i < hostsToImport.length; i++) { for (let i = 0; i < hostsToImport.length; i++) {
const hostData = hostsToImport[i]; const hostData = normalizeImportedHost(hostsToImport[i]);
try { try {
const effectiveConnectionType = hostData.connectionType || "ssh"; const effectiveConnectionType = hostData.connectionType || "ssh";
+137 -21
View File
@@ -1,8 +1,14 @@
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express"; import express from "express";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { snippets, snippetFolders } from "../db/schema.js"; import {
import { eq, and, desc, asc, sql } from "drizzle-orm"; 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 type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js"; import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.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; return typeof val === "string" && val.trim().length > 0;
} }
async function getUserRoleIds(userId: string): Promise<number[]> {
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 authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware();
@@ -618,22 +710,12 @@ router.post(
} }
try { try {
const snippetResult = await db const snippet = await getAccessibleSnippet(parseInt(snippetId), userId);
.select()
.from(snippets)
.where(
and(
eq(snippets.id, parseInt(snippetId)),
eq(snippets.userId, userId),
),
);
if (snippetResult.length === 0) { if (!snippet) {
return res.status(404).json({ error: "Snippet not found" }); return res.status(404).json({ error: "Snippet not found" });
} }
const snippet = snippetResult[0];
const { Client } = await import("ssh2"); const { Client } = await import("ssh2");
const { hosts, sshCredentials } = await import("../db/schema.js"); const { hosts, sshCredentials } = await import("../db/schema.js");
@@ -869,7 +951,7 @@ router.get(
} }
try { try {
const result = await db const ownedSnippets = await db
.select() .select()
.from(snippets) .from(snippets)
.where(eq(snippets.userId, userId)) .where(eq(snippets.userId, userId))
@@ -880,6 +962,43 @@ router.get(
desc(snippets.updatedAt), 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<number, Record<string, unknown>>();
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); res.json(result);
} catch (err) { } catch (err) {
authLogger.error("Failed to fetch snippets", err); authLogger.error("Failed to fetch snippets", err);
@@ -930,16 +1049,13 @@ router.get(
} }
try { try {
const result = await db const result = await getAccessibleSnippet(snippetId, userId);
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (result.length === 0) { if (!result) {
return res.status(404).json({ error: "Snippet not found" }); return res.status(404).json({ error: "Snippet not found" });
} }
res.json(result[0]); res.json(result);
} catch (err) { } catch (err) {
authLogger.error("Failed to fetch snippet", err); authLogger.error("Failed to fetch snippet", err);
res.status(500).json({ res.status(500).json({
+36
View File
@@ -224,6 +224,13 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0; 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 authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware(); const requireAdmin = authManager.createAdminMiddleware();
@@ -1575,6 +1582,7 @@ router.post("/login", async (req, res) => {
success: true, success: true,
is_admin: !!userRecord.isAdmin, is_admin: !!userRecord.isAdmin,
username: userRecord.username, username: userRecord.username,
...(isNativeAppRequest(req) ? { token } : {}),
}; };
const timeoutRow = db.$client 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<string, string> }).cookies
?.jwt;
res.json({ token: token || null });
});
/** /**
* @openapi * @openapi
* /users/setup-required: * /users/setup-required:
@@ -3393,6 +3428,7 @@ router.post("/totp/verify-login", async (req, res) => {
userId: userRecord.id, userId: userRecord.id,
is_oidc: !!userRecord.isOidc, is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled, totp_enabled: !!userRecord.totpEnabled,
...(isNativeAppRequest(req) ? { token } : {}),
}; };
const timeoutRow = db.$client const timeoutRow = db.$client
+1 -1
View File
@@ -53,7 +53,7 @@ const clientOptions = {
}, },
}, },
allowedUnencryptedConnectionSettings: { allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"], rdp: ["width", "height"],
vnc: ["width", "height"], vnc: ["width", "height"],
telnet: ["width", "height"], telnet: ["width", "height"],
}, },
-1
View File
@@ -130,7 +130,6 @@ export class GuacamoleTokenService {
username, username,
password, password,
port: 3389, port: 3389,
security: "nla",
"ignore-cert": true, "ignore-cert": true,
...options, ...options,
}, },
+1 -1
View File
@@ -1033,7 +1033,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
.json({ error: "Invalid SSH key format", connectionLogs }); .json({ error: "Invalid SSH key format", connectionLogs });
} }
} else if (resolvedCredentials.authType === "password") { } else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) { if (!resolvedCredentials.password) {
connectionLogs.push( connectionLogs.push(
createConnectionLog( createConnectionLog(
"error", "error",
@@ -12,6 +12,14 @@ interface DatabaseInstance {
export class LazyFieldEncryption { export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = { private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
key_password: "keyPassword", 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", private_key: "privateKey",
public_key: "publicKey", public_key: "publicKey",
password_hash: "passwordHash", password_hash: "passwordHash",
@@ -21,6 +29,14 @@ export class LazyFieldEncryption {
oidc_identifier: "oidcIdentifier", oidc_identifier: "oidcIdentifier",
keyPassword: "key_password", 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", privateKey: "private_key",
publicKey: "public_key", publicKey: "public_key",
passwordHash: "password_hash", passwordHash: "password_hash",
@@ -93,6 +109,14 @@ export class LazyFieldEncryption {
"password", "password",
"key", "key",
"keyPassword", "keyPassword",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"privateKey", "privateKey",
"publicKey", "publicKey",
"clientSecret", "clientSecret",
@@ -259,6 +283,10 @@ export class LazyFieldEncryption {
"autostartPassword", "autostartPassword",
"autostartKey", "autostartKey",
"autostartKeyPassword", "autostartKeyPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
], ],
ssh_credentials: [ ssh_credentials: [
"password", "password",
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@/lib/utils.ts";
import { import {
Download, Download,
@@ -76,6 +76,29 @@ interface MenuItem {
danger?: boolean; 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({ export function FileManagerContextMenu({
x, x,
y, y,
@@ -108,6 +131,7 @@ export function FileManagerContextMenu({
onCopyPath, onCopyPath,
}: ContextMenuProps) { }: ContextMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState({ x, y }); const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
@@ -120,23 +144,9 @@ export function FileManagerContextMenu({
setIsMounted(true); setIsMounted(true);
const adjustPosition = () => { const adjustPosition = () => {
const menuWidth = 200; const menuWidth = menuRef.current?.offsetWidth ?? 260;
const menuHeight = 300; const menuHeight = menuRef.current?.offsetHeight ?? 400;
const viewportWidth = window.innerWidth; setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight));
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 });
}; };
adjustPosition(); adjustPosition();
@@ -514,13 +524,15 @@ export function FileManagerContextMenu({
/> />
<div <div
ref={menuRef}
data-context-menu data-context-menu
className={cn( className={cn(
"fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden", "fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-x-hidden overflow-y-auto",
)} )}
style={{ style={{
left: menuPosition.x, left: menuPosition.x,
top: menuPosition.y, top: menuPosition.y,
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
}} }}
> >
{finalMenuItems.map((item, index) => { {finalMenuItems.map((item, index) => {
@@ -140,7 +140,6 @@ export const GuacamoleDisplay = forwardRef<
const width = connectionConfig.width ?? containerWidth ?? 1280; const width = connectionConfig.width ?? containerWidth ?? 1280;
const height = connectionConfig.height ?? containerHeight ?? 720; const height = connectionConfig.height ?? containerHeight ?? 720;
const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null;
const wsBase = isDev const wsBase = isDev
? `ws://localhost:30008` ? `ws://localhost:30008`
@@ -171,9 +170,6 @@ export const GuacamoleDisplay = forwardRef<
width: String(width), width: String(width),
height: String(height), height: String(height),
}); });
if (dpi !== null && dpi !== undefined) {
params.set("dpi", String(dpi));
}
return `${wsBase}?${params.toString()}`; return `${wsBase}?${params.toString()}`;
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
@@ -232,7 +232,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const isReconnectingRef = useRef(false); const isReconnectingRef = useRef(false);
const isConnectingRef = useRef(false); const isConnectingRef = useRef(false);
const wasConnectedRef = useRef(false); const wasConnectedRef = useRef(false);
const closeAfterDisconnectRef = useRef(false);
useEffect(() => { useEffect(() => {
isUnmountingRef.current = false; isUnmountingRef.current = false;
@@ -242,7 +241,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
reconnectAttempts.current = 0; reconnectAttempts.current = 0;
wasConnectedRef.current = false; wasConnectedRef.current = false;
isAttachingSessionRef.current = false; isAttachingSessionRef.current = false;
closeAfterDisconnectRef.current = false;
return () => {}; return () => {};
}, [hostConfig.id]); }, [hostConfig.id]);
@@ -1212,12 +1210,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnecting(false); setIsConnecting(false);
if (wasConnectedRef.current) { if (wasConnectedRef.current) {
wasConnectedRef.current = false; wasConnectedRef.current = false;
setShowDisconnectedOverlay(false); setShowDisconnectedOverlay(true);
if (onClose && !closeAfterDisconnectRef.current) {
closeAfterDisconnectRef.current = true;
isUnmountingRef.current = true;
window.setTimeout(onClose, 0);
}
} else if (!connectionErrorRef.current) { } else if (!connectionErrorRef.current) {
updateConnectionError( updateConnectionError(
msg.message || t("terminal.connectionRejected"), msg.message || t("terminal.connectionRejected"),
@@ -1978,7 +1971,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return; return;
} }
if (isElectron() && getUseRightClickCopyPaste()) { if (getUseRightClickCopyPaste()) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (terminal.hasSelection()) { if (terminal.hasSelection()) {
+29 -16
View File
@@ -149,20 +149,27 @@ export function Auth({
const [dbHealthChecking, setDbHealthChecking] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState(false);
const handleElectronAuthSuccess = useCallback( const handleElectronAuthSuccess = useCallback(
async (previousJwt: string | null) => { async (token: string | null) => {
try { try {
const cookieReady = await window.electronAPI?.waitForSessionCookie?.( // token was stored in localStorage by ElectronLoginForm before this runs,
"jwt", // so getUserInfo() can authenticate via the cookie interceptor or localStorage jwt.
currentServerUrl, let retries = 5;
previousJwt, let meRes = null;
5000, while (retries-- > 0) {
); try {
if (cookieReady && !cookieReady.success) { meRes = await getUserInfo();
throw new Error( break;
cookieReady.error || "Authentication cookie not ready", } 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"); if (!meRes) throw new Error("Failed to get user info");
setInternalLoggedIn(true); setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
@@ -187,7 +194,6 @@ export function Auth({
setUserId, setUserId,
t, t,
setInternalLoggedIn, setInternalLoggedIn,
currentServerUrl,
], ],
); );
@@ -334,9 +340,10 @@ export function Auth({
type: "AUTH_SUCCESS", type: "AUTH_SUCCESS",
source: "auth_component", source: "auth_component",
platform: "desktop", platform: "desktop",
token: res.token || null,
timestamp: Date.now(), timestamp: Date.now(),
}, },
window.location.origin, "*",
); );
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
return; return;
@@ -537,9 +544,10 @@ export function Auth({
type: "AUTH_SUCCESS", type: "AUTH_SUCCESS",
source: "totp_auth_component", source: "totp_auth_component",
platform: "desktop", platform: "desktop",
token: res.token || null,
timestamp: Date.now(), timestamp: Date.now(),
}, },
window.location.origin, "*",
); );
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
setTotpLoading(false); setTotpLoading(false);
@@ -665,14 +673,16 @@ export function Auth({
if (isInElectronWebView()) { if (isInElectronWebView()) {
try { try {
const urlToken = urlParams.get("token");
window.parent.postMessage( window.parent.postMessage(
{ {
type: "AUTH_SUCCESS", type: "AUTH_SUCCESS",
source: "oidc_callback", source: "oidc_callback",
platform: "desktop", platform: "desktop",
token: urlToken || null,
timestamp: Date.now(), timestamp: Date.now(),
}, },
window.location.origin, "*",
); );
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
setOidcLoading(false); setOidcLoading(false);
@@ -1100,7 +1110,10 @@ export function Auth({
<Input <Input
ref={totpInputRef} ref={totpInputRef}
id="totp-code" id="totp-code"
name="totp"
type="text" type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="000000" placeholder="000000"
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}
@@ -5,7 +5,7 @@ import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
interface ElectronLoginFormProps { interface ElectronLoginFormProps {
serverUrl: string; serverUrl: string;
onAuthSuccess: (previousJwt: string | null) => void | Promise<void>; onAuthSuccess: (token: string | null) => void | Promise<void>;
onChangeServer: () => void; onChangeServer: () => void;
} }
@@ -27,60 +27,49 @@ export function ElectronLoginForm({
const isAuthenticatingRef = useRef(false); const isAuthenticatingRef = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const hasAuthenticatedRef = useRef(false); const hasAuthenticatedRef = useRef(false);
const [cookieSnapshotReady, setCookieSnapshotReady] = useState(false);
const [currentUrl, setCurrentUrl] = useState(serverUrl); const [currentUrl, setCurrentUrl] = useState(serverUrl);
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);
const onAuthSuccessRef = useRef(onAuthSuccess); const onAuthSuccessRef = useRef(onAuthSuccess);
const initialJwtRef = useRef<string | null | undefined>(undefined);
useEffect(() => { useEffect(() => {
onAuthSuccessRef.current = onAuthSuccess; onAuthSuccessRef.current = onAuthSuccess;
}, [onAuthSuccess]); }, [onAuthSuccess]);
useEffect(() => { const handleAuthSuccess = useCallback(
window.electronAPI async (token: string | null) => {
?.getSessionCookie?.("jwt", serverUrl) if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
.then((value) => { hasAuthenticatedRef.current = true;
initialJwtRef.current = value; isAuthenticatingRef.current = true;
}) setIsAuthenticating(true);
.catch(() => {
initialJwtRef.current = null;
})
.finally(() => {
setCookieSnapshotReady(true);
});
}, [serverUrl]);
const handleAuthSuccess = useCallback(async () => { try {
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return; if (token) {
hasAuthenticatedRef.current = true; localStorage.setItem("jwt", token);
isAuthenticatingRef.current = true; }
setIsAuthenticating(true); await onAuthSuccessRef.current(token);
} catch (_err) {
try { setError(t("errors.authTokenSaveFailed"));
await onAuthSuccessRef.current(initialJwtRef.current ?? null); isAuthenticatingRef.current = false;
} catch (_err) { setIsAuthenticating(false);
setError(t("errors.authTokenSaveFailed")); hasAuthenticatedRef.current = false;
isAuthenticatingRef.current = false; }
setIsAuthenticating(false); },
hasAuthenticatedRef.current = false; [t],
} );
}, [t]);
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. // 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(() => { useEffect(() => {
const handleMessage = async (event: MessageEvent) => { const handleMessage = async (event: MessageEvent) => {
try { try {
const expectedOrigin = new URL(serverUrl).origin;
if (event.origin !== expectedOrigin) return;
if (event.source !== iframeRef.current?.contentWindow) return; if (event.source !== iframeRef.current?.contentWindow) return;
if (!event.data || typeof event.data !== "object") return; if (!event.data || typeof event.data !== "object") return;
const { type, platform, source } = event.data; const { type, platform, source, token } = event.data;
if ( if (
type === "AUTH_SUCCESS" && type === "AUTH_SUCCESS" &&
platform === "desktop" && platform === "desktop" &&
AUTH_MESSAGE_SOURCES.has(source) AUTH_MESSAGE_SOURCES.has(source)
) { ) {
await handleAuthSuccess(); await handleAuthSuccess(token ?? null);
} }
} catch { } catch {
// ignore // ignore
@@ -89,7 +78,7 @@ export function ElectronLoginForm({
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage);
}, [handleAuthSuccess, serverUrl]); }, [handleAuthSuccess]);
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
@@ -202,7 +191,7 @@ export function ElectronLoginForm({
> >
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={cookieSnapshotReady ? serverUrl : "about:blank"} src={serverUrl}
className="w-full h-full border-0" className="w-full h-full border-0"
title="Server Authentication" title="Server Authentication"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
@@ -95,7 +95,7 @@ export function SSHAuthDialog({
} = {}; } = {};
if (authTab === "password") { if (authTab === "password") {
if (password.trim()) { if (password !== "") {
credentials.password = password; credentials.password = password;
} }
} else { } else {
@@ -129,7 +129,7 @@ export function SSHAuthDialog({
const canSubmit = () => { const canSubmit = () => {
if (authTab === "password") { if (authTab === "password") {
return password.trim() !== ""; return password !== "";
} else { } else {
return sshKey.trim() !== ""; return sshKey.trim() !== "";
} }
+17
View File
@@ -383,6 +383,14 @@ function createApiInstance(
} else { } else {
config.headers["X-Electron-App"] = "true"; config.headers["X-Electron-App"] = "true";
} }
const jwt = localStorage.getItem("jwt");
if (jwt) {
if (config.headers.set) {
config.headers.set("Authorization", `Bearer ${jwt}`);
} else {
config.headers["Authorization"] = `Bearer ${jwt}`;
}
}
} }
if ( if (
@@ -2944,6 +2952,15 @@ export async function getUserInfo(): Promise<UserInfo> {
} }
} }
export async function getCurrentToken(): Promise<string | null> {
try {
const response = await authApi.get("/users/me/token");
return response.data?.token ?? null;
} catch {
return null;
}
}
export async function unlockUserData( export async function unlockUserData(
password: string, password: string,
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
+25 -2
View File
@@ -22,6 +22,8 @@ import {
completePasswordReset, completePasswordReset,
getOIDCAuthorizeUrl, getOIDCAuthorizeUrl,
verifyTOTPLogin, verifyTOTPLogin,
getCookie,
getCurrentToken,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { PasswordInput } from "@/components/ui/password-input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx";
@@ -38,15 +40,21 @@ function isReactNativeWebView(): boolean {
); );
} }
function postAuthSuccessToWebView() { async function postAuthSuccessToWebView() {
if (!isReactNativeWebView()) { if (!isReactNativeWebView()) {
return; return;
} }
try { try {
// HTTP-only cookies can't be read via JS — fetch token from the API
let token = getCookie("jwt") || localStorage.getItem("jwt");
if (!token) {
token = await getCurrentToken();
}
(window as ReactNativeWindow).ReactNativeWebView?.postMessage( (window as ReactNativeWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ JSON.stringify({
type: "AUTH_SUCCESS", type: "AUTH_SUCCESS",
token,
source: "explicit", source: "explicit",
platform: "mobile", platform: "mobile",
timestamp: Date.now(), timestamp: Date.now(),
@@ -130,7 +138,19 @@ export function Auth({
useEffect(() => { useEffect(() => {
setInternalLoggedIn(loggedIn); setInternalLoggedIn(loggedIn);
}, [loggedIn]); if (loggedIn && !mobileAuthSuccess) {
// React Native may not have injected ReactNativeWebView yet — poll briefly
const tryPostAuth = (attemptsLeft: number) => {
if (isReactNativeWebView()) {
postAuthSuccessToWebView();
setMobileAuthSuccess(true);
} else if (attemptsLeft > 0) {
setTimeout(() => tryPostAuth(attemptsLeft - 1), 100);
}
};
tryPostAuth(10);
}
}, [loggedIn, mobileAuthSuccess]);
useEffect(() => { useEffect(() => {
if (totpRequired && totpInputRef.current) { if (totpRequired && totpInputRef.current) {
@@ -721,7 +741,10 @@ export function Auth({
<Input <Input
ref={totpInputRef} ref={totpInputRef}
id="totp-code" id="totp-code"
name="totp"
type="text" type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="000000" placeholder="000000"
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}