- {deviceFlow.status === "idle" && (
+ {deviceFlow.status === "idle" && buttonVariant === "unstyled" && (
+
+ )}
+
+ {deviceFlow.status === "idle" && buttonVariant !== "unstyled" && (
- {t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
+ {idleButtonContent ?? idleLabel}
)}
- {deviceFlow.status === "starting" && (
-
+
+ {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_STARTING)}
+
+ {t(I18nKey.BACKEND$AUTH_AWAITING)}
- )}
-
- {deviceFlow.status === "awaiting_authorization" && (
-
-
-
-
- {t(I18nKey.BACKEND$AUTH_AWAITING)}
-
+
+ {t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
+
+ {validVerificationUrl ? (
+
-
- {t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
-
- {deviceFlow.verificationUrl &&
- isValidVerificationUrl(deviceFlow.verificationUrl) && (
-
- )}
-
- {t(I18nKey.BACKEND$AUTH_CANCEL)}
-
-
- )}
-
- {deviceFlow.status === "error" && (
-
-
{deviceFlow.error}
-
- {t(I18nKey.BACKEND$AUTH_RETRY)}
-
-
- )}
+ {t(I18nKey.BACKEND$AUTH_CANCEL)}
+
+
+ );
+ }
+
+ return (
+
+
{error}
+
+ {t(I18nKey.BACKEND$AUTH_RETRY)}
+
);
}
diff --git a/src/components/features/backends/manage-backends-modal.tsx b/src/components/features/backends/manage-backends-modal.tsx
index 52eeabfe4d..34965a418b 100644
--- a/src/components/features/backends/manage-backends-modal.tsx
+++ b/src/components/features/backends/manage-backends-modal.tsx
@@ -38,7 +38,7 @@ export function ManageBackendsModal({
recoveryMode = false,
}: ManageBackendsModalProps) {
const { t } = useTranslation("openhands");
- const { backends, active, removeBackend, setActive } =
+ const { backends, active, removeBackend, setActive, updateBackend } =
useActiveBackendContext();
const healthByBackendId = useBackendsHealth(backends, {
probeDisabledOnce: true,
@@ -66,6 +66,13 @@ export function ManageBackendsModal({
[active.backend.id, active.orgId, onClose, setActive],
);
+ const handleCloudLogin = React.useCallback(
+ (backend: Backend, apiKey: string) => {
+ updateBackend(backend.id, { apiKey });
+ },
+ [updateBackend],
+ );
+
return (
<>
handleCloudLogin(backend, apiKey)}
/>
))}
diff --git a/src/hooks/query/use-backends-health.ts b/src/hooks/query/use-backends-health.ts
index 4164340754..69cfb32e23 100644
--- a/src/hooks/query/use-backends-health.ts
+++ b/src/hooks/query/use-backends-health.ts
@@ -1,4 +1,5 @@
import React from "react";
+import axios from "axios";
import { useQueries } from "@tanstack/react-query";
import {
ServerClient,
@@ -22,6 +23,7 @@ import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage"
const REFRESH_INTERVAL_MS = 10000;
const PROBE_TIMEOUT_MS = 4000;
export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";
+export const CLOUD_BACKEND_LOGGED_OUT_ERROR = "Logged out";
export function isInvalidBackendApiKeyHealthError(
error: string | null | undefined,
@@ -29,6 +31,12 @@ export function isInvalidBackendApiKeyHealthError(
return error === INVALID_BACKEND_API_KEY_ERROR;
}
+export function isCloudBackendLoggedOutHealthError(
+ error: string | null | undefined,
+): boolean {
+ return error === CLOUD_BACKEND_LOGGED_OUT_ERROR;
+}
+
/**
* Probe a single backend for connectivity. The probe path differs by
* backend kind:
@@ -48,7 +56,14 @@ export function isInvalidBackendApiKeyHealthError(
*/
async function probeBackend(backend: Backend): Promise {
if (backend.kind === "cloud") {
- await getCurrentCloudApiKey(backend);
+ try {
+ await getCurrentCloudApiKey(backend);
+ } catch (error) {
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
+ throw new Error(CLOUD_BACKEND_LOGGED_OUT_ERROR);
+ }
+ throw error;
+ }
return true;
}
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 2c2a553542..4984760497 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -27335,6 +27335,40 @@
"uk": "Увійти через OpenHands Cloud",
"ca": "Inicia sessió amb OpenHands Cloud"
},
+ "BACKEND$LOG_BACK_IN": {
+ "en": "Log back in",
+ "ar": "تسجيل الدخول مجددًا",
+ "ca": "Torna a iniciar sessió",
+ "de": "Erneut anmelden",
+ "es": "Volver a iniciar sesión",
+ "fr": "Se reconnecter",
+ "it": "Accedi di nuovo",
+ "ja": "再ログイン",
+ "ko-KR": "다시 로그인",
+ "no": "Logg inn igjen",
+ "pt": "Entrar novamente",
+ "tr": "Tekrar giriş yap",
+ "uk": "Увійти знову",
+ "zh-CN": "重新登录",
+ "zh-TW": "重新登入"
+ },
+ "BACKEND$LOGGED_OUT": {
+ "en": "Logged out",
+ "ar": "تم تسجيل الخروج",
+ "ca": "Sessió tancada",
+ "de": "Abgemeldet",
+ "es": "Sesión cerrada",
+ "fr": "Déconnecté",
+ "it": "Disconnesso",
+ "ja": "ログアウト済み",
+ "ko-KR": "로그아웃됨",
+ "no": "Logget ut",
+ "pt": "Desconectado",
+ "tr": "Oturum kapatıldı",
+ "uk": "Вийшли з системи",
+ "zh-CN": "已退出登录",
+ "zh-TW": "已登出"
+ },
"BACKEND$LOGIN_CLOUD_HINT": {
"en": "Works with OpenHands Cloud or your self-hosted OpenHands Cloud deployment.",
"ja": "OpenHands Cloud またはセルフホスト型 OpenHands Cloud デプロイメントで使用できます。",
diff --git a/src/query-client-config.ts b/src/query-client-config.ts
index a4d832479e..c4d28ea881 100644
--- a/src/query-client-config.ts
+++ b/src/query-client-config.ts
@@ -4,6 +4,7 @@ import i18n from "#/i18n";
import { I18nKey } from "./i18n/declaration";
import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message";
import { displayErrorToast } from "./utils/custom-toast-handlers";
+import { getActiveBackend } from "#/api/backend-registry/active-store";
import { recordBackendSuccess } from "#/api/backend-registry/health-store";
const handle401Error = (error: AxiosError, client: QueryClient) => {
@@ -12,6 +13,19 @@ const handle401Error = (error: AxiosError, client: QueryClient) => {
}
};
+const isActiveCloudBackendAuthError = (error: unknown) => {
+ if (!(error instanceof AxiosError)) return false;
+ if (error.response?.status !== 401 && error.status !== 401) return false;
+
+ const activeBackend = getActiveBackend().backend;
+ if (activeBackend.kind !== "cloud") return false;
+
+ const requestUrl = error.config?.url;
+ return (
+ !requestUrl || requestUrl.startsWith(activeBackend.host.replace(/\/+$/, ""))
+ );
+};
+
const shownErrors = new Set();
export const createAgentServerQueryClient = () => {
@@ -34,7 +48,7 @@ export const createAgentServerQueryClient = () => {
const disableToast =
query.meta?.disableToast ?? query.options.meta?.disableToast;
- if (!disableToast) {
+ if (!disableToast && !isActiveCloudBackendAuthError(error)) {
const errorMessage = retrieveAxiosErrorMessage(error);
if (!shownErrors.has(errorMessage || "")) {
@@ -55,7 +69,7 @@ export const createAgentServerQueryClient = () => {
const disableToast =
mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast;
- if (!disableToast) {
+ if (!disableToast && !isActiveCloudBackendAuthError(error)) {
const message = retrieveAxiosErrorMessage(error);
displayErrorToast(message || i18n.t(I18nKey.ERROR$GENERIC));
}
diff --git a/src/root.tsx b/src/root.tsx
index f785454797..88c55ec2b0 100644
--- a/src/root.tsx
+++ b/src/root.tsx
@@ -19,6 +19,11 @@ import {
} from "#/api/agent-server-compatibility";
import { isAuthRequiredAndMissing } from "#/api/agent-server-config";
import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store";
+import { useActiveBackendContext } from "#/contexts/active-backend-context";
+import {
+ isCloudBackendLoggedOutHealthError,
+ useBackendsHealth,
+} from "#/hooks/query/use-backends-health";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -149,6 +154,14 @@ export default function App() {
// Skip the /server_info probe entirely when we already know auth is
// required and missing — it would just 401 and waste time.
const config = useConfig({ enabled: !authMissing });
+ const { active } = useActiveBackendContext();
+ const activeCloudHealth = useBackendsHealth(
+ active.backend.kind === "cloud" ? [active.backend] : [],
+ )[active.backend.id];
+ const activeCloudLoggedOut =
+ active.backend.kind === "cloud" &&
+ activeCloudHealth?.isConnected === false &&
+ isCloudBackendLoggedOutHealthError(activeCloudHealth.lastError);
// No key at all → instant auth screen (no network).
// Stale key → /server_info 401 → auth screen (public mode only).
@@ -164,7 +177,7 @@ export default function App() {
return ;
}
- if (isAgentServerUnavailableError(config.error)) {
+ if (activeCloudLoggedOut || isAgentServerUnavailableError(config.error)) {
return ;
}