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, JWT goes to localStorage like // every other client. "remoteSync": this iframe is authenticating a // Settings-triggered connection to a remote Termix server for the sync // engine -- the JWT is handed to the Electron main process's encrypted // store instead, never exposed to the renderer's localStorage. targetPurpose?: "local" | "remoteSync"; } interface SaveRemoteSyncJwtResult { success: boolean; reason?: string; error?: string; status?: { needsReauth?: boolean; lastError?: string | null; } | null; } const AUTH_MESSAGE_SOURCES = new Set([ "auth_component", "totp_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 === "remoteSync") { if (!token) { throw new Error(t("errors.authTokenMissing")); } // The main process refuses to persist the JWT when it has no OS // keyring to encrypt it with, and reports that by resolving with // success: false. Dropping the result signs the user in against a // store that kept nothing, so the next sync tick calls a session // that was never saved expired. const result = (await window.electronAPI?.invoke?.( "save-remote-sync-jwt", token, )) as SaveRemoteSyncJwtResult | undefined; if (!result?.success) { throw new Error( result?.reason === "encryption_unavailable" ? t("errors.keyringUnavailable") : result?.error || t("errors.authTokenSaveFailed"), ); } if (result.status?.needsReauth || result.status?.lastError) { throw new Error( result.status.lastError || t("errors.authTokenRejected"), ); } } 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. // Uses '*' as target origin because the iframe may be cross-origin (e.g. remote Docker server). useEffect(() => { const handleMessage = async (event: MessageEvent) => { try { if (event.source !== iframeRef.current?.contentWindow) 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; }) => { iframeRef.current?.contentWindow?.postMessage( { type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT", source: "oidc_system_browser_auth", providerId, ...result, }, "*", ); }; const electronAPI = ( window as unknown as { electronAPI?: { oidcSystemBrowserAuth?: ( url: string, port: number, ) => Promise<{ success: boolean; token?: string; error?: string; }>; }; } ).electronAPI; if (!electronAPI?.oidcSystemBrowserAuth) { sendResultToIframe({ success: false, error: t("errors.failedOidcLogin"), }); return; } const result = await electronAPI.oidcSystemBrowserAuth( authUrl, callbackPort, ); if (result.success && result.token) { await handleAuthSuccess(result.token); return; } sendResultToIframe({ success: false, error: result.error || t("errors.failedOidcLogin"), }); } } 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.failedOidcLogin")); iframeRef.current?.contentWindow?.postMessage( { type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT", source: "oidc_system_browser_auth", providerId, success: false, error, }, "*", ); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [handleAuthSuccess, 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?:\/\//, ""); const isEmbeddedServer = serverUrl.includes("localhost:30001"); return (
{isAuthenticating && (
)} {!isAuthenticating && (
{isEmbeddedServer ? t("serverConfig.localServer") : displayUrl}
)} {error && !isAuthenticating && (
{t("common.error")} {error}
)} {loading && !isAuthenticating && (
{t("auth.loadingServer")}
)}