mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-09-28 05:54:50 +08:00
fix: recover stale organization selections (#17671)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ export {
|
||||
AgentServerUIRoot,
|
||||
type AgentServerUIRootProps,
|
||||
} from "./agent-server-ui-root";
|
||||
export { CloudOrganizationBoundary } from "../features/backends/cloud-organization-boundary";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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": "Немає доступних робочих просторів. Попросіть адміністратора перевірити ваші права доступу."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "../components/terminal";
|
||||
export {
|
||||
AgentServerUIProviders,
|
||||
AgentServerUIRoot,
|
||||
CloudOrganizationBoundary,
|
||||
DEFAULT_AGENT_SERVER_ANALYTICS,
|
||||
type AgentServerUIAnalyticsConfig,
|
||||
type AgentServerUIPostHogAnalyticsConfig,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user