openhands / src /components /features /backends /backend-form-modal.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
9b906ea verified
Raw History Blame Contribute Delete
55.1 kB
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ChevronDown, Globe, Info, Monitor } from "lucide-react";
import { ServerClient } from "@openhands/typescript-client/clients";
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import {
MODAL_MAX_WIDTH_VIEWPORT,
modalWidthClassName,
} from "#/components/shared/modals/modal-body";
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { useNavigation } from "#/context/navigation-context";
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
import { useTracking } from "#/hooks/use-tracking";
import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { getLockedCloudHost } from "#/api/agent-server-config";
import { isOpenHandsCloudHost } from "#/api/device-flow-client";
import {
getDisplayAgentServerVersion,
validateLocalBackend,
} from "#/api/agent-server-compatibility";
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
import { I18nKey } from "#/i18n/declaration";
import type { Backend, BackendKind } from "#/api/backend-registry/types";
import { getUserFacingConnectionErrorMessage } from "#/utils/user-facing-error";
import { cn } from "#/utils/utils";
import {
modalTitleLgClassName,
modalTitleLgMediumClassName,
} from "#/utils/modal-classes";
import ExternalLinkIcon from "#/icons/external-link.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import { getBackendStatusLabel } from "./backend-status-label";
import { BackendStatusDot } from "./backend-status-dot";
import { DeviceFlowAuth } from "./device-flow-auth";
export type BackendFormMode = "add" | "edit";
interface BackendConnectionTestMetadata {
agentServerVersion: string | null;
}
interface BackendFormModalProps {
mode: BackendFormMode;
/** Required when `mode === "edit"`. */
backend?: Backend;
onClose: () => void;
/** Analytics surface for the `backend_added` event (add mode only). */
source?: BackendAddedSource;
/** Hide the close button and disable backdrop/escape dismissal. Used for locked Cloud first-run. */
hideCloseButton?: boolean;
}
/**
* Seed the default backend kind from the host. Uses proper hostname-suffix
* matching (via {@link isOpenHandsCloudHost}) rather than a substring test, so
* a look-alike host such as `all-hands-testing.dev` isn't misread as cloud.
*
* This is only a *default*: a self-hosted OpenHands Cloud/Enterprise instance
* on a truly custom domain is indistinguishable from a local agent-server by
* host alone, so the manual add form lets the user override the kind
* explicitly (see the Type selector in ManualConnectionColumn).
*/
function inferKindFromHost(host: string): BackendKind {
return isOpenHandsCloudHost(host) ? "cloud" : "local";
}
/**
* Returns true for hostnames that represent a local / private-network address.
* Used by normalizeHost to choose http:// instead of https://.
*/
function isLocalAddress(hostname: string): boolean {
// Strip IPv6 bracket notation: [::1] β†’ ::1
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
// IPv6 loopback, any-address, and named loopback
if (h === "localhost" || h === "::1" || h === "::" || h === "0.0.0.0")
return true;
// 127.x.x.x loopback range + IPv4-mapped loopback (::ffff:127.x.x.x)
if (/^127\./.test(h) || /^::ffff:127\./i.test(h)) return true;
// RFC 1918 private ranges
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
// IPv6 link-local (fe80::/10) and unique local (fc00::/7)
if (/^fe[89ab][0-9a-f]:/i.test(h)) return true;
if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true;
// mDNS / Bonjour (.local)
if (h.endsWith(".local")) return true;
// Single-label hostnames (no dots, no colons) are local network names.
// Colons are excluded so bare IPv6 addresses don't accidentally match.
if (!h.includes(".") && !h.includes(":")) return true;
return false;
}
function normalizeHost(host: string): string {
const trimmed = host.trim().replace(/\/+$/, "");
if (!trimmed) return "";
// Already has an explicit scheme β€” respect it.
if (/^https?:\/\//i.test(trimmed)) return trimmed;
// Extract the pure hostname for scheme selection, handling three cases:
// [::1]:8080 β†’ bracket IPv6 notation β†’ extract ::1
// ::1 β†’ bare IPv6 (multiple colons, no bracket) β†’ whole string
// host:port β†’ regular host:port β†’ part before the colon
const bracketMatch = trimmed.match(/^\[([^\]]+)\]/);
const hostname = bracketMatch
? bracketMatch[1]
: (trimmed.match(/:/g) ?? []).length > 1
? trimmed
: trimmed.split(":")[0];
const scheme = isLocalAddress(hostname) ? "http" : "https";
return `${scheme}://${trimmed}`;
}
/**
* Returns true when `host` represents a reachable backend URL.
*
* Rules (applied in order):
* 1. Must be non-empty after trimming.
* 2. Must contain no whitespace β€” spaces can never appear in a host/port.
* 3. After normalisation (bare hosts get `https://` prepended), must parse
* as a valid http or https URL with a non-empty hostname.
*/
function isValidHostUrl(host: string): boolean {
const trimmed = host.trim();
if (!trimmed) return false;
// Spaces anywhere in the input are an immediate rejection.
if (/\s/.test(trimmed)) return false;
const normalized = normalizeHost(trimmed);
if (!normalized) return false;
try {
const url = new URL(normalized);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
url.hostname.length > 0
);
} catch {
return false;
}
}
const DEFAULT_OPENHANDS_CLOUD_HOST = "https://app.all-hands.dev";
const LOCAL_BACKEND_COMMAND = "agent-canvas --backend-only --port 8001";
const LOCAL_AGENT_SERVER_DOCS_URL =
"https://github.com/OpenHands/OpenHands/blob/main/docs/DEVELOPMENT.md#alternative-development-workflows";
const REMOTE_AGENT_SERVER_DOCS_URL =
"https://github.com/OpenHands/OpenHands/blob/main/docs/SELF_HOSTING.md";
const DEPLOYMENT_OPTIONS_URL =
"https://docs.openhands.dev/overview/introduction";
export type BackendConnectionMethod = "manual" | "cloud_login";
export type BackendAddedSource = CloudConnectionSource;
type AddBackendOption = "cloud" | "agent-server";
type AgentServerLocation = "local" | "remote";
function getConnectionTestFailedTitle(
t: ReturnType<typeof useTranslation>["t"],
host: string,
): string {
return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, {
host,
interpolation: { escapeValue: false },
});
}
function getConnectionErrorDetail(error: unknown): string | null {
return getUserFacingConnectionErrorMessage(error);
}
function getConnectionTestFailedMessage(title: string, error: unknown): string {
const detail = getConnectionErrorDetail(error);
return detail ? `${title}\n${detail}` : title;
}
async function testBackendConnection(
backend: Pick<Backend, "host" | "apiKey" | "kind">,
): Promise<BackendConnectionTestMetadata> {
// Cloud backends authenticate via OAuth; preflight GET is not applicable.
if (backend.kind !== "local") return { agentServerVersion: null };
const agentServerVersion = await validateLocalBackend(backend, 5000);
return { agentServerVersion };
}
/**
* Live status row for the edit form: shows a connection dot, a
* "Local"/"Cloud" label, and the agent server's reported version when
* available. Replaces the legacy local/cloud radio fieldset (kind is
* now inferred from the host).
*/
function BackendStatusBadge({
backend,
testIdRoot,
}: {
backend: Backend;
testIdRoot: string;
}) {
const { t } = useTranslation("openhands");
const healthByBackendId = useBackendsHealth([backend]);
const health = healthByBackendId[backend.id];
const isConnected = health?.isConnected ?? null;
const disabled = health?.disabled === true;
const consecutiveFailures = health?.consecutiveFailures ?? 0;
const lastError = health?.lastError ?? null;
const { data: version } = useQuery({
queryKey: ["backend-version", backend.host, backend.apiKey],
queryFn: async () => {
const info = await new ServerClient(
getAgentServerClientOptions({
host: backend.host,
sessionApiKey: backend.apiKey || null,
timeout: 5000,
}),
).getServerInfo();
return getDisplayAgentServerVersion(info);
},
retry: false,
staleTime: 60_000,
enabled: backend.kind === "local" && !disabled,
});
const statusLabel = getBackendStatusLabel(t, backend, health);
const kindLabel =
backend.kind === "cloud"
? t(I18nKey.BACKEND$KIND_CLOUD)
: t(I18nKey.BACKEND$KIND_LOCAL);
return (
<div className="flex flex-col gap-2">
<div
data-testid={`${testIdRoot}-status`}
className="flex items-center gap-3 text-sm"
>
<BackendStatusDot isConnected={isConnected} />
<span className="text-white" data-testid={`${testIdRoot}-status-label`}>
{statusLabel}
</span>
<span className="text-tertiary-alt">Β·</span>
<span className="text-[var(--oh-text-tertiary)]">{kindLabel}</span>
{version ? (
<span
className="text-xs text-[var(--oh-muted)]"
data-testid={`${testIdRoot}-version`}
>
{t(I18nKey.BACKEND$VERSION_LABEL, { version })}
</span>
) : null}
</div>
{disabled ? (
<div
data-testid={`${testIdRoot}-status-error`}
className="flex flex-col gap-1 rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm"
>
<span className="font-semibold text-red-300">
{t(I18nKey.BACKEND$HEALTH_FAILED_TITLE)}
</span>
<span className="text-xs text-[var(--oh-text-tertiary)]">
{t(I18nKey.BACKEND$HEALTH_FAILED_DETAIL, {
count: consecutiveFailures,
})}
</span>
{lastError ? (
<span
data-testid={`${testIdRoot}-status-error-message`}
className="text-xs text-red-300 whitespace-pre-wrap break-words"
>
{lastError}
</span>
) : null}
</div>
) : null}
</div>
);
}
export interface BackendFormSubmitPayload {
name: string;
host: string;
apiKey: string;
kind: BackendKind;
}
interface UseBackendFormOptions {
initialName?: string;
initialHost?: string;
initialApiKey?: string;
/**
* Called to test the connection. The hook does NOT call
* `testBackendConnection` directly so callers can inject a
* wrapped version (e.g. with extra logging or different timeout).
* Should throw on failure.
*/
onTestConnection: (
payload: BackendFormSubmitPayload,
) => Promise<BackendConnectionTestMetadata>;
/** Called after a successful connection test and persistence. */
onSuccess: (metadata: BackendConnectionTestMetadata) => void;
/** Require a non-empty API key even when the host looks local. */
requireApiKey?: boolean;
/**
* When provided, completely replaces the default submit flow
* (onTestConnection + onSuccess). The hook still manages form state
* and canSubmit validation, but the caller owns error handling and
* success side effects. Should throw on failure.
*/
onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise<void>;
/** Fix the persisted backend kind instead of inferring it from the host. */
fixedKind?: BackendKind;
}
/**
* Shared hook for the backend-form state used by both `BackendForm`
* (edit/add mode) and `ManualConnectionColumn` (add-mode-only column).
* Encapsulates name / host / apiKey fields, `connectionError`,
* `isSubmitting`, and the shared `handleSubmit` flow.
*/
function useBackendForm({
initialName = "",
initialHost = "",
initialApiKey = "",
onTestConnection,
onSuccess,
requireApiKey = false,
onSubmitOverride,
fixedKind,
}: UseBackendFormOptions) {
const { t } = useTranslation("openhands");
const [name, setName] = React.useState(initialName);
const [host, setHost] = React.useState(initialHost);
const [apiKey, setApiKey] = React.useState(initialApiKey);
const [connectionError, setConnectionError] = React.useState<string | null>(
null,
);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [kindOverride, setKindOverride] = React.useState<BackendKind | null>(
null,
);
// Kind follows host inference until the user explicitly picks a type, then
// respects that choice. A custom-domain OHE can't be distinguished from a
// custom-domain local agent-server by host alone, so ManualConnectionColumn
// exposes `setKind` (a Type selector) to let the user declare it.
const kind = fixedKind ?? kindOverride ?? inferKindFromHost(host);
const needsApiKey = requireApiKey || kind !== "local";
const canSubmit =
name.trim().length > 0 &&
isValidHostUrl(host) &&
(!needsApiKey || apiKey.trim().length > 0);
const handleSubmit = React.useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!canSubmit || isSubmitting) return;
const payload: BackendFormSubmitPayload = {
name: name.trim(),
host: normalizeHost(host),
apiKey: apiKey.trim(),
kind,
};
setConnectionError(null);
setIsSubmitting(true);
try {
// When onSubmitOverride is provided, it completely replaces the
// default flow (onTestConnection + onSuccess).
if (onSubmitOverride) {
await onSubmitOverride(payload);
} else {
const metadata = await onTestConnection(payload);
onSuccess(metadata);
}
} catch (error) {
setConnectionError(
getConnectionTestFailedMessage(
getConnectionTestFailedTitle(t, payload.host),
error,
),
);
} finally {
setIsSubmitting(false);
}
},
[
canSubmit,
isSubmitting,
name,
host,
apiKey,
kind,
onTestConnection,
onSuccess,
requireApiKey,
onSubmitOverride,
fixedKind,
t,
],
);
return {
name,
setName,
host,
setHost,
apiKey,
setApiKey,
connectionError,
setConnectionError,
isSubmitting,
kind,
setKind: setKindOverride,
canSubmit,
handleSubmit,
};
}
export interface BackendFormProps {
mode: BackendFormMode;
/** Required when `mode === "edit"`. */
backend?: Backend;
/**
* Called after the form is submitted and the backend has been
* persisted. Use this to dismiss a containing modal, advance an
* onboarding step, etc.
*/
onSubmitted: () => void;
/**
* Optional render slot rendered in place of the default
* Save / Cancel button row, so callers (e.g. the onboarding flow)
* can re-skin the action area while still owning submission via the
* standard `<form onSubmit>` flow. Receives the form's submit-ready
* state.
*/
renderActions?: (state: {
canSubmit: boolean;
isSubmitting: boolean;
testIdRoot: string;
}) => React.ReactNode;
/** Used to disambiguate test ids across the same screen. */
testIdRoot?: string;
/** When true, the host field is pre-filled and disabled. */
hostReadOnly?: boolean;
/**
* When true, a non-empty API key is required for submission regardless
* of the inferred backend kind. The standard add form allows empty
* keys for local backends; the auth-gate screen needs to enforce one.
*/
requireApiKey?: boolean;
/**
* When true, hides the name/host/API-key inputs (and related inline
* errors) while keeping the action row visible β€” used by onboarding
* after a successful connection probe.
*/
hideConfigurationFields?: boolean;
/**
* Replace the default synchronous add/update-and-close submit with a
* custom async handler. The form builds the payload, validates
* client-side, then hands it to this callback. If the callback throws,
* the form remains open so the caller can surface errors.
*/
onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise<void>;
}
/**
* Reusable form body for adding / editing a backend. Renders the
* common name / host / API-key inputs plus the kind selector
* (radio buttons in `add` mode, status badge in `edit` mode).
*
* Rendered as a `<form>`, so consumers should put any extra controls
* either inside `renderActions` or as siblings inside a wrapping
* element β€” but submission flows through the standard form submit so
* Enter-to-submit still works.
*/
export function BackendForm({
mode,
backend,
onSubmitted,
renderActions,
testIdRoot: explicitTestIdRoot,
hostReadOnly,
requireApiKey,
hideConfigurationFields = false,
onSubmitOverride,
}: BackendFormProps) {
const { t } = useTranslation("openhands");
const { addBackend, updateBackend } = useActiveBackendContext();
// In edit mode preserve the existing backend's kind so that renaming or
// rotating the API key on a cloud backend (e.g. an OHE/enterprise instance
// on a custom domain) does not silently downgrade it to "local" and switch
// the auth header from `Authorization: Bearer` to `X-Session-API-Key`.
// Only infer from the host when adding a new backend.
const fixedKind: BackendKind | null =
mode === "edit" && backend ? backend.kind : null;
const {
name,
setName,
host,
setHost,
apiKey,
setApiKey,
connectionError,
setConnectionError,
isSubmitting,
kind: inferredKind,
handleSubmit: runSubmit,
} = useBackendForm({
initialName: backend?.name ?? "",
initialHost: backend?.host ?? "",
initialApiKey: backend?.apiKey ?? "",
onTestConnection: testBackendConnection,
onSuccess: async () => {
const payload: BackendFormSubmitPayload = {
name: name.trim(),
host: normalizeHost(host),
apiKey: apiKey.trim(),
kind: fixedKind ?? inferredKind,
};
if (mode === "edit" && backend) {
updateBackend(backend.id, payload);
} else {
addBackend(payload);
}
onSubmitted();
},
requireApiKey,
onSubmitOverride,
});
// Inline validation: only show errors after the user has left a field.
const [nameTouched, setNameTouched] = React.useState(false);
const [hostTouched, setHostTouched] = React.useState(false);
const kind = fixedKind ?? inferredKind;
const testIdRoot =
explicitTestIdRoot ?? (mode === "edit" ? "edit-backend" : "add-backend");
const needsApiKey = requireApiKey || kind !== "local";
const canSubmit =
name.trim().length > 0 &&
isValidHostUrl(host) &&
(!needsApiKey || apiKey.trim().length > 0);
// Error messages β€” only surfaced after the user has blurred the field.
const nameError =
nameTouched && !name.trim() ? t(I18nKey.BACKEND$NAME_REQUIRED) : undefined;
const hostError = hostTouched
? !host.trim()
? t(I18nKey.BACKEND$HOST_REQUIRED)
: !isValidHostUrl(host)
? t(I18nKey.BACKEND$HOST_INVALID)
: undefined
: undefined;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
if (!canSubmit) {
// Mark all validated fields as touched so inline errors become visible
// (e.g. user pressed Enter before filling required fields).
setNameTouched(true);
setHostTouched(true);
return;
}
await runSubmit(event);
};
return (
<form
data-testid={`${testIdRoot}-form`}
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<div
data-testid={`${testIdRoot}-configuration-fields`}
className={cn(
"flex flex-col gap-4",
hideConfigurationFields && "hidden",
)}
>
<SettingsInput
testId={`${testIdRoot}-name`}
name={`${testIdRoot}-name`}
type="text"
label={t(I18nKey.BACKEND$NAME_LABEL)}
value={name}
onChange={(value) => {
setName(value);
setConnectionError(null);
}}
onBlur={() => setNameTouched(true)}
// eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy
placeholder="Production"
className="w-full"
showRequiredTag
error={nameError}
/>
<SettingsInput
testId={`${testIdRoot}-host`}
name={`${testIdRoot}-host`}
type="text"
label={t(I18nKey.BACKEND$HOST_LABEL)}
value={host}
onChange={
hostReadOnly
? undefined
: (value) => {
setHost(value);
setConnectionError(null);
}
}
onBlur={() => setHostTouched(true)}
placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}
className="w-full"
showRequiredTag
error={hostError}
isDisabled={hostReadOnly}
/>
<SettingsInput
testId={`${testIdRoot}-api-key`}
name={`${testIdRoot}-api-key`}
type="password"
label={t(I18nKey.BACKEND$KEY_LABEL)}
value={apiKey}
onChange={(value) => {
setApiKey(value);
setConnectionError(null);
}}
placeholder=""
className="w-full"
/>
{connectionError ? (
<div
role="alert"
data-testid={`${testIdRoot}-error`}
className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300 whitespace-pre-wrap break-words"
>
{connectionError}
</div>
) : null}
{mode === "edit" && backend && (
<BackendStatusBadge backend={backend} testIdRoot={testIdRoot} />
)}
</div>
{renderActions ? (
renderActions({
canSubmit: canSubmit && !isSubmitting,
isSubmitting,
testIdRoot,
})
) : (
<div className="flex justify-end gap-2 mt-2 w-full">
<BrandButton
type="button"
variant="secondary"
onClick={onSubmitted}
testId={`${testIdRoot}-cancel`}
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="submit"
variant="primary"
isDisabled={!canSubmit || isSubmitting}
testId={`${testIdRoot}-submit`}
>
{t(I18nKey.BACKEND$SAVE)}
</BrandButton>
</div>
)}
</form>
);
}
// ── Add-mode two-column layout ──────────────────────────────────────
/**
* @spec BM-002 β€” Adding a backend auto-switches the active selection to it
* (BM-001), so a backend-scoped detail page the user is viewing now belongs
* to the previous backend. Redirect to that section's list so they never see
* stale data, mirroring the switch-backend redirect in BackendSelector.
*/
function useRedirectAfterAddBackend() {
const { currentPath, navigate } = useNavigation();
return React.useCallback(() => {
if (/^\/automations\/[^/]+/.test(currentPath)) navigate("/automations");
else if (/^\/conversations\/[^/]+/.test(currentPath))
navigate("/conversations");
}, [currentPath, navigate]);
}
interface BackendConnectionOptionsProps {
onConnected: (
payload: BackendFormSubmitPayload,
connectionMethod: BackendConnectionMethod,
metadata?: BackendConnectionTestMetadata,
) => void;
testIdRoot?: string;
initialManualBackend?: Partial<
Pick<BackendFormSubmitPayload, "name" | "host" | "apiKey">
>;
requireManualApiKey?: boolean;
manualSubmitLabel?: React.ReactNode;
manualSubmittingLabel?: React.ReactNode;
manualSubmitTestId?: string;
analyticsSource?: CloudConnectionSource;
}
/**
* Manual agent-server connection plus OpenHands Cloud OAuth login.
* Used by both the Add Backend modal and the onboarding backend step so
* supported backend choices stay consistent across first-run and settings UI.
*/
export function BackendConnectionOptions({
onConnected,
testIdRoot = "add-backend",
initialManualBackend,
requireManualApiKey = false,
manualSubmitLabel,
manualSubmittingLabel,
manualSubmitTestId,
analyticsSource,
}: BackendConnectionOptionsProps) {
const { t } = useTranslation("openhands");
const lockedCloudHost = getLockedCloudHost();
if (lockedCloudHost) {
return (
<div
data-testid={`${testIdRoot}-connection-options`}
className="flex justify-center"
>
<div className="w-full">
<CloudLoginColumn
onConnected={onConnected}
testIdRoot={testIdRoot}
lockedHost={lockedCloudHost}
analyticsSource={analyticsSource}
/>
</div>
</div>
);
}
return (
<div
data-testid={`${testIdRoot}-connection-options`}
className="flex flex-col gap-6 md:flex-row"
>
<div className="flex-1 min-w-0">
<ManualConnectionColumn
onConnected={onConnected}
testIdRoot={testIdRoot}
initialBackend={initialManualBackend}
requireApiKey={requireManualApiKey}
submitLabel={manualSubmitLabel ?? t(I18nKey.BACKEND$CONNECT)}
submittingLabel={
manualSubmittingLabel ??
t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING)
}
submitTestId={manualSubmitTestId}
/>
</div>
<div className="flex-1 min-w-0">
<CloudLoginColumn
onConnected={onConnected}
testIdRoot={testIdRoot}
analyticsSource={analyticsSource}
/>
</div>
</div>
);
}
interface ManualConnectionColumnProps {
onConnected: (
payload: BackendFormSubmitPayload,
connectionMethod: BackendConnectionMethod,
metadata?: BackendConnectionTestMetadata,
) => void;
testIdRoot: string;
initialBackend?: Partial<
Pick<BackendFormSubmitPayload, "name" | "host" | "apiKey">
>;
requireApiKey: boolean;
submitLabel: React.ReactNode;
submittingLabel: React.ReactNode;
submitTestId?: string;
fixedKind?: BackendKind;
showKindSelector?: boolean;
}
/**
* Manual connection via Host + API Key. Designed for self-hosted agent servers
* and self-hosted OpenHands Cloud with API key auth.
*/
function ManualConnectionColumn({
onConnected,
testIdRoot,
initialBackend,
requireApiKey,
submitLabel,
submittingLabel,
submitTestId,
fixedKind,
showKindSelector = true,
}: ManualConnectionColumnProps) {
const { t } = useTranslation("openhands");
const {
name,
setName,
host,
setHost,
apiKey,
setApiKey,
connectionError,
setConnectionError,
isSubmitting,
kind,
setKind,
canSubmit,
handleSubmit,
} = useBackendForm({
initialName: initialBackend?.name ?? "",
initialHost: initialBackend?.host ?? "",
initialApiKey: initialBackend?.apiKey ?? "",
onTestConnection: testBackendConnection,
onSuccess: (metadata) => {
onConnected(
{
name: name.trim(),
host: normalizeHost(host),
apiKey: apiKey.trim(),
kind,
},
"manual",
metadata,
);
},
requireApiKey,
fixedKind,
});
return (
<form
data-testid={`${testIdRoot}-form`}
onSubmit={handleSubmit}
className="flex flex-col gap-4 flex-1 min-w-0"
>
<SettingsInput
testId={`${testIdRoot}-name`}
name={`${testIdRoot}-name`}
type="text"
label={t(I18nKey.BACKEND$NAME_LABEL)}
hint={t(I18nKey.BACKEND$NAME_HELPER)}
value={name}
onChange={(value) => {
setName(value);
setConnectionError(null);
}}
// eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy
placeholder="e.g. My Server"
className="w-full"
/>
<SettingsInput
testId={`${testIdRoot}-host`}
name={`${testIdRoot}-host`}
type="text"
label={t(I18nKey.BACKEND$HOST_LABEL)}
hint={t(I18nKey.BACKEND$HOST_HELPER)}
value={host}
onChange={(value) => {
setHost(value);
setConnectionError(null);
}}
// eslint-disable-next-line i18next/no-literal-string -- example value, not translatable
placeholder="http://localhost:8000"
className="w-full"
/>
{showKindSelector ? (
<div className="flex flex-col items-start gap-2.5">
<span className="text-sm">{t(I18nKey.BACKEND$KIND_LABEL)}</span>
<SegmentedToggle<BackendKind>
value={kind}
options={[
{ value: "local", label: t(I18nKey.BACKEND$KIND_LOCAL) },
{ value: "cloud", label: t(I18nKey.BACKEND$KIND_CLOUD) },
]}
onChange={(value) => setKind(value)}
ariaLabel={t(I18nKey.BACKEND$KIND_LABEL)}
testId={`${testIdRoot}-kind`}
/>
</div>
) : null}
<SettingsInput
testId={`${testIdRoot}-api-key`}
name={`${testIdRoot}-api-key`}
type="password"
label={t(I18nKey.BACKEND$KEY_LABEL)}
value={apiKey}
onChange={(value) => {
setApiKey(value);
setConnectionError(null);
}}
// eslint-disable-next-line i18next/no-literal-string -- example value, not translatable
placeholder="sk-β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’"
className="w-full"
/>
{connectionError ? (
<div
role="alert"
data-testid={`${testIdRoot}-error`}
className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300 whitespace-pre-wrap break-words"
>
{connectionError}
</div>
) : null}
<BrandButton
type="submit"
variant="secondary"
isDisabled={!canSubmit || isSubmitting}
testId={submitTestId ?? `${testIdRoot}-submit`}
className="w-full text-center"
>
{isSubmitting ? submittingLabel : submitLabel}
</BrandButton>
</form>
);
}
interface CloudLoginColumnProps {
onConnected: (
payload: BackendFormSubmitPayload,
connectionMethod: BackendConnectionMethod,
metadata?: BackendConnectionTestMetadata,
) => void;
testIdRoot: string;
lockedHost?: string;
analyticsSource?: CloudConnectionSource;
/** Omit repeated branding when a surrounding chooser already names Cloud. */
showBranding?: boolean;
}
/**
* One-click OAuth login with OpenHands Cloud. Includes an "Advanced"
* disclosure for users who self-host OpenHands Cloud and need to override the
* host.
*/
function CloudLoginColumn({
onConnected,
testIdRoot,
lockedHost,
analyticsSource,
showBranding = true,
}: CloudLoginColumnProps) {
const { t } = useTranslation("openhands");
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [customHost, setCustomHost] = React.useState("");
const advancedPanelId = `${testIdRoot}-advanced-panel`;
const effectiveHost =
lockedHost ?? (customHost.trim() || DEFAULT_OPENHANDS_CLOUD_HOST);
const handleLoginSuccess = (apiKey: string) => {
onConnected(
{
name: "OpenHands Cloud",
host: normalizeHost(effectiveHost),
apiKey,
kind: "cloud",
},
"cloud_login",
);
};
return (
<div className="flex w-full min-w-0 flex-col items-center gap-3">
{showBranding ? (
<div className="flex flex-col items-center gap-1">
<OpenHandsLogoWhite width={56} height={56} aria-hidden />
<h4
className={modalTitleLgMediumClassName}
data-testid={`${testIdRoot}-cloud-title`}
>
{t(I18nKey.BACKEND$CLOUD_TITLE)}
</h4>
</div>
) : null}
<DeviceFlowAuth
host={effectiveHost}
onSuccess={handleLoginSuccess}
testIdRoot={testIdRoot}
analyticsSource={analyticsSource}
className="w-full items-center"
idleDescription={
<p
className="text-center text-sm leading-relaxed text-[var(--oh-muted)]"
data-testid={`${testIdRoot}-cloud-description`}
>
{t(I18nKey.BACKEND$CLOUD_DESCRIPTION)}
</p>
}
// Host overriding only matters before the flow starts, so it rides the
// same idle-only slot as the description instead of sitting under the
// authorization status.
idleFooter={
lockedHost ? null : (
// Keep the Advanced disclosure at the original compact width even
// though the surrounding cloud panel spans the full chooser.
<div className="mx-auto w-full max-w-md">
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
aria-expanded={advancedOpen}
aria-controls={advancedPanelId}
data-testid={`${testIdRoot}-advanced-toggle`}
className="flex w-full cursor-pointer items-center justify-center gap-1 text-center text-xs text-[var(--oh-muted)] transition-colors hover:text-content-2"
>
<span>{t(I18nKey.BACKEND$ADVANCED)}</span>
<ChevronDownSmallIcon
className={cn(
"h-4 w-4 shrink-0 text-muted transition-transform duration-200 ease-out",
advancedOpen && "rotate-180",
)}
aria-hidden
/>
</button>
{/* Height animates through `grid-template-rows` (0fr ↔ 1fr) so no
max-height guess is needed; the inner clip is what makes `0fr`
collapse. Content stays mounted so a typed host survives, and
`inert` keeps the collapsed field out of the tab order. */}
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
advancedOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div
id={advancedPanelId}
data-testid={`${testIdRoot}-advanced-panel`}
aria-hidden={!advancedOpen}
inert={!advancedOpen ? true : undefined}
className={cn(
"pt-3 transition-opacity duration-200 ease-out motion-reduce:transition-none",
advancedOpen ? "opacity-100" : "opacity-0",
)}
>
<SettingsInput
testId={`${testIdRoot}-cloud-host`}
name={`${testIdRoot}-cloud-host`}
type="text"
label={t(I18nKey.BACKEND$HOST_LABEL)}
value={customHost}
onChange={setCustomHost}
placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}
className="w-full"
/>
<p className="mt-1 text-xs text-[var(--oh-muted)]">
{t(I18nKey.BACKEND$LOGIN_CLOUD_HINT)}
</p>
</div>
</div>
</div>
</div>
)
}
/>
</div>
);
}
interface BackendOptionTabProps {
value: AddBackendOption;
selectedValue: AddBackendOption;
title: string;
description: string;
icon: React.ReactNode;
onSelect: (value: AddBackendOption) => void;
panelId: string;
testId: string;
}
/**
* Presents a connection-method tab: icon, title, and a one-line subtitle.
*
* The shared tablist owns the outer border, so these buttons meet cleanly at
* the center. Selection uses a bottom bar instead of recoloring the full
* outline, preserving the group as one visual control.
*/
function BackendOptionTab({
value,
selectedValue,
title,
description,
icon,
onSelect,
panelId,
testId,
}: BackendOptionTabProps) {
const isSelected = value === selectedValue;
const tabId = `${testId}-tab`;
return (
<button
id={tabId}
type="button"
role="tab"
aria-selected={isSelected}
aria-controls={panelId}
data-testid={testId}
onClick={() => onSelect(value)}
className={cn(
"relative flex min-h-16 w-full cursor-pointer items-center gap-3 px-3 py-3 text-left transition-colors",
"first:border-r first:border-r-[var(--oh-border)]",
"focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-300",
isSelected
? "bg-[var(--oh-surface-raised)] text-white after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-primary"
: "text-[var(--oh-muted)] hover:bg-[var(--oh-surface-raised)] hover:text-white",
)}
>
<span
className="flex size-8 shrink-0 items-center justify-center"
aria-hidden
>
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title}</span>
<span className="mt-0.5 block text-xs leading-tight text-[var(--oh-muted)]">
{description}
</span>
</span>
</button>
);
}
interface AnimatedPanelHeightProps {
children: React.ReactNode;
}
/**
* Animates a panel between content-driven heights.
*
* A ResizeObserver keeps the wrapper synchronized as tabs or nested
* disclosures change. The transition class remains mounted before the
* observer publishes a new height; adding it in the same render as the height
* would give the browser no previous painted value to interpolate from.
*/
function AnimatedPanelHeight({ children }: AnimatedPanelHeightProps) {
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number>();
React.useLayoutEffect(() => {
const content = contentRef.current;
if (!content) return undefined;
const measure = () => {
const nextHeight = content.getBoundingClientRect().height;
// jsdom reports zero-sized layout boxes; leaving height automatic there
// keeps component tests representative without changing browser behavior.
setHeight(nextHeight > 0 ? nextHeight : undefined);
};
measure();
if (typeof ResizeObserver === "undefined") return undefined;
const observer = new ResizeObserver(measure);
observer.observe(content);
return () => observer.disconnect();
}, []);
return (
<div
data-testid="add-backend-panel-height"
style={height === undefined ? undefined : { height }}
className="overflow-hidden transition-[height] duration-300 ease-in-out motion-reduce:transition-none"
>
<div ref={contentRef}>{children}</div>
</div>
);
}
/**
* Collects every setup instruction for the selected location into one
* collapsible note.
*
* All of the guidance lives here β€” the intro, the example command (local) or
* the host formats to use with a tunnel (remote), and the docs link β€” so users
* never have to leave the modal to find out what belongs in the host field.
* It starts collapsed so the connection form remains compact; users can
* reveal the complete instructions without leaving the modal.
*
* Open/close uses a `grid-template-rows` transition (`0fr` ↔ `1fr`) so the
* height animates without a fixed `max-height` guess. Content stays mounted
* so the toggle does not remount the docs link on every expand.
*/
function AgentServerGuidance({ location }: { location: AgentServerLocation }) {
const { t } = useTranslation("openhands");
const [isExpanded, setIsExpanded] = React.useState(false);
const isRemote = location === "remote";
const title = isRemote
? t(I18nKey.BACKEND$REMOTE_SETUP_TITLE)
: t(I18nKey.BACKEND$BEFORE_CONNECT_TITLE);
const description = isRemote
? t(I18nKey.BACKEND$REMOTE_SETUP_DESCRIPTION)
: t(I18nKey.BACKEND$LOCAL_SETUP_DESCRIPTION);
const docsHref = isRemote
? REMOTE_AGENT_SERVER_DOCS_URL
: LOCAL_AGENT_SERVER_DOCS_URL;
const docsLabel = isRemote
? t(I18nKey.BACKEND$REMOTE_SETUP_DOCS)
: t(I18nKey.BACKEND$LOCAL_SETUP_DOCS);
const testIdRoot = isRemote ? "add-backend-remote" : "add-backend-local";
const toggleId = `${testIdRoot}-guidance-toggle`;
const bodyId = `${testIdRoot}-guidance-body`;
return (
<aside
data-testid={`${testIdRoot}-guidance`}
className="rounded-lg bg-[var(--oh-surface-raised)] text-sm text-[var(--oh-muted)]"
>
{/* Heading wraps the button so the accordion keeps a real heading in the
document outline while the whole row stays clickable. */}
<h4 className="text-white">
<button
id={toggleId}
type="button"
onClick={() => setIsExpanded((expanded) => !expanded)}
aria-expanded={isExpanded}
aria-controls={bodyId}
data-testid={toggleId}
className={cn(
// Padding lives on the button rather than the card so the hover
// fill spans the full row.
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-2 text-left font-medium",
"transition-colors hover:bg-[var(--oh-interactive-hover)]",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-300",
)}
>
<span className="flex min-w-0 items-center gap-2">
<Info
className="size-4 shrink-0 text-[var(--oh-muted)]"
aria-hidden
/>
<span className="truncate">{title}</span>
</span>
<ChevronDown
className={cn(
"size-5 shrink-0 text-[var(--oh-muted)] transition-transform duration-200 ease-out",
isExpanded && "rotate-180",
)}
aria-hidden
/>
</button>
</h4>
{/* Outer grid owns the height animation; the inner overflow clip is
required so `1fr` measures the content while `0fr` fully collapses. */}
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div
id={bodyId}
data-testid={bodyId}
role="region"
aria-labelledby={toggleId}
aria-hidden={!isExpanded}
inert={!isExpanded ? true : undefined}
className={cn(
"flex flex-col gap-2 px-3 pb-3 transition-opacity duration-200 ease-out motion-reduce:transition-none",
isExpanded ? "opacity-100" : "opacity-0",
)}
>
<p className="leading-5">{description}</p>
{isRemote ? (
<div>
<h5 className="font-medium text-white">
{t(I18nKey.BACKEND$REMOTE_CONNECTION_TITLE)}
</h5>
<p className="mt-1 leading-5">
{t(I18nKey.BACKEND$REMOTE_CONNECTION_DESCRIPTION)}
</p>
</div>
) : (
<code className="block break-words font-mono text-xs text-white">
{LOCAL_BACKEND_COMMAND}
</code>
)}
<a
href={docsHref}
target="_blank"
rel="noopener noreferrer"
data-testid={`${testIdRoot}-docs-link`}
tabIndex={isExpanded ? undefined : -1}
className="inline-flex w-fit items-center gap-1.5 text-primary hover:underline"
>
<span>{docsLabel}</span>
<ExternalLinkIcon className="size-4 shrink-0" aria-hidden />
</a>
</div>
</div>
</div>
</aside>
);
}
function AddBackendChooser({
onConnected,
source,
}: {
onConnected: (
payload: BackendFormSubmitPayload,
connectionMethod: BackendConnectionMethod,
metadata?: BackendConnectionTestMetadata,
) => void;
source: BackendAddedSource;
}) {
const { t } = useTranslation("openhands");
const [selectedOption, setSelectedOption] =
React.useState<AddBackendOption>("cloud");
const [agentServerLocation, setAgentServerLocation] =
React.useState<AgentServerLocation>("local");
const panelId = "add-backend-selected-panel";
const selectedTabId = `add-backend-option-${selectedOption}-tab`;
const isCloudSelected = selectedOption === "cloud";
return (
<div data-testid="add-backend-chooser" className="flex flex-col">
<div
role="tablist"
aria-label={t(I18nKey.BACKEND$CHOOSER_TITLE)}
className="grid grid-cols-2 overflow-hidden rounded-lg border border-[var(--oh-border)]"
>
<BackendOptionTab
value="cloud"
selectedValue={selectedOption}
title={t(I18nKey.BACKEND$CLOUD_TITLE)}
description={t(I18nKey.BACKEND$CLOUD_OPTION_DESCRIPTION)}
icon={
<OpenHandsLogoWhite
width={32}
height={32}
data-testid="add-backend-option-cloud-logo"
/>
}
onSelect={setSelectedOption}
panelId={panelId}
testId="add-backend-option-cloud"
/>
<BackendOptionTab
value="agent-server"
selectedValue={selectedOption}
title={t(I18nKey.BACKEND$AGENT_SERVER_TITLE)}
description={t(I18nKey.BACKEND$AGENT_SERVER_OPTION_DESCRIPTION)}
icon={<ServerIcon className="size-6" />}
onSelect={setSelectedOption}
panelId={panelId}
testId="add-backend-option-agent-server"
/>
</div>
<div className="mt-6">
<AnimatedPanelHeight>
<section
id={panelId}
role="tabpanel"
aria-labelledby={selectedTabId}
className="min-w-0"
>
{isCloudSelected ? (
/* Padding keeps the CTA and Advanced host field from hugging the
border; when Advanced expands the panel grows with the content
instead of squeezing it into the reserved min-height. */
<div
data-testid="add-backend-cloud-panel"
className={cn(
"relative isolate flex min-h-[13.5rem] w-full items-center justify-center rounded-xl border border-[var(--oh-border)] px-5 py-6",
// A soft greyscale halo sits behind the CTA; -z-10 keeps it under the
// content rather than washing over it.
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-xl",
"before:bg-[radial-gradient(75%_75%_at_50%_50%,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0)_70%)]",
)}
>
<CloudLoginColumn
onConnected={onConnected}
testIdRoot="add-backend"
analyticsSource={source}
showBranding={false}
/>
</div>
) : (
<div
data-testid="add-backend-agent-server-panel"
className="mx-auto w-full max-w-xl"
>
{/* Rules on either side center the toggle and read as a
divider between the chooser and the connection form. */}
<div className="flex items-center gap-3">
<span
className="h-px flex-1 bg-[var(--oh-border)]"
aria-hidden
/>
<SegmentedToggle<AgentServerLocation>
value={agentServerLocation}
options={[
{
value: "local",
label: t(I18nKey.BACKEND$KIND_LOCAL),
icon: <Monitor aria-hidden />,
},
{
value: "remote",
label: t(I18nKey.BACKEND$KIND_REMOTE),
icon: <Globe aria-hidden />,
},
]}
onChange={setAgentServerLocation}
ariaLabel={t(I18nKey.BACKEND$AGENT_SERVER_LOCATION)}
testId="add-backend-location"
/>
<span
className="h-px flex-1 bg-[var(--oh-border)]"
aria-hidden
/>
</div>
<div className="mt-4 flex flex-col gap-4">
<AgentServerGuidance
key={agentServerLocation}
location={agentServerLocation}
/>
<ManualConnectionColumn
onConnected={onConnected}
testIdRoot="add-backend"
requireApiKey={agentServerLocation === "remote"}
submitLabel={t(I18nKey.BACKEND$CONNECT)}
submittingLabel={t(
I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING,
)}
fixedKind="local"
showKindSelector={false}
/>
</div>
</div>
)}
</section>
</AnimatedPanelHeight>
</div>
</div>
);
}
function AddBackendConnectionOptions({
onClose,
source,
}: {
onClose: () => void;
source: BackendAddedSource;
}) {
const { addBackend } = useActiveBackendContext();
const redirectAfterAdd = useRedirectAfterAddBackend();
const { trackBackendAdded } = useTracking();
const lockedCloudHost = getLockedCloudHost();
const handleConnected = React.useCallback(
(
payload: BackendFormSubmitPayload,
connectionMethod: BackendConnectionMethod,
metadata?: BackendConnectionTestMetadata,
) => {
addBackend(payload);
trackBackendAdded({
backendKind: payload.kind,
connectionMethod,
hasApiKey: Boolean(payload.apiKey),
source,
agentServerVersion: metadata?.agentServerVersion,
});
redirectAfterAdd();
onClose();
},
[addBackend, redirectAfterAdd, onClose, trackBackendAdded, source],
);
if (lockedCloudHost) {
return (
<CloudLoginColumn
onConnected={handleConnected}
testIdRoot="add-backend"
lockedHost={lockedCloudHost}
analyticsSource={source}
/>
);
}
return <AddBackendChooser onConnected={handleConnected} source={source} />;
}
// ── Modal wrappers ──────────────────────────────────────────────────
/**
* Modal wrapper. In **add** mode it renders a two-column layout
* (manual connection | OR | Cloud login). In **edit** mode it wraps
* the standard `BackendForm`.
*/
export function BackendFormModal({
mode,
backend,
onClose,
source = "add_backend_modal",
hideCloseButton = false,
}: BackendFormModalProps) {
const { t } = useTranslation("openhands");
if (mode === "add") {
return (
<ModalBackdrop
onClose={hideCloseButton ? undefined : onClose}
closeOnEscape={false}
closeOnBackdropClick={!hideCloseButton}
aria-label={t(I18nKey.BACKEND$ADD_TITLE)}
>
<div
data-testid={
hideCloseButton ? "onboarding-modal" : "add-backend-modal"
}
className={cn(
"relative max-h-[92vh] w-[720px] overflow-y-auto rounded-xl border border-[var(--oh-border)] bg-base-secondary p-6",
MODAL_MAX_WIDTH_VIEWPORT,
)}
>
{hideCloseButton ? null : (
<ModalCloseButton onClose={onClose} testId="add-backend-close" />
)}
{hideCloseButton ? null : (
<div className="pr-8">
<h2 className={modalTitleLgClassName}>
{t(I18nKey.BACKEND$CHOOSER_TITLE)}
</h2>
<p
className="mt-2 text-sm leading-6 text-[var(--oh-muted)]"
data-testid="add-backend-description"
>
{t(I18nKey.BACKEND$CHOOSER_DESCRIPTION)}{" "}
<a
href={DEPLOYMENT_OPTIONS_URL}
target="_blank"
rel="noopener noreferrer"
aria-label={t(I18nKey.BACKEND$DEPLOYMENT_OPTIONS)}
data-testid="add-backend-deployment-options-link"
className="text-primary hover:underline"
>
{t(I18nKey.CTA$LEARN_MORE)}
<ExternalLinkIcon
className="ml-1 inline size-3.5 align-[-0.125em]"
aria-hidden
/>
</a>
</p>
</div>
)}
<div className={hideCloseButton ? undefined : "mt-5"}>
<AddBackendConnectionOptions onClose={onClose} source={source} />
</div>
</div>
</ModalBackdrop>
);
}
// Edit mode β€” single-column form (unchanged)
const testIdRoot = "edit-backend";
return (
<ModalBackdrop
onClose={onClose}
closeOnEscape={false}
aria-label={t(I18nKey.BACKEND$EDIT_TITLE)}
>
<div
data-testid={`${testIdRoot}-modal`}
className={cn(
"relative bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-[var(--oh-border)]",
modalWidthClassName("md"),
)}
>
<ModalCloseButton onClose={onClose} testId={`${testIdRoot}-close`} />
<h2 className={cn("pr-6", modalTitleLgClassName)}>
{t(I18nKey.BACKEND$EDIT_TITLE)}
</h2>
<BackendForm
mode="edit"
backend={backend}
onSubmitted={onClose}
testIdRoot={testIdRoot}
/>
</div>
</ModalBackdrop>
);
}