mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
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
This commit is contained in:
@@ -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}",
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<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 = [
|
||||
"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<string, unknown>[] = [];
|
||||
const decryptedOwnHosts: Record<string, unknown>[] = [];
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (userDataKey) {
|
||||
for (const host of ownHosts) {
|
||||
try {
|
||||
decryptedOwnHosts = await SimpleDBOps.select(
|
||||
Promise.resolve(ownHosts),
|
||||
"ssh_data",
|
||||
userId,
|
||||
decryptedOwnHosts.push(
|
||||
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey),
|
||||
);
|
||||
} catch (decryptError) {
|
||||
sshLogger.error("Failed to decrypt own hosts", 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",
|
||||
});
|
||||
decryptedOwnHosts = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -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<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 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<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);
|
||||
} 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({
|
||||
|
||||
@@ -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<string, string> }).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
|
||||
|
||||
@@ -53,7 +53,7 @@ const clientOptions = {
|
||||
},
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
rdp: ["width", "height"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
|
||||
@@ -130,7 +130,6 @@ export class GuacamoleTokenService {
|
||||
username,
|
||||
password,
|
||||
port: 3389,
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
...options,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,14 @@ interface DatabaseInstance {
|
||||
export class LazyFieldEncryption {
|
||||
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
|
||||
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",
|
||||
|
||||
@@ -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<HTMLDivElement>(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({
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
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={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
|
||||
}}
|
||||
>
|
||||
{finalMenuItems.map((item, index) => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -232,7 +232,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
reconnectAttempts.current = 0;
|
||||
wasConnectedRef.current = false;
|
||||
isAttachingSessionRef.current = false;
|
||||
closeAfterDisconnectRef.current = false;
|
||||
|
||||
return () => {};
|
||||
}, [hostConfig.id]);
|
||||
@@ -1212,12 +1210,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isElectron() && getUseRightClickCopyPaste()) {
|
||||
if (getUseRightClickCopyPaste()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
|
||||
@@ -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({
|
||||
<Input
|
||||
ref={totpInputRef}
|
||||
id="totp-code"
|
||||
name="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (previousJwt: string | null) => void | Promise<void>;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
}
|
||||
|
||||
@@ -27,60 +27,49 @@ export function ElectronLoginForm({
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(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<string | null | undefined>(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 () => {
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (token: string | null) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
await onAuthSuccessRef.current(initialJwtRef.current ?? null);
|
||||
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]);
|
||||
},
|
||||
[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({
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={cookieSnapshotReady ? serverUrl : "about:blank"}
|
||||
src={serverUrl}
|
||||
className="w-full h-full border-0"
|
||||
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"
|
||||
|
||||
@@ -95,7 +95,7 @@ export function SSHAuthDialog({
|
||||
} = {};
|
||||
|
||||
if (authTab === "password") {
|
||||
if (password.trim()) {
|
||||
if (password !== "") {
|
||||
credentials.password = password;
|
||||
}
|
||||
} else {
|
||||
@@ -129,7 +129,7 @@ export function SSHAuthDialog({
|
||||
|
||||
const canSubmit = () => {
|
||||
if (authTab === "password") {
|
||||
return password.trim() !== "";
|
||||
return password !== "";
|
||||
} else {
|
||||
return sshKey.trim() !== "";
|
||||
}
|
||||
|
||||
@@ -383,6 +383,14 @@ function createApiInstance(
|
||||
} else {
|
||||
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 (
|
||||
@@ -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(
|
||||
password: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getCookie,
|
||||
getCurrentToken,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
|
||||
@@ -38,15 +40,21 @@ function isReactNativeWebView(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function postAuthSuccessToWebView() {
|
||||
async function postAuthSuccessToWebView() {
|
||||
if (!isReactNativeWebView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
JSON.stringify({
|
||||
type: "AUTH_SUCCESS",
|
||||
token,
|
||||
source: "explicit",
|
||||
platform: "mobile",
|
||||
timestamp: Date.now(),
|
||||
@@ -130,7 +138,19 @@ export function Auth({
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (totpRequired && totpInputRef.current) {
|
||||
@@ -721,7 +741,10 @@ export function Auth({
|
||||
<Input
|
||||
ref={totpInputRef}
|
||||
id="totp-code"
|
||||
name="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
|
||||
Reference in New Issue
Block a user