openhands / src /components /features /backends /device-flow-auth.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9b906ea verified
Raw History Blame Contribute Delete
11.2 kB
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<Window | null>(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 = (
<DeviceFlowStatusContent
status={deviceFlow.status}
error={deviceFlow.error}
verificationUrl={deviceFlow.verificationUrl}
testIdRoot={testIdRoot}
onCancel={handleCancel}
onRetry={handleStartAuth}
/>
);
const showStatusModal =
statusDisplay === "modal" && deviceFlow.status !== "idle";
const idleLabel = idleButtonLabel ?? t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS);
return (
<div
data-testid={`${testIdRoot}-device-flow`}
className={cn("flex flex-col gap-3", className)}
>
{deviceFlow.status === "idle" ? idleDescription : null}
{deviceFlow.status === "idle" && buttonVariant === "unstyled" && (
<button
type="button"
onClick={handleStartAuth}
data-testid={`${testIdRoot}-login-button`}
className={buttonClassName}
disabled={isDisabled}
aria-label={idleButtonContent ? idleLabel : undefined}
>
{idleButtonContent ?? idleLabel}
</button>
)}
{deviceFlow.status === "idle" && buttonVariant !== "unstyled" && (
<BrandButton
type="button"
variant={buttonVariant}
onClick={handleStartAuth}
testId={`${testIdRoot}-login-button`}
className={buttonClassName}
isDisabled={isDisabled}
ariaLabel={idleButtonContent ? idleLabel : undefined}
>
{idleButtonContent ?? idleLabel}
</BrandButton>
)}
{deviceFlow.status === "idle" ? idleFooter : null}
{statusDisplay === "inline" ? statusContent : null}
{showStatusModal ? (
<ModalBackdrop
onClose={handleCancel}
aria-label={t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
closeOnBackdropClick={false}
>
<ModalBody
testID={`${testIdRoot}-auth-modal`}
width="sm"
className="items-stretch border border-[var(--oh-border)]"
>
{statusContent}
</ModalBody>
</ModalBackdrop>
) : null}
</div>
);
}
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 (
<div
className="flex items-center justify-center gap-2"
data-testid={`${testIdRoot}-auth-starting`}
role="status"
aria-live="polite"
>
<LoadingSpinner />
<span className="text-sm text-[var(--oh-text-tertiary)]">
{t(I18nKey.BACKEND$AUTH_STARTING)}
</span>
</div>
);
}
if (status === "awaiting_authorization") {
const validVerificationUrl =
verificationUrl && isValidVerificationUrl(verificationUrl)
? verificationUrl
: null;
return (
<div
className="flex flex-col gap-4"
data-testid={`${testIdRoot}-auth-awaiting`}
role="status"
aria-live="polite"
>
<div className="flex items-center justify-center gap-2">
<LoadingSpinner />
<span className="text-sm font-medium text-white">
{t(I18nKey.BACKEND$AUTH_AWAITING)}
</span>
</div>
<div className="flex flex-col gap-1">
<p className="text-center text-sm leading-5 text-[var(--oh-text-tertiary)]">
{validVerificationUrl
? `${t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} ${t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}`
: t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
</p>
{validVerificationUrl ? (
<a
href={validVerificationUrl}
target="_blank"
rel="noopener noreferrer"
className="max-h-16 overflow-auto break-all text-center text-xs text-blue-400 hover:underline"
>
{validVerificationUrl}
</a>
) : null}
</div>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
testId={`${testIdRoot}-auth-cancel`}
className="w-full"
>
{t(I18nKey.BACKEND$AUTH_CANCEL)}
</BrandButton>
</div>
);
}
return (
<div
className="flex flex-col gap-3 rounded-lg border border-red-700 bg-red-900/20 p-4"
data-testid={`${testIdRoot}-auth-error`}
role="alert"
>
<p className="text-sm text-red-400">{error}</p>
<BrandButton
type="button"
variant="secondary"
onClick={onRetry}
testId={`${testIdRoot}-auth-retry`}
className="w-full"
>
{t(I18nKey.BACKEND$AUTH_RETRY)}
</BrandButton>
</div>
);
}
function LoadingSpinner() {
return (
<svg
className="animate-spin h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}