import React from "react"; import { useTranslation } from "react-i18next"; import { Plus } from "lucide-react"; import { getLockedCloudHost } from "#/api/agent-server-config"; import { type Backend } from "#/api/backend-registry/types"; import { BrandButton } from "#/components/features/settings/brand-button"; import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; 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 { useActiveBackendContext } from "#/contexts/active-backend-context"; import { useBackendsHealth } from "#/hooks/query/use-backends-health"; import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations"; import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id"; import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; import { modalTitleLgClassName } from "#/utils/modal-classes"; import { BackendFormModal } from "./backend-form-modal"; import { BackendRow } from "./backend-row"; import { DeviceFlowAuth } from "./device-flow-auth"; interface ManageBackendsModalProps { onClose: () => void; /** * Recovery mode is used by the root unavailable-backend gate. There is no * app shell behind the modal, so dismiss controls would be misleading. */ recoveryMode?: boolean; } interface PendingRemoval { id: string; name: string; } /** * Resolve the connected organization label for a backend row, mirroring the * per-org labelling used by the backend selector. Cloud API keys are bound to * a single org (legacy keys may expose several, joined here); local backends * and unresolved/errored lookups yield no label. */ function resolveBackendOrgLabel( backend: Backend, cloudOrgs: ReturnType, currentUserIds: ReturnType, personalWorkspaceLabel: string, ): string | undefined { if (backend.kind !== "cloud") return undefined; const entry = cloudOrgs[backend.id]; if (!entry || entry.orgs.length === 0) return undefined; const userId = currentUserIds[backend.id]?.userId ?? null; return entry.orgs .map((org) => !!userId && userId === org.id ? personalWorkspaceLabel : org.name, ) .join(", "); } export function ManageBackendsModal({ onClose, recoveryMode = false, }: ManageBackendsModalProps) { const { t } = useTranslation("openhands"); const { backends, active, removeBackend, setActive, updateBackend } = useActiveBackendContext(); const healthByBackendId = useBackendsHealth(backends, { probeDisabledOnce: true, }); const cloudOrgs = useAllCloudOrganizations(); const currentUserIds = useCloudCurrentUserId(); const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE); const lockedCloudHost = getLockedCloudHost(); const isLockedToCloud = lockedCloudHost !== null; // Cookie-auth (OHE-hosted) backends have no API key to refresh — re-auth is // the main-app login, so the device-flow reconnect controls don't apply. const isCookieAuthBackend = active.backend.authMode === "cookie"; const lockedCloudBackend = isLockedToCloud && !isCookieAuthBackend && active.backend.kind === "cloud" ? active.backend : null; const lockedCloudReconnectHost = lockedCloudHost ?? lockedCloudBackend?.host ?? ""; const modalTitle = isLockedToCloud && !isCookieAuthBackend ? t(I18nKey.BACKEND$RECONNECT_CLOUD_TITLE) : t(I18nKey.BACKEND$MANAGE_TITLE); const [pendingRemoval, setPendingRemoval] = React.useState(null); const [editingBackend, setEditingBackend] = React.useState( null, ); const [showAddForm, setShowAddForm] = React.useState(false); const handleConfirmRemoval = () => { if (!pendingRemoval) return; removeBackend(pendingRemoval.id); setPendingRemoval(null); }; const handleSelectBackend = React.useCallback( (backend: Backend) => { if (active.backend.id !== backend.id || active.orgId !== null) { setActive(backend.id); } onClose(); }, [active.backend.id, active.orgId, onClose, setActive], ); const handleCloudLogin = React.useCallback( (backend: Backend, apiKey: string) => { updateBackend(backend.id, { apiKey }); }, [updateBackend], ); const handleLockedCloudReconnect = React.useCallback( (apiKey: string) => { if (!lockedCloudBackend) return; updateBackend(lockedCloudBackend.id, { apiKey }); }, [lockedCloudBackend, updateBackend], ); return ( <>
{recoveryMode ? null : ( )}

{modalTitle}

{backends.length === 0 ? (

{t(I18nKey.BACKEND$MANAGE_EMPTY)}

) : (
    {backends.map((backend) => ( handleSelectBackend(backend)} onEdit={() => setEditingBackend(backend)} onRemove={() => setPendingRemoval({ id: backend.id, name: backend.name, }) } onLogin={ backend.authMode === "cookie" ? undefined : (apiKey) => handleCloudLogin(backend, apiKey) } /> ))}
)}
{isLockedToCloud ? ( lockedCloudBackend ? ( ) : null ) : ( setShowAddForm(true)} testId="manage-backends-add" startContent={} > {t(I18nKey.BACKEND$ADD)} )} {recoveryMode ? null : ( {t(I18nKey.HOME$DONE)} )}
{showAddForm ? ( setShowAddForm(false)} /> ) : null} {editingBackend ? ( setEditingBackend(null)} /> ) : null} {pendingRemoval ? ( setPendingRemoval(null)} /> ) : null} ); }