mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
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<void>;
|
|
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<string | null>(null);
|
|
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
|
const isAuthenticatingRef = useRef(false);
|
|
const iframeRef = useRef<HTMLIFrameElement>(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 (
|
|
<div className="relative w-full h-full bg-background flex flex-col">
|
|
{isAuthenticating && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
)}
|
|
|
|
{!isAuthenticating && (
|
|
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
|
|
<button
|
|
onClick={onChangeServer}
|
|
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
<span className="text-base font-medium">
|
|
{t("serverConfig.changeServer")}
|
|
</span>
|
|
</button>
|
|
<div className="flex-1 mx-4 text-center">
|
|
<span className="text-muted-foreground text-sm truncate block">
|
|
{isEmbeddedServer ? t("serverConfig.localServer") : displayUrl}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="p-2 text-foreground hover:text-primary transition-colors"
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && !isAuthenticating && (
|
|
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>{t("common.error")}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
)}
|
|
|
|
{loading && !isAuthenticating && (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center bg-background z-40"
|
|
style={{ marginTop: "60px" }}
|
|
>
|
|
<div className="flex items-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<span className="ml-3 text-muted-foreground">
|
|
{t("auth.loadingServer")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="flex-1 overflow-hidden"
|
|
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
|
|
>
|
|
<iframe
|
|
ref={iframeRef}
|
|
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"
|
|
allow="clipboard-read; clipboard-write"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|