* 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"
},
"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
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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",
+187 -14
View File
@@ -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>[] = [];
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<string, unknown>[] = [];
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";
+137 -21
View File
@@ -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({
+36
View File
@@ -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
+1 -1
View File
@@ -53,7 +53,7 @@ const clientOptions = {
},
},
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
rdp: ["width", "height"],
vnc: ["width", "height"],
telnet: ["width", "height"],
},
-1
View File
@@ -130,7 +130,6 @@ export class GuacamoleTokenService {
username,
password,
port: 3389,
security: "nla",
"ignore-cert": true,
...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 });
}
} 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()) {
+29 -16
View File
@@ -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 (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({
>
<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() !== "";
}
+17
View File
@@ -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 }> {
+25 -2
View File
@@ -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}