import { getErrorMessage } from "../lib/error-message.js"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx"; import { useTranslation } from "react-i18next"; import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react"; interface ElectronLoginFormProps { serverUrl: string; onAuthSuccess: (token: string | null) => void | Promise; onChangeServer: () => void; // "local" (default): the app's own login, the token goes to localStorage // like every other client. "link": signing in to a server to link this // desktop to it; the token is only handed to onAuthSuccess, which trades // it for the desktop's own session. targetPurpose?: "local" | "link"; } const AUTH_MESSAGE_SOURCES = new Set([ "auth_component", "second_factor_auth_component", "method_auth_component", "oidc_callback", ]); export function ElectronLoginForm({ serverUrl, onAuthSuccess, onChangeServer, targetPurpose = "local", }: ElectronLoginFormProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isAuthenticating, setIsAuthenticating] = useState(false); const isAuthenticatingRef = useRef(false); const iframeRef = useRef(null); const hasAuthenticatedRef = useRef(false); const [currentUrl, setCurrentUrl] = useState(serverUrl); const hasLoadedOnce = useRef(false); const onAuthSuccessRef = useRef(onAuthSuccess); useEffect(() => { onAuthSuccessRef.current = onAuthSuccess; }, [onAuthSuccess]); const handleAuthSuccess = useCallback( async (token: string | null) => { if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return; hasAuthenticatedRef.current = true; isAuthenticatingRef.current = true; setIsAuthenticating(true); try { if (targetPurpose === "link") { if (!token) { throw new Error(t("errors.authTokenMissing")); } } else if (token) { localStorage.setItem("jwt", token); } await onAuthSuccessRef.current(token); } catch (err) { setError( err instanceof Error && err.message ? err.message : t("errors.authTokenSaveFailed"), ); isAuthenticatingRef.current = false; setIsAuthenticating(false); hasAuthenticatedRef.current = false; } }, [t, targetPurpose], ); // postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. // Only the server's own origin is trusted: the iframe can navigate away // (identity provider, redirects), and those pages must not hand us a token, // start a system browser login, or receive the replies. useEffect(() => { let serverOrigin: string; try { serverOrigin = new URL(serverUrl).origin; } catch { return; } const handleMessage = async (event: MessageEvent) => { try { if (event.source !== iframeRef.current?.contentWindow) return; if (event.origin !== serverOrigin) return; if (!event.data || typeof event.data !== "object") return; const { type, platform, source, token, authUrl, callbackPort, providerId, } = event.data; if ( type === "AUTH_SUCCESS" && platform === "desktop" && AUTH_MESSAGE_SOURCES.has(source) ) { await handleAuthSuccess(token ?? null); return; } // OIDC login requested from inside the iframe, open the system browser // so captcha stages (e.g. Cloudflare Turnstile) render correctly. if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) { const sendResultToIframe = (result: { success: boolean; error?: string; secondFactor?: boolean; tempToken?: string; factors?: string; }) => { iframeRef.current?.contentWindow?.postMessage( { type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT", source: "oidc_system_browser_auth", providerId, ...result, }, serverOrigin, ); }; const electronAPI = ( window as unknown as { electronAPI?: { externalBrowserLogin?: ( url: string, port: number, ) => Promise<{ success: boolean; token?: string; error?: string; secondFactor?: boolean; tempToken?: string; factors?: string; }>; }; } ).electronAPI; if (!electronAPI?.externalBrowserLogin) { sendResultToIframe({ success: false, error: t("errors.failedExternalLogin"), }); return; } const result = await electronAPI.externalBrowserLogin( authUrl, callbackPort, ); if (result.success && result.token) { await handleAuthSuccess(result.token); return; } // The login stopped for a second factor; the iframe asks for it. if (result.secondFactor) { sendResultToIframe({ success: false, secondFactor: true, tempToken: result.tempToken, factors: result.factors, }); return; } sendResultToIframe({ success: false, error: result.error || t("errors.failedExternalLogin"), }); } } catch (err) { const providerId = event.data && typeof event.data === "object" && typeof event.data.providerId === "number" ? event.data.providerId : undefined; const error = getErrorMessage(err, t("errors.failedExternalLogin")); iframeRef.current?.contentWindow?.postMessage( { type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT", source: "oidc_system_browser_auth", providerId, success: false, error, }, serverOrigin, ); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [handleAuthSuccess, serverUrl, t]); useEffect(() => { const iframe = iframeRef.current; if (!iframe) return; const handleLoad = () => { setLoading(false); hasLoadedOnce.current = true; setError(null); try { if (iframe.contentWindow) { setCurrentUrl(iframe.contentWindow.location.href); } } catch { setCurrentUrl(serverUrl); } }; const handleError = () => { setLoading(false); if (hasLoadedOnce.current) { setError(t("errors.failedToLoadServer")); } }; iframe.addEventListener("load", handleLoad); iframe.addEventListener("error", handleError); return () => { iframe.removeEventListener("load", handleLoad); iframe.removeEventListener("error", handleError); }; }, [serverUrl, t]); const handleRefresh = () => { if (iframeRef.current) { iframeRef.current.src = serverUrl; setLoading(true); setError(null); } }; const displayUrl = currentUrl.replace(/^https?:\/\//, ""); return (
{isAuthenticating && (
)} {!isAuthenticating && (
{displayUrl}
)} {error && !isAuthenticating && (
{t("common.error")} {error}
)} {loading && !isAuthenticating && (
{t("auth.loadingServer")}
)}