import React from "react"; import { useTranslation } from "react-i18next"; import { BrandButton } from "#/components/features/settings/brand-button"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { useDeviceFlow, type DeviceFlowStatus } from "#/hooks/use-device-flow"; import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics"; import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; type DeviceFlowButtonVariant = | "primary" | "secondary" | "tertiary" | "unstyled"; type DeviceFlowStatusDisplay = "inline" | "modal"; interface DeviceFlowAuthProps { /** The host URL for the cloud backend */ host: string; /** Callback when authentication succeeds with the API key */ onSuccess: (apiKey: string) => void; /** Test ID prefix for the component */ testIdRoot: string; /** Whether the login button should be disabled (e.g., when no host is entered) */ isDisabled?: boolean; /** Override for the idle button label and icon-only accessible name. */ idleButtonLabel?: string; /** Optional visible content for the idle button. Defaults to the idle label. */ idleButtonContent?: React.ReactNode; /** Optional content shown only before authentication starts. */ idleDescription?: React.ReactNode; /** * Optional content rendered under the idle button and, like * `idleDescription`, only before authentication starts — so secondary * controls step aside once the flow takes over the surface. */ idleFooter?: React.ReactNode; /** Optional classes for the root wrapper. */ className?: string; /** Optional classes for the idle button. */ buttonClassName?: string; /** Visual variant for the idle button. */ buttonVariant?: DeviceFlowButtonVariant; /** Whether in-progress auth content should render inline or in a modal. */ statusDisplay?: DeviceFlowStatusDisplay; /** Product surface that initiated this authorization attempt. */ analyticsSource?: CloudConnectionSource; } /** * Device Flow authentication UI component. * * Shows a "Connect to OpenHands" button that initiates OAuth 2.0 Device Flow * authentication. Displays status during the auth process and auto-opens * the browser for user authorization. */ /** * Validate that a URL is safe to open in a popup. * Prevents XSS via javascript: URLs or other malicious schemes. */ function isValidVerificationUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.protocol === "https:"; } catch { return false; } } export function DeviceFlowAuth({ host, onSuccess, testIdRoot, isDisabled = false, idleButtonLabel, idleButtonContent, idleDescription, idleFooter, className, buttonClassName, buttonVariant = "primary", statusDisplay = "inline", analyticsSource, }: DeviceFlowAuthProps) { const { t } = useTranslation("openhands"); const deviceFlow = useDeviceFlow(); const popupRef = React.useRef(null); // Close popup on unmount or when auth completes/errors React.useEffect(() => { return () => { popupRef.current?.close(); }; }, []); // Update popup URL when verification URL becomes available React.useEffect(() => { if ( deviceFlow.status === "awaiting_authorization" && deviceFlow.verificationUrl && popupRef.current && !popupRef.current.closed ) { // Validate URL before assigning to prevent XSS if (!isValidVerificationUrl(deviceFlow.verificationUrl)) { console.error("Invalid verification URL protocol"); return; } try { popupRef.current.location.href = deviceFlow.verificationUrl; } catch { // Cross-origin error - popup was navigated away // Open a new one as fallback popupRef.current = window.open( deviceFlow.verificationUrl, "_blank", "noopener,noreferrer", ); } } }, [deviceFlow.status, deviceFlow.verificationUrl]); // Call onSuccess when authentication completes React.useEffect(() => { if (deviceFlow.status === "success" && deviceFlow.apiKey) { try { onSuccess(deviceFlow.apiKey); } finally { deviceFlow.reset(); popupRef.current?.close(); } } }, [deviceFlow.status, deviceFlow.apiKey, deviceFlow.reset, onSuccess]); const handleStartAuth = () => { // Normalize and validate the host URL const normalizedHost = host.trim().replace(/\/+$/, ""); const fullHost = /^https?:\/\//i.test(normalizedHost) ? normalizedHost : `https://${normalizedHost}`; // Validate URL before proceeding try { const url = new URL(fullHost); // Check for URL manipulation attacks if (url.username || url.password) { throw new Error("Invalid URL format"); } } catch { return; // Invalid URL, don't proceed } // Open popup immediately on user click to avoid popup blocker // Start with about:blank and update URL once we have verification URL // Note: We intentionally don't use "noopener" here because we need to // maintain a reference to update the popup's location when the // verification URL becomes available popupRef.current = window.open("about:blank", "_blank"); if (!popupRef.current) { // Popup was blocked - flow will still work, user can click manual link console.warn("Popup blocked - user will need to use manual link"); } deviceFlow.start(fullHost, analyticsSource); }; const handleCancel = () => { deviceFlow.cancel(); popupRef.current?.close(); }; const statusContent = ( ); const showStatusModal = statusDisplay === "modal" && deviceFlow.status !== "idle"; const idleLabel = idleButtonLabel ?? t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS); return (
{deviceFlow.status === "idle" ? idleDescription : null} {deviceFlow.status === "idle" && buttonVariant === "unstyled" && ( )} {deviceFlow.status === "idle" && buttonVariant !== "unstyled" && ( {idleButtonContent ?? idleLabel} )} {deviceFlow.status === "idle" ? idleFooter : null} {statusDisplay === "inline" ? statusContent : null} {showStatusModal ? ( {statusContent} ) : null}
); } interface DeviceFlowStatusContentProps { status: DeviceFlowStatus; error: string | null; verificationUrl: string | null; testIdRoot: string; onCancel: () => void; onRetry: () => void; } function DeviceFlowStatusContent({ status, error, verificationUrl, testIdRoot, onCancel, onRetry, }: DeviceFlowStatusContentProps) { const { t } = useTranslation("openhands"); if (status === "idle" || status === "success") return null; if (status === "starting") { return (
{t(I18nKey.BACKEND$AUTH_STARTING)}
); } if (status === "awaiting_authorization") { const validVerificationUrl = verificationUrl && isValidVerificationUrl(verificationUrl) ? verificationUrl : null; return (
{t(I18nKey.BACKEND$AUTH_AWAITING)}

{validVerificationUrl ? `${t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} ${t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}` : t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}

{validVerificationUrl ? ( {validVerificationUrl} ) : null}
{t(I18nKey.BACKEND$AUTH_CANCEL)}
); } return (

{error}

{t(I18nKey.BACKEND$AUTH_RETRY)}
); } function LoadingSpinner() { return ( ); }