fix: recover stale organization selections (#17671)

This commit is contained in:
Alona
2026-09-24 18:42:00 -04:00
committed by GitHub
parent ef0c86d345
commit 684d50dd74
14 changed files with 799 additions and 65 deletions
@@ -10,11 +10,17 @@ import {
import userEvent from "@testing-library/user-event";
import { createRoutesStub, MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import {
__resetActiveStoreForTests,
getActiveSelection,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import {
ActiveBackendProvider,
useActiveBackendContext,
} from "#/contexts/active-backend-context";
import { CloudOrganizationBoundary } from "#/components/features/backends/cloud-organization-boundary";
import { BackendSelector } from "#/components/features/backends/backend-selector";
import {
__resetEnvironmentSwitchOverlayForTests,
@@ -58,7 +64,24 @@ const SEED_CLOUD_PRODUCTION = {
kind: "cloud" as const,
};
function renderWithProviders(ui: React.ReactElement) {
function renderWithProviders(
ui: React.ReactElement,
resolveOrganization = false,
) {
if (resolveOrganization) {
if (ui.type === TestSeed) {
const seeded = ui as React.ReactElement<{ children: React.ReactNode }>;
ui = React.cloneElement(
seeded,
{},
<CloudOrganizationBoundary>
{seeded.props.children}
</CloudOrganizationBoundary>,
);
} else {
ui = <CloudOrganizationBoundary>{ui}</CloudOrganizationBoundary>;
}
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
@@ -319,6 +342,7 @@ describe("BackendSelector", () => {
>
<BackendSelector />
</TestSeed>,
true,
);
await waitFor(() => {
@@ -414,6 +438,7 @@ describe("BackendSelector", () => {
>
<BackendSelector />
</TestSeed>,
true,
);
// After orgs resolve, the selector snaps the active selection onto the
@@ -428,6 +453,169 @@ describe("BackendSelector", () => {
});
});
it.each(["cookie", "api-key"] as const)(
"recovers a persisted removed org with %s authentication",
async (authMode) => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud", authMode };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "removed-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "first-org", name: "First" },
{ id: "current-org", name: "Current" },
],
currentOrgId: "current-org",
});
renderWithProviders(<BackendSelector />, true);
await waitFor(() =>
expect(getActiveSelection()?.orgId).toBe("current-org"),
);
expect(
screen.getByTestId("backend-selector-settings-link"),
).toHaveAttribute("href", `${backend.host}/settings?org=current-org`);
expect(getCloudOrganizationMe).not.toHaveBeenCalledWith(
"removed-org",
expect.anything(),
);
},
);
it.each(["cookie", "api-key"] as const)(
"prefers a visible personal workspace before identity resolves with %s auth",
async (authMode) => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud", authMode };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "removed-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "team-org", name: "Team" },
{ id: "personal-org", name: "Personal", is_personal: true },
],
currentOrgId: "removed-org",
});
vi.mocked(getCloudOrganizationMe).mockReturnValue(new Promise(() => {}));
renderWithProviders(<BackendSelector />, true);
await waitFor(() =>
expect(getActiveSelection()?.orgId).toBe("personal-org"),
);
expect(getCloudOrganizationMe).not.toHaveBeenCalledWith(
"removed-org",
expect.anything(),
);
},
);
it("keeps a valid persisted choice when the server current org differs", async () => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud" };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "chosen-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "chosen-org", name: "Chosen" },
{ id: "server-org", name: "Server" },
],
currentOrgId: "server-org",
});
renderWithProviders(<BackendSelector />, true);
await waitFor(() =>
expect(getCloudOrganizationMe).toHaveBeenCalledWith(
"chosen-org",
expect.anything(),
),
);
expect(getActiveSelection()?.orgId).toBe("chosen-org");
});
it("recovers only to an org allowed by the API key", async () => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud" };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "other-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "other-org", name: "Other" },
{ id: "bound-org", name: "Bound" },
],
currentOrgId: "other-org",
});
vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
orgId: "bound-org",
isLegacyKey: false,
});
renderWithProviders(<BackendSelector />, true);
await waitFor(() => expect(getActiveSelection()?.orgId).toBe("bound-org"));
expect(getCloudOrganizationMe).not.toHaveBeenCalledWith(
"other-org",
expect.anything(),
);
});
it("recovers from a hidden workspace without selecting it again", async () => {
const backend = {
...SEED_CLOUD_PRODUCTION,
id: "cloud",
authMode: "cookie" as const,
};
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "hidden-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "hidden-org", name: "Hidden", is_visible: false },
{ id: "team-org", name: "Team" },
],
currentOrgId: "hidden-org",
});
renderWithProviders(<BackendSelector />, true);
await waitFor(() => expect(getActiveSelection()?.orgId).toBe("team-org"));
});
it("waits for memberships before changing the persisted selection", async () => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud" };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "old-org" });
let resolve!: (
value: Awaited<ReturnType<typeof getCloudOrganizations>>,
) => void;
vi.mocked(getCloudOrganizations).mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
renderWithProviders(<BackendSelector />, true);
expect(getActiveSelection()?.orgId).toBe("old-org");
expect(getCloudOrganizationMe).not.toHaveBeenCalled();
resolve({
items: [{ id: "new-org", name: "New" }],
currentOrgId: "new-org",
});
await waitFor(() => expect(getActiveSelection()?.orgId).toBe("new-org"));
});
it("does not interpret a failed membership request as an empty membership", async () => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud" };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "saved-org" });
vi.mocked(getCloudOrganizations).mockRejectedValue(
new Error("network unavailable"),
);
renderWithProviders(<BackendSelector />, true);
await waitFor(() => expect(getCloudOrganizations).toHaveBeenCalled());
expect(getActiveSelection()?.orgId).toBe("saved-org");
expect(getCloudOrganizationMe).not.toHaveBeenCalled();
});
it("clears the org selection when no authorized organizations remain", async () => {
const backend = { ...SEED_CLOUD_PRODUCTION, id: "cloud" };
setRegisteredBackends([backend]);
setActiveSelection({ backendId: backend.id, orgId: "removed-org" });
renderWithProviders(<BackendSelector />, true);
await waitFor(() =>
expect(getActiveSelection()).toEqual({ backendId: "cloud", orgId: null }),
);
expect(getCloudOrganizationMe).not.toHaveBeenCalled();
});
it("switches the active backend when an option is selected", async () => {
renderWithProviders(
<TestSeed
@@ -652,6 +840,7 @@ describe("BackendSelector", () => {
>
<BackendSelector />
</TestSeed>,
true,
);
await waitFor(() => {
const wrapper = screen.getByTestId("backend-selector");
@@ -0,0 +1,365 @@
import React from "react";
import { HttpError } from "@openhands/typescript-client";
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentServerUIProviders } from "#/components/providers/agent-server-ui-providers";
import {
__resetActiveStoreForTests,
getActiveSelection,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import {
getCloudOrganizations,
getCloudOrganizationMe,
getCurrentCloudApiKey,
} from "#/api/cloud/organization-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useSettings } from "#/hooks/query/use-settings";
vi.mock("#/api/cloud/organization-service.api", () => ({
getCloudOrganizations: vi.fn(),
getCloudOrganizationMe: vi.fn(),
getCurrentCloudApiKey: vi.fn(),
}));
vi.mock("#/api/settings-service/settings-service.api", () => ({
default: { getSettings: vi.fn() },
}));
vi.mock("#/hooks/query/use-free-models", () => ({
useHydrateFreeModels: vi.fn(),
}));
vi.mock("#/components/providers/telemetry-provider", () => ({
TelemetryProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const cloud = {
id: "cloud",
name: "Cloud",
host: "https://cloud.example",
kind: "cloud" as const,
authMode: "cookie" as const,
apiKey: "",
};
function IdentityConsumer() {
const users = useCloudCurrentUserId();
return <span aria-label="Current user">{users.cloud?.userId}</span>;
}
function SettingsConsumer() {
const settings = useSettings();
return settings.isSuccess ? (
<input aria-label="Draft" defaultValue="saved" />
) : null;
}
function OrganizationConsumer() {
const organizations = useAllCloudOrganizations();
return <span>{organizations.cloud.isFetching ? "Fetching" : "Settled"}</span>;
}
beforeEach(() => {
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(getCloudOrganizations).mockReset();
vi.mocked(getCurrentCloudApiKey).mockReset();
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
orgId: "chosen-org",
userId: "test-user",
role: null,
});
vi.mocked(SettingsService.getSettings).mockReset();
vi.mocked(SettingsService.getSettings).mockResolvedValue(DEFAULT_SETTINGS);
setRegisteredBackends([cloud]);
setActiveSelection({ backendId: cloud.id, orgId: "removed-org" });
});
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
});
describe("Cloud organization startup", () => {
function renderConsumer() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
render(
<AgentServerUIProviders queryClient={client} resolveCloudOrganization>
<SettingsConsumer />
<OrganizationConsumer />
</AgentServerUIProviders>,
);
return client;
}
it("keeps a valid page and unsaved input during a failed background refresh", async () => {
setActiveSelection({ backendId: cloud.id, orgId: "chosen-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "chosen-org", name: "Chosen" },
{ id: "other-org", name: "Other" },
],
currentOrgId: "other-org",
});
const client = renderConsumer();
const input = await screen.findByLabelText("Draft");
fireEvent.change(input, { target: { value: "unsaved" } });
vi.mocked(getCloudOrganizations).mockRejectedValue(new Error("offline"));
await act(async () => {
await client.refetchQueries({ queryKey: ["cloud-organizations"] });
});
expect(screen.getByLabelText("Draft")).toBe(input);
expect(input).toHaveValue("unsaved");
expect(getActiveSelection()?.orgId).toBe("chosen-org");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps a saved selection usable after transient startup failures", async () => {
vi.mocked(getCloudOrganizations).mockRejectedValue(new Error("offline"));
renderConsumer();
await screen.findByLabelText("Draft");
expect(getActiveSelection()?.orgId).toBe("removed-org");
await screen.findByText("Settled");
expect(getCloudOrganizations).toHaveBeenCalledTimes(3);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("offers Retry when startup fails without a saved selection", async () => {
setActiveSelection({ backendId: cloud.id, orgId: null });
vi.mocked(getCloudOrganizations).mockRejectedValue(new Error("offline"));
renderConsumer();
await screen.findByRole("alert");
expect(SettingsService.getSettings).not.toHaveBeenCalled();
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [{ id: "restored-org", name: "Restored" }],
currentOrgId: "restored-org",
});
fireEvent.click(screen.getByRole("button", { name: "BACKEND$AUTH_RETRY" }));
await screen.findByLabelText("Draft");
expect(getActiveSelection()?.orgId).toBe("restored-org");
});
it.each([401, 403])(
"does not bypass an HTTP %s authorization failure",
async (status) => {
vi.mocked(getCloudOrganizations).mockRejectedValue(
new HttpError(status, "Unauthorized"),
);
renderConsumer();
await screen.findByRole("alert");
expect(SettingsService.getSettings).not.toHaveBeenCalled();
expect(getCloudOrganizations).toHaveBeenCalledTimes(1);
},
);
it("leaves an embedded host's login visible without opting into recovery", () => {
vi.mocked(getCloudOrganizations).mockReturnValue(new Promise(() => {}));
render(
<AgentServerUIProviders>
<button>Host login</button>
</AgentServerUIProviders>,
);
expect(
screen.getByRole("button", { name: "Host login" }),
).toBeInTheDocument();
expect(getCloudOrganizations).not.toHaveBeenCalled();
});
it("keeps cached identity while memberships refetch or temporarily fail", async () => {
setActiveSelection({ backendId: cloud.id, orgId: "chosen-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [{ id: "chosen-org", name: "Chosen" }],
currentOrgId: "chosen-org",
});
const client = new QueryClient({
defaultOptions: { queries: { retryDelay: 0 } },
});
render(
<AgentServerUIProviders queryClient={client} resolveCloudOrganization>
<IdentityConsumer />
</AgentServerUIProviders>,
);
await screen.findByText("test-user");
let reject!: (error: Error) => void;
vi.mocked(getCloudOrganizations).mockImplementation(
() =>
new Promise((_, fail) => {
reject = fail;
}),
);
let refetch!: Promise<void>;
await act(async () => {
refetch = client.refetchQueries({ queryKey: ["cloud-organizations"] });
});
expect(screen.getByLabelText("Current user")).toHaveTextContent(
"test-user",
);
vi.mocked(getCloudOrganizations).mockRejectedValue(new Error("offline"));
await act(async () => {
reject(new Error("offline"));
await refetch;
});
expect(screen.getByLabelText("Current user")).toHaveTextContent(
"test-user",
);
});
it("blocks org-scoped consumers when membership is empty and allows another backend", async () => {
const local = {
...cloud,
id: "local",
name: "Local",
kind: "local" as const,
host: "http://localhost:9000",
};
setRegisteredBackends([cloud, local]);
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [],
currentOrgId: null,
});
renderConsumer();
await screen.findByRole("alert");
await waitFor(() => expect(getActiveSelection()?.orgId).toBeNull());
expect(SettingsService.getSettings).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("combobox"), {
target: { value: "local" },
});
await screen.findByLabelText("Draft");
expect(getActiveSelection()?.backendId).toBe("local");
});
it("does not block a local backend on an inactive cloud's pending memberships", async () => {
const local = {
...cloud,
id: "local",
kind: "local" as const,
host: "http://localhost:9000",
};
setRegisteredBackends([cloud, local]);
setActiveSelection({ backendId: local.id });
vi.mocked(getCloudOrganizations).mockReturnValue(new Promise(() => {}));
renderConsumer();
await screen.findByLabelText("Draft");
expect(getActiveSelection()?.backendId).toBe("local");
});
it("waits for the API key binding before mounting consumers", async () => {
setRegisteredBackends([
{ ...cloud, authMode: "api-key", apiKey: "test-key" },
]);
setActiveSelection({ backendId: cloud.id, orgId: "other-org" });
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "other-org", name: "Other" },
{ id: "bound-org", name: "Bound" },
],
currentOrgId: "other-org",
});
let resolve!: (
value: Awaited<ReturnType<typeof getCurrentCloudApiKey>>,
) => void;
vi.mocked(getCurrentCloudApiKey).mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
renderConsumer();
await waitFor(() => expect(getCurrentCloudApiKey).toHaveBeenCalled());
expect(SettingsService.getSettings).not.toHaveBeenCalled();
await act(async () => resolve({ orgId: "bound-org", isLegacyKey: false }));
await screen.findByLabelText("Draft");
expect(getActiveSelection()?.orgId).toBe("bound-org");
});
it("gates a later backend switch until the newly selected cloud is resolved", async () => {
const other = {
...cloud,
id: "second-cloud",
host: "https://second.example",
};
setRegisteredBackends([cloud, other]);
let resolve!: (
value: Awaited<ReturnType<typeof getCloudOrganizations>>,
) => void;
vi.mocked(getCloudOrganizations).mockImplementation(async (backend) => {
if (backend?.id === other.id)
return new Promise((done) => {
resolve = done;
});
return {
items: [{ id: "first-org", name: "First" }],
currentOrgId: "first-org",
};
});
const requestedOrgs: (string | null | undefined)[] = [];
vi.mocked(SettingsService.getSettings).mockImplementation(async () => {
requestedOrgs.push(getActiveSelection()?.orgId);
return DEFAULT_SETTINGS;
});
renderConsumer();
await screen.findByLabelText("Draft");
act(() =>
setActiveSelection({ backendId: other.id, orgId: "removed-org" }),
);
expect(screen.queryByLabelText("Draft")).not.toBeInTheDocument();
expect(requestedOrgs).toEqual(["first-org"]);
await act(async () =>
resolve({
items: [{ id: "second-org", name: "Second" }],
currentOrgId: "second-org",
}),
);
await screen.findByLabelText("Draft");
expect(requestedOrgs).toEqual(["first-org", "second-org"]);
});
it("does not request settings until the persisted organization is validated and repaired", async () => {
let resolve!: (
value: Awaited<ReturnType<typeof getCloudOrganizations>>,
) => void;
vi.mocked(getCloudOrganizations).mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
const requestedOrgs: (string | null | undefined)[] = [];
vi.mocked(SettingsService.getSettings).mockImplementation(async () => {
requestedOrgs.push(getActiveSelection()?.orgId);
return DEFAULT_SETTINGS;
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
render(
<AgentServerUIProviders queryClient={client} resolveCloudOrganization>
<SettingsConsumer />
</AgentServerUIProviders>,
);
await act(async () => {});
expect(SettingsService.getSettings).not.toHaveBeenCalled();
await act(async () =>
resolve({
items: [{ id: "current-org", name: "Current" }],
currentOrgId: "current-org",
}),
);
await screen.findByLabelText("Draft");
expect(requestedOrgs).toEqual(["current-org"]);
await waitFor(() =>
expect(getActiveSelection()?.orgId).toBe("current-org"),
);
});
});
@@ -1,6 +1,6 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
@@ -54,6 +54,9 @@ beforeEach(() => {
[cloudBackend.id]: {
backend: cloudBackend,
isLoading: false,
isSuccess: true,
hasData: true,
isFetching: false,
orgs: [
{ id: "org-personal", name: "Personal" },
{ id: "org-2", name: "Acme Inc" },
@@ -68,6 +71,30 @@ afterEach(() => {
});
describe("useCloudCurrentUserId", () => {
it("waits for a removed selection to recover before requesting identity", async () => {
setActiveSelection({ backendId: cloudBackend.id, orgId: "removed-org" });
getCloudOrganizationMeMock.mockResolvedValue({
orgId: "org-personal",
userId: "user-X",
});
const { result } = renderHook(() => useCloudCurrentUserId(), {
wrapper: makeWrapper(),
});
expect(result.current[cloudBackend.id]).toBeUndefined();
expect(getCloudOrganizationMeMock).not.toHaveBeenCalled();
act(() => {
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-personal" });
});
await waitFor(() => {
expect(result.current[cloudBackend.id]?.userId).toBe("user-X");
});
expect(getCloudOrganizationMeMock).toHaveBeenCalledExactlyOnceWith(
"org-personal",
cloudBackend,
);
});
it("uses active.orgId for /me when the active backend is this cloud backend", async () => {
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
getCloudOrganizationMeMock.mockResolvedValue({
+10 -3
View File
@@ -4,6 +4,7 @@ import { type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub, data, Link } from "react-router";
import MainApp, { ErrorBoundary } from "#/routes/root-layout";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { I18nKey } from "#/i18n/declaration";
const useConfigMock = vi.fn();
@@ -148,7 +149,9 @@ function renderMainApp(path = "/") {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<RouterStub initialEntries={[path]} />
<ActiveBackendProvider>
<RouterStub initialEntries={[path]} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
@@ -193,7 +196,9 @@ describe("root layout", () => {
render(
<QueryClientProvider client={new QueryClient()}>
<RouterStub initialEntries={["/"]} />
<ActiveBackendProvider>
<RouterStub initialEntries={["/"]} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
@@ -231,7 +236,9 @@ describe("root layout", () => {
const classNames = paths.map((path) => {
const { unmount } = render(
<QueryClientProvider client={new QueryClient()}>
<RouterStub initialEntries={[path]} />
<ActiveBackendProvider>
<RouterStub initialEntries={[path]} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
const { className } = screen.getByTestId("root-layout");
+16
View File
@@ -203,3 +203,19 @@ You can create a `.env` file in the project directory with these variables based
| `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` |
| `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` |
| `VITE_INSECURE_SKIP_VERIFY` | Skip TLS certificate verification for proxied backend requests | `false` |
### Cloud organization recovery in embedded hosts
`AgentServerUIProviders` leaves organization recovery off by default so the host's
login and onboarding can render before authentication. Import the public
`CloudOrganizationBoundary` and mount it inside the providers, after your auth
gate, around consumers that send organization-scoped requests. If the entire
provider subtree is already authenticated, use `resolveCloudOrganization` to
apply the same boundary automatically. Recovery fills its containing panel.
Standalone Canvas mounts the boundary after its own authentication gate.
A successful membership response repairs an inaccessible saved selection. A
transient lookup failure retains a saved selection; 401/403 responses and an
empty membership list show recovery instead. Background refreshes retain cached
membership and user identity while the saved selection remains accessible.
@@ -209,37 +209,6 @@ export function BackendSelector({
const someCloudLoading = Object.values(cloudOrgs).some((c) => c.isLoading);
// Self-heal a malformed `(cloudBackendId, null)` selection.
//
// Once a cloud backend's orgs resolve, the dropdown only renders
// per-org rows for it — the `(backendId, null)` row disappears, so
// selecting that shape would drift from what the dropdown can render
// (UI says "Local", APIs hit cloud). When we detect the drift, snap
// the selection onto Cloud's current org first, then fall back to the
// personal workspace (or, lacking a /me result, the first org). The
// selection is recorded locally only; the cloud request scope follows
// from the X-Org-Id header sent by `callCloudProxy`, so the cloud UI's
// org choice is never mutated as a side effect.
React.useEffect(() => {
if (noBackendSelected || active.backend.kind !== "cloud" || active.orgId)
return;
const { backend } = active;
const entry = cloudOrgs[backend.id];
if (!entry || entry.orgs.length === 0) return;
const currentOrg = entry.currentOrgId
? entry.orgs.find((o) => o.id === entry.currentOrgId)
: undefined;
const userId = currentUserIds[backend.id]?.userId ?? null;
const personal = userId
? entry.orgs.find((o) => o.id === userId)
: undefined;
const target = currentOrg ?? personal ?? entry.orgs[0];
if (target) {
setActive(backend.id, target.id);
}
}, [active, cloudOrgs, currentUserIds, setActive, noBackendSelected]);
const openAddBackendModal = React.useCallback(() => {
if (onOpenAddBackend) {
onOpenAddBackend();
@@ -0,0 +1,94 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { BrandButton } from "#/components/features/settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
export function CloudOrganizationBoundary({
children,
}: {
children: React.ReactNode;
}) {
const { active, setActive } = useActiveBackendContext();
const organizations = useAllCloudOrganizations();
const entry = organizations[active.backend.id];
const isCloud = active.backend.kind === "cloud";
const hasValidSelection =
entry?.hasData && entry.orgs.some((org) => org.id === active.orgId);
// Resolve membership before mounting consumers that send org-scoped requests.
React.useEffect(() => {
if (!isCloud || hasValidSelection || !entry?.isSuccess || entry.isFetching)
return;
const current = entry.orgs.find((org) => org.id === entry.currentOrgId);
const personal = entry.orgs.find((org) => org.is_personal === true);
const target = current ?? personal ?? entry.orgs[0];
if ((target?.id ?? null) !== active.orgId) {
setActive(active.backend.id, target?.id ?? null);
}
}, [active, entry, hasValidSelection, isCloud, setActive]);
const canUseSavedSelection = active.orgId && entry?.isError && !entry.hasData;
if (
!isCloud ||
(!entry?.isAuthorizationError &&
(hasValidSelection || canUseSavedSelection))
)
return children;
if (
entry &&
!entry.isFetching &&
(entry.isError || entry.orgs.length === 0)
) {
return <CloudOrganizationRecovery entry={entry} />;
}
return (
<div className="min-h-full flex items-center justify-center bg-base">
<LoadingSpinner size="large" />
</div>
);
}
function CloudOrganizationRecovery({
entry,
}: {
entry: ReturnType<typeof useAllCloudOrganizations>[string];
}) {
const { t } = useTranslation("openhands");
const { active, backends, setActive } = useActiveBackendContext();
return (
<div className="min-h-full flex flex-col items-center justify-center gap-4 bg-base px-6 text-contrast">
<p role="alert">
{t(
entry.isError
? I18nKey.BACKEND$ORGANIZATIONS_LOAD_FAILED
: I18nKey.BACKEND$ORGANIZATIONS_EMPTY,
)}
</p>
<BrandButton
type="button"
variant="primary"
onClick={() => void entry.refetch()}
>
{t(I18nKey.BACKEND$AUTH_RETRY)}
</BrandButton>
{backends.length > 1 && (
<select
aria-label={t(I18nKey.BACKEND$CHOOSER_TITLE)}
value={active.backend.id}
onChange={(event) => setActive(event.target.value, null)}
className="rounded border border-border bg-base px-3 py-2"
>
{backends.map((backend) => (
<option key={backend.id} value={backend.id}>
{backend.name}
</option>
))}
</select>
)}
</div>
);
}
@@ -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 = (
<TelemetryProvider config={posthogConfig}>
<FreeModelsHydrator />
{children}
{resolveCloudOrganization ? (
<CloudOrganizationBoundary>{children}</CloudOrganizationBoundary>
) : (
children
)}
</TelemetryProvider>
);
+1
View File
@@ -9,3 +9,4 @@ export {
AgentServerUIRoot,
type AgentServerUIRootProps,
} from "./agent-server-ui-root";
export { CloudOrganizationBoundary } from "../features/backends/cloud-organization-boundary";
+13 -26
View File
@@ -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,
+28 -1
View File
@@ -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,
};
+34
View File
@@ -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": "Немає доступних робочих просторів. Попросіть адміністратора перевірити ваші права доступу."
}
}
+1
View File
@@ -7,6 +7,7 @@ export * from "../components/terminal";
export {
AgentServerUIProviders,
AgentServerUIRoot,
CloudOrganizationBoundary,
DEFAULT_AGENT_SERVER_ANALYTICS,
type AgentServerUIAnalyticsConfig,
type AgentServerUIPostHogAnalyticsConfig,
+9
View File
@@ -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 (
<CloudOrganizationBoundary>
<MainAppContent />
</CloudOrganizationBoundary>
);
}
function MainAppContent() {
const location = useLocation();
const appTitle = useAppTitle();
const { data: settings } = useSettings();