diff --git a/__tests__/components/backends/backend-selector.test.tsx b/__tests__/components/backends/backend-selector.test.tsx index b09794b794..1b3bd05535 100644 --- a/__tests__/components/backends/backend-selector.test.tsx +++ b/__tests__/components/backends/backend-selector.test.tsx @@ -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, + {}, + + {seeded.props.children} + , + ); + } else { + ui = {ui}; + } + } const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -319,6 +342,7 @@ describe("BackendSelector", () => { > , + true, ); await waitFor(() => { @@ -414,6 +438,7 @@ describe("BackendSelector", () => { > , + 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(, 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(, 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(, 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(, 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(, 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>, + ) => void; + vi.mocked(getCloudOrganizations).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + renderWithProviders(, 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(, 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(, 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( { > , + true, ); await waitFor(() => { const wrapper = screen.getByTestId("backend-selector"); diff --git a/__tests__/components/backends/cloud-organization-startup.test.tsx b/__tests__/components/backends/cloud-organization-startup.test.tsx new file mode 100644 index 0000000000..916d9d5b01 --- /dev/null +++ b/__tests__/components/backends/cloud-organization-startup.test.tsx @@ -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 {users.cloud?.userId}; +} + +function SettingsConsumer() { + const settings = useSettings(); + return settings.isSuccess ? ( + + ) : null; +} + +function OrganizationConsumer() { + const organizations = useAllCloudOrganizations(); + return {organizations.cloud.isFetching ? "Fetching" : "Settled"}; +} + +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( + + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + await screen.findByText("test-user"); + let reject!: (error: Error) => void; + vi.mocked(getCloudOrganizations).mockImplementation( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); + let refetch!: Promise; + 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>, + ) => 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>, + ) => 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>, + ) => 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( + + + , + ); + 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"), + ); + }); +}); diff --git a/__tests__/hooks/query/use-cloud-current-user-id.test.tsx b/__tests__/hooks/query/use-cloud-current-user-id.test.tsx index c8197caea7..8e1531df67 100644 --- a/__tests__/hooks/query/use-cloud-current-user-id.test.tsx +++ b/__tests__/hooks/query/use-cloud-current-user-id.test.tsx @@ -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({ diff --git a/__tests__/routes/root-layout.test.tsx b/__tests__/routes/root-layout.test.tsx index 667fb245ae..308ee20fff 100644 --- a/__tests__/routes/root-layout.test.tsx +++ b/__tests__/routes/root-layout.test.tsx @@ -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( - + + + , ); } @@ -193,7 +196,9 @@ describe("root layout", () => { render( - + + + , ); @@ -231,7 +236,9 @@ describe("root layout", () => { const classNames = paths.map((path) => { const { unmount } = render( - + + + , ); const { className } = screen.getByTestId("root-layout"); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1796175801..336cc6f517 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/src/components/features/backends/backend-selector.tsx b/src/components/features/backends/backend-selector.tsx index 8ba81048c6..f9793be675 100644 --- a/src/components/features/backends/backend-selector.tsx +++ b/src/components/features/backends/backend-selector.tsx @@ -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(); diff --git a/src/components/features/backends/cloud-organization-boundary.tsx b/src/components/features/backends/cloud-organization-boundary.tsx new file mode 100644 index 0000000000..02404f2229 --- /dev/null +++ b/src/components/features/backends/cloud-organization-boundary.tsx @@ -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 ; + } + return ( +
+ +
+ ); +} + +function CloudOrganizationRecovery({ + entry, +}: { + entry: ReturnType[string]; +}) { + const { t } = useTranslation("openhands"); + const { active, backends, setActive } = useActiveBackendContext(); + return ( +
+

+ {t( + entry.isError + ? I18nKey.BACKEND$ORGANIZATIONS_LOAD_FAILED + : I18nKey.BACKEND$ORGANIZATIONS_EMPTY, + )} +

+ void entry.refetch()} + > + {t(I18nKey.BACKEND$AUTH_RETRY)} + + {backends.length > 1 && ( + + )} +
+ ); +} 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();