+ );
+}
diff --git a/src/components/providers/agent-server-ui-providers.tsx b/src/components/providers/agent-server-ui-providers.tsx
index b9f884355f..5a67e79132 100644
--- a/src/components/providers/agent-server-ui-providers.tsx
+++ b/src/components/providers/agent-server-ui-providers.tsx
@@ -14,6 +14,7 @@ import {
setI18n,
} from "#/i18n";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import { CloudOrganizationBoundary } from "#/components/features/backends/cloud-organization-boundary";
import { useHydrateFreeModels } from "#/hooks/query/use-free-models";
import type { TelemetryConfig } from "#/services/telemetry";
import { TelemetryProvider } from "./telemetry-provider";
@@ -44,6 +45,8 @@ export interface AgentServerUIProvidersProps extends Pick<
analytics?: AgentServerUIAnalyticsConfig;
i18n?: I18nInstance;
withStyleRoot?: boolean;
+ /** Opt in only after authentication; otherwise mount CloudOrganizationBoundary after the host auth gate. */
+ resolveCloudOrganization?: boolean;
}
/**
@@ -67,6 +70,7 @@ export function AgentServerUIProviders({
styleOverrides,
theme,
withStyleRoot = true,
+ resolveCloudOrganization = false,
}: AgentServerUIProvidersProps) {
const resolvedQueryClient = React.useMemo(
() => queryClient ?? getDefaultQueryClient(),
@@ -109,7 +113,11 @@ export function AgentServerUIProviders({
const content = (
- {children}
+ {resolveCloudOrganization ? (
+ {children}
+ ) : (
+ children
+ )}
);
diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts
index 5b8e9f6df9..7a062ae272 100644
--- a/src/components/providers/index.ts
+++ b/src/components/providers/index.ts
@@ -9,3 +9,4 @@ export {
AgentServerUIRoot,
type AgentServerUIRootProps,
} from "./agent-server-ui-root";
+export { CloudOrganizationBoundary } from "../features/backends/cloud-organization-boundary";
diff --git a/src/hooks/query/use-cloud-current-user-id.ts b/src/hooks/query/use-cloud-current-user-id.ts
index d462eb21ab..673770bc1d 100644
--- a/src/hooks/query/use-cloud-current-user-id.ts
+++ b/src/hooks/query/use-cloud-current-user-id.ts
@@ -6,27 +6,7 @@ import {
} from "#/contexts/active-backend-context";
import { useAllCloudOrganizations } from "./use-cloud-organizations";
-/**
- * Resolve the current user's `user_id` per cloud backend with one
- * `/api/organizations/{orgId}/me` call per backend (NOT one per org).
- *
- * The cloud contract: `/me` returns `{ org_id, user_id, … }`. `user_id`
- * is identical regardless of which org you ask, so we make a single
- * call per backend.
- *
- * Path-param rule: when `backend.id === active.backend.id` and
- * `active.orgId` is set, the call uses **that** orgId — i.e. `/me`
- * always tracks the currently selected environment for the active
- * backend. For inactive backends (or when no org is selected yet), the
- * first org is used as a sentinel just to obtain `user_id`. This
- * matches the requirement that `/me` reflect the selected org for the
- * active environment, while still supporting the personal-workspace
- * label across non-active backends in the dropdown.
- *
- * The query key includes `active.orgId`, so picking a different org
- * via `setActive` re-keys this query and refetches `/me` with the new
- * active orgId.
- */
+/** Resolve one authorized membership per backend to identify personal workspaces. */
export function useCloudCurrentUserId(): Record<
string,
{ isLoading: boolean; userId: string | null }
@@ -43,13 +23,20 @@ export function useCloudCurrentUserId(): Record<
for (const backend of backends) {
if (backend.kind === "cloud") {
const entry = cloudOrgs[backend.id];
- // Prefer the active org when this backend IS the active one and
- // an org has been selected; otherwise fall back to the first org
- // we know about for that backend.
+ if (!entry?.hasData || entry.isAuthorizationError) continue;
+ const isActiveBackend = backend.id === active.backend.id;
+ if (
+ isActiveBackend &&
+ active.orgId &&
+ !entry.orgs.some((org) => org.id === active.orgId)
+ ) {
+ // Wait for selection repair so the request's X-Org-Id matches its path.
+ continue;
+ }
const preferredOrgId =
- backend.id === active.backend.id && active.orgId
+ isActiveBackend && active.orgId
? active.orgId
- : (entry?.orgs[0]?.id ?? null);
+ : (entry.orgs[0]?.id ?? null);
if (preferredOrgId) {
targets.push({
backendId: backend.id,
diff --git a/src/hooks/query/use-cloud-organizations.ts b/src/hooks/query/use-cloud-organizations.ts
index 886b38a428..352e5af92d 100644
--- a/src/hooks/query/use-cloud-organizations.ts
+++ b/src/hooks/query/use-cloud-organizations.ts
@@ -1,4 +1,6 @@
import { useQueries } from "@tanstack/react-query";
+import { HttpError } from "@openhands/typescript-client";
+import axios from "axios";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import {
getCloudOrganizations,
@@ -6,6 +8,16 @@ import {
} from "#/api/cloud/organization-service.api";
import type { Backend } from "#/api/backend-registry/types";
+function isAuthorizationError(error: unknown): boolean {
+ const status =
+ error instanceof HttpError
+ ? error.status
+ : axios.isAxiosError(error)
+ ? error.response?.status
+ : undefined;
+ return status === 401 || status === 403;
+}
+
/**
* Fetch organizations for every registered cloud backend in parallel.
*
@@ -53,7 +65,10 @@ export function useAllCloudOrganizations() {
};
},
staleTime: 1000 * 60 * 5,
- retry: false,
+ retry: (failureCount: number, error: unknown) =>
+ failureCount < 2 && !isAuthorizationError(error),
+ // Mounting workspace consumers must not restart a failed startup query.
+ retryOnMount: false,
meta: { disableToast: true },
})),
});
@@ -64,6 +79,12 @@ export function useAllCloudOrganizations() {
{
backend: Backend;
isLoading: boolean;
+ isSuccess: boolean;
+ isFetching: boolean;
+ isError: boolean;
+ isAuthorizationError: boolean;
+ hasData: boolean;
+ refetch: () => unknown;
orgs: { id: string; name: string; is_personal?: boolean }[];
currentOrgId: string | null;
}
@@ -73,6 +94,12 @@ export function useAllCloudOrganizations() {
byBackendId[backend.id] = {
backend,
isLoading: q.isLoading,
+ isSuccess: q.isSuccess,
+ isFetching: q.isFetching,
+ isError: q.isError,
+ isAuthorizationError: isAuthorizationError(q.error),
+ hasData: q.data !== undefined,
+ refetch: q.refetch,
orgs: q.data?.items ?? [],
currentOrgId: q.data?.currentOrgId ?? null,
};
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 4e5d1565cd..125e9a5cd6 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -42143,5 +42143,39 @@
"de": "Sie haben noch keine Geheimnisse gespeichert.",
"uk": "Ви ще не зберегли жодного секрету.",
"ca": "Encara no has desat cap secret."
+ },
+ "BACKEND$ORGANIZATIONS_LOAD_FAILED": {
+ "en": "Could not load your workspaces. Check your connection and try again.",
+ "ja": "ワークスペースを読み込めませんでした。接続を確認して、もう一度お試しください。",
+ "zh-CN": "无法加载您的工作区。请检查连接后重试。",
+ "zh-TW": "無法載入您的工作區。請檢查連線後再試一次。",
+ "ko-KR": "작업 공간을 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
+ "no": "Kunne ikke laste arbeidsområdene dine. Kontroller tilkoblingen og prøv igjen.",
+ "ar": "تعذر تحميل مساحات العمل. تحقق من الاتصال وحاول مرة أخرى.",
+ "de": "Ihre Arbeitsbereiche konnten nicht geladen werden. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
+ "fr": "Impossible de charger vos espaces de travail. Vérifiez votre connexion et réessayez.",
+ "it": "Impossibile caricare gli spazi di lavoro. Controlla la connessione e riprova.",
+ "pt": "Não foi possível carregar seus espaços de trabalho. Verifique a conexão e tente novamente.",
+ "es": "No se pudieron cargar tus espacios de trabajo. Comprueba la conexión e inténtalo de nuevo.",
+ "ca": "No s’han pogut carregar els espais de treball. Comprova la connexió i torna-ho a provar.",
+ "tr": "Çalışma alanlarınız yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin.",
+ "uk": "Не вдалося завантажити робочі простори. Перевірте з’єднання та спробуйте ще раз."
+ },
+ "BACKEND$ORGANIZATIONS_EMPTY": {
+ "en": "No accessible workspaces are available. Ask your administrator to check your access.",
+ "ja": "アクセスできるワークスペースがありません。管理者にアクセス権の確認を依頼してください。",
+ "zh-CN": "没有可访问的工作区。请联系管理员检查您的访问权限。",
+ "zh-TW": "沒有可存取的工作區。請聯絡管理員檢查您的存取權限。",
+ "ko-KR": "접근 가능한 작업 공간이 없습니다. 관리자에게 접근 권한을 확인해 달라고 요청하세요.",
+ "no": "Ingen tilgjengelige arbeidsområder. Be administratoren kontrollere tilgangen din.",
+ "ar": "لا توجد مساحات عمل يمكنك الوصول إليها. اطلب من المسؤول التحقق من صلاحياتك.",
+ "de": "Es sind keine zugänglichen Arbeitsbereiche verfügbar. Bitten Sie Ihren Administrator, Ihren Zugriff zu prüfen.",
+ "fr": "Aucun espace de travail accessible. Demandez à votre administrateur de vérifier vos droits d’accès.",
+ "it": "Non ci sono spazi di lavoro accessibili. Chiedi all’amministratore di verificare i tuoi permessi.",
+ "pt": "Não há espaços de trabalho acessíveis. Peça ao administrador para verificar seu acesso.",
+ "es": "No hay espacios de trabajo accesibles. Pide a tu administrador que compruebe tus permisos.",
+ "ca": "No hi ha espais de treball accessibles. Demana a l’administrador que comprovi els teus permisos.",
+ "tr": "Erişilebilir çalışma alanı yok. Yöneticinizden erişim izinlerinizi kontrol etmesini isteyin.",
+ "uk": "Немає доступних робочих просторів. Попросіть адміністратора перевірити ваші права доступу."
}
}
diff --git a/src/lib/index.ts b/src/lib/index.ts
index af627ec581..bdf1cc893b 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -7,6 +7,7 @@ export * from "../components/terminal";
export {
AgentServerUIProviders,
AgentServerUIRoot,
+ CloudOrganizationBoundary,
DEFAULT_AGENT_SERVER_ANALYTICS,
type AgentServerUIAnalyticsConfig,
type AgentServerUIPostHogAnalyticsConfig,
diff --git a/src/routes/root-layout.tsx b/src/routes/root-layout.tsx
index 60c0fbeeee..eb430ae614 100644
--- a/src/routes/root-layout.tsx
+++ b/src/routes/root-layout.tsx
@@ -20,6 +20,7 @@ import { useSyncAutomationTelemetryConsent } from "#/hooks/use-sync-automation-t
import { useTelemetryIdentity } from "#/hooks/use-telemetry-identity";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
+import { CloudOrganizationBoundary } from "#/components/features/backends/cloud-organization-boundary";
import { useAppTitle } from "#/hooks/use-app-title";
import { ReactRouterNavigationProvider } from "./react-router-navigation-provider";
import { OnboardingHost } from "#/components/features/onboarding";
@@ -74,6 +75,14 @@ export function ErrorBoundary() {
}
export default function MainApp() {
+ return (
+
+
+
+ );
+}
+
+function MainAppContent() {
const location = useLocation();
const appTitle = useAppTitle();
const { data: settings } = useSettings();