* 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
@@ -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}