fix(frontend): scope organization data queries by organization ID (org project) (#13459)

This commit is contained in:
Hiep Le
2026-03-19 14:18:29 +07:00
committed by GitHub
parent a96760eea7
commit 8039807c3f
30 changed files with 391 additions and 51 deletions

View File

@@ -1,11 +1,16 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("AnalyticsConsentFormModal", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
it("should call saveUserSettings with consent", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();

View File

@@ -10,9 +10,12 @@ import {
import { OpenHandsObservation } from "#/types/core/observations";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("react-router", () => ({
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useParams: () => ({ conversationId: "123" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
let queryClient: QueryClient;
@@ -47,6 +50,7 @@ const renderMessages = ({
describe("Messages", () => {
beforeEach(() => {
queryClient = new QueryClient();
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const assistantMessage: AssistantMessageAction = {

View File

@@ -10,6 +10,7 @@ import OptionService from "#/api/option-service/option-service.api";
import { GitRepository } from "#/types/git";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
const renderRepoConnector = () => {
const mockRepoSelection = vi.fn();
@@ -65,6 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
];
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,

View File

@@ -1,7 +1,8 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the react-i18next
vi.mock("react-i18next", async () => {
@@ -37,6 +38,10 @@ vi.mock("#/hooks/query/use-api-keys", () => ({
}));
describe("ApiKeysManager", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const renderComponent = () => {
const queryClient = new QueryClient();
return render(

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
renderWithProviders,
createAxiosNotFoundErrorObject,
@@ -10,6 +10,7 @@ import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { WebClientConfig } from "#/api/option-service/option.types";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Helper to create mock config with sensible defaults
const createMockConfig = (
@@ -76,6 +77,10 @@ describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
vi.clearAllMocks();
});

View File

@@ -1,26 +1,25 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useConversationStore } from "#/stores/conversation-store";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
@@ -52,6 +51,10 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,

View File

@@ -7,6 +7,7 @@ import {
WsClientProvider,
useWsClient,
} from "#/context/ws-client-provider";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => {
@@ -56,6 +57,7 @@ function TestComponent() {
describe("WsClientProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => {
return { data: {

View File

@@ -40,6 +40,7 @@ import {
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
import { useEventStore } from "#/stores/use-event-store";
import { isV1Event } from "#/types/v1/type-guards";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock useUserConversation to return V1 conversation data
vi.mock("#/hooks/query/use-user-conversation", () => ({
@@ -62,6 +63,10 @@ beforeAll(() => {
mswServer.listen({ onUnhandledRequest: "bypass" });
});
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
mswServer.resetHandlers();
// Clean up any React components

View File

@@ -1,10 +1,15 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("useSaveSettings", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {

View File

@@ -0,0 +1,225 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useSettings } from "#/hooks/query/use-settings";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { useApiKeys } from "#/hooks/query/use-api-keys";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SecretsService } from "#/api/secrets-service";
import ApiKeysClient from "#/api/api-keys";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { app_mode: "saas" },
}),
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({
data: true,
}),
}));
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
useIsOnIntermediatePage: () => false,
}));
describe("Organization-scoped query hooks", () => {
let queryClient: QueryClient;
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
vi.clearAllMocks();
});
describe("useSettings", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const { result } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["settings", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["settings"]);
expect(oldKeyData).toBeUndefined();
});
it("should refetch when organization changes", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "en",
});
// First render with org-1
const { result, rerender } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
expect(getSettingsSpy).toHaveBeenCalledTimes(1);
// Change organization
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "es",
});
// Rerender to pick up the new org ID
rerender();
await waitFor(() => {
// Should have fetched again for the new org
expect(getSettingsSpy).toHaveBeenCalledTimes(2);
});
// Verify both org caches exist independently
const org1Data = queryClient.getQueryData(["settings", "org-1"]);
const org2Data = queryClient.getQueryData(["settings", "org-2"]);
expect(org1Data).toBeDefined();
expect(org2Data).toBeDefined();
});
});
describe("useGetSecrets", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
const { result } = renderHook(() => useGetSecrets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["secrets", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["secrets"]);
expect(oldKeyData).toBeUndefined();
});
it("should fetch different data when organization changes", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// Mock different secrets for different orgs
getSecretsSpy.mockResolvedValueOnce([
{ name: "SECRET_ORG_1", description: "Org 1 secret" },
]);
const { result, rerender } = renderHook(() => useGetSecrets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].name).toBe("SECRET_ORG_1");
// Change organization
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
getSecretsSpy.mockResolvedValueOnce([
{ name: "SECRET_ORG_2", description: "Org 2 secret" },
]);
rerender();
await waitFor(() => {
expect(result.current.data?.[0]?.name).toBe("SECRET_ORG_2");
});
});
});
describe("useApiKeys", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getApiKeysSpy = vi.spyOn(ApiKeysClient, "getApiKeys");
getApiKeysSpy.mockResolvedValue([]);
const { result } = renderHook(() => useApiKeys(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["api-keys", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["api-keys"]);
expect(oldKeyData).toBeUndefined();
});
});
describe("Cache isolation between organizations", () => {
it("should maintain separate caches for each organization", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
// Simulate fetching for org-1
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
language: "en",
});
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
const { rerender } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(queryClient.getQueryData(["settings", "org-1"])).toBeDefined();
});
// Switch to org-2
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
language: "fr",
});
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
rerender();
await waitFor(() => {
expect(queryClient.getQueryData(["settings", "org-2"])).toBeDefined();
});
// Switch back to org-1 - should use cached data, not refetch
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
rerender();
// org-1 data should still be in cache
const org1Cache = queryClient.getQueryData(["settings", "org-1"]) as any;
expect(org1Cache?.language).toBe("en");
// org-2 data should also still be in cache
const org2Cache = queryClient.getQueryData(["settings", "org-2"]) as any;
expect(org2Cache?.language).toBe("fr");
});
});
});

View File

@@ -5,9 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AcceptTOS from "#/routes/accept-tos";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import { openHands } from "#/api/open-hands-axios";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the react-router hooks
vi.mock("react-router", () => ({
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useSearchParams: () => [
{
@@ -19,6 +21,7 @@ vi.mock("react-router", () => ({
},
},
],
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock the axios instance
@@ -54,6 +57,7 @@ const createWrapper = () => {
describe("AcceptTOS", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
vi.stubGlobal("location", { href: "" });
});

View File

@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen, { clientLoader } from "#/routes/app-settings";
@@ -8,6 +8,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {

View File

@@ -1,8 +1,13 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { test, expect, describe, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -29,14 +34,12 @@ vi.mock("#/hooks/query/use-active-conversation", () => ({
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({

View File

@@ -10,6 +10,7 @@ import { BrandButton } from "../brand-button";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { OptionalTag } from "../optional-tag";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
interface SecretFormProps {
mode: "add" | "edit";
@@ -24,6 +25,7 @@ export function SecretForm({
}: SecretFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { organizationId } = useSelectedOrganizationId();
const { data: secrets } = useGetSecrets();
const { mutate: createSecret } = useCreateSecret();
@@ -49,7 +51,9 @@ export function SecretForm({
{
onSettled: onCancel,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
await queryClient.invalidateQueries({
queryKey: ["secrets", organizationId],
});
},
},
);
@@ -61,7 +65,7 @@ export function SecretForm({
description?: string,
) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
["secrets", organizationId],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.map((secret) => {
@@ -79,7 +83,7 @@ export function SecretForm({
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] });
};
const handleEditSecret = (

View File

@@ -2,10 +2,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
const { trackGitProviderConnected } = useTracking();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: ({
@@ -25,7 +27,9 @@ export const useAddGitProviders = () => {
});
}
await queryClient.invalidateQueries({ queryKey: ["settings"] });
await queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
meta: {
disableToast: true,

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -19,6 +20,7 @@ interface MCPServerConfig {
export function useAddMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (server: MCPServerConfig): Promise<void> => {
@@ -64,7 +66,9 @@ export function useAddMcpServer() {
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
});
}

View File

@@ -1,16 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys";
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export function useCreateApiKey() {
const queryClient = useQueryClient();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (name: string): Promise<CreateApiKeyResponse> =>
ApiKeysClient.createApiKey(name),
onSuccess: () => {
// Invalidate the API keys query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
queryClient.invalidateQueries({
queryKey: [API_KEYS_QUERY_KEY, organizationId],
});
},
});
}

View File

@@ -1,9 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys";
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export function useDeleteApiKey() {
const queryClient = useQueryClient();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (id: string): Promise<void> => {
@@ -11,7 +13,9 @@ export function useDeleteApiKey() {
},
onSuccess: () => {
// Invalidate the API keys query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
queryClient.invalidateQueries({
queryKey: [API_KEYS_QUERY_KEY, organizationId],
});
},
});
}

View File

@@ -2,10 +2,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPConfig } from "#/types/settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export function useDeleteMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (serverId: string): Promise<void> => {
@@ -32,7 +34,9 @@ export function useDeleteMcpServer() {
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
});
}

View File

@@ -4,6 +4,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { Settings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const settingsToSave: Partial<Settings> = {
@@ -30,6 +31,7 @@ export const useSaveSettings = () => {
const posthog = usePostHog();
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (settings: Partial<Settings>) => {
@@ -56,7 +58,9 @@ export const useSaveSettings = () => {
await saveSettingsMutationFn(newSettings);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
await queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
meta: {
disableToast: true,

View File

@@ -17,10 +17,9 @@ export const useSwitchOrganization = () => {
queryClient.invalidateQueries({
queryKey: ["organizations", orgId, "me"],
});
// Update local state
// Update local state - this triggers automatic refetch for all org-scoped queries
// since their query keys include organizationId (e.g., ["settings", orgId], ["secrets", orgId])
setOrganizationId(orgId);
// Invalidate settings for the new org context
queryClient.invalidateQueries({ queryKey: ["settings"] });
// Invalidate conversations to fetch data for the new org context
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
// Remove all individual conversation queries to clear any stale/null data

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -19,6 +20,7 @@ interface MCPServerConfig {
export function useUpdateMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async ({
@@ -66,7 +68,9 @@ export function useUpdateMcpServer() {
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
});
}

View File

@@ -1,15 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys";
import { useConfig } from "./use-config";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export const API_KEYS_QUERY_KEY = "api-keys";
export function useApiKeys() {
const { data: config } = useConfig();
const { organizationId } = useSelectedOrganizationId();
return useQuery({
queryKey: [API_KEYS_QUERY_KEY],
enabled: config?.app_mode === "saas",
queryKey: [API_KEYS_QUERY_KEY, organizationId],
enabled: config?.app_mode === "saas" && !!organizationId,
queryFn: async () => {
const keys = await ApiKeysClient.getApiKeys();
return Array.isArray(keys) ? keys : [];

View File

@@ -2,16 +2,18 @@ import { useQuery } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { useConfig } from "./use-config";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export const useGetSecrets = () => {
const { data: config } = useConfig();
const { data: isAuthed } = useIsAuthed();
const { organizationId } = useSelectedOrganizationId();
const isOss = config?.app_mode === "oss";
return useQuery({
queryKey: ["secrets"],
queryKey: ["secrets", organizationId],
queryFn: SecretsService.getSecrets,
enabled: isOss || isAuthed, // Enable regardless of providers
enabled: isOss || (isAuthed && !!organizationId),
});
};

View File

@@ -4,6 +4,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { useConfig } from "./use-config";
const getSettingsQueryFn = async (): Promise<Settings> => {
const settings = await SettingsService.getSettings();
@@ -27,9 +29,13 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
export const useSettings = () => {
const isOnIntermediatePage = useIsOnIntermediatePage();
const { data: userIsAuthenticated } = useIsAuthed();
const { organizationId } = useSelectedOrganizationId();
const { data: config } = useConfig();
const isOss = config?.app_mode === "oss";
const query = useQuery({
queryKey: ["settings"],
queryKey: ["settings", organizationId],
queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
@@ -38,7 +44,10 @@ export const useSettings = () => {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnIntermediatePage && !!userIsAuthenticated,
enabled:
!isOnIntermediatePage &&
!!userIsAuthenticated &&
(isOss || !!organizationId),
meta: {
disableToast: true,
},

View File

@@ -1084,6 +1084,14 @@ export enum I18nKey {
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE",
ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE",
ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER",
ONBOARDING$ENGINEERING_MANAGER = "ONBOARDING$ENGINEERING_MANAGER",
ONBOARDING$CTO_FOUNDER = "ONBOARDING$CTO_FOUNDER",
ONBOARDING$PRODUCT_OPERATIONS = "ONBOARDING$PRODUCT_OPERATIONS",
ONBOARDING$STUDENT_HOBBYIST = "ONBOARDING$STUDENT_HOBBYIST",
ONBOARDING$OTHER = "ONBOARDING$OTHER",
HOOKS_MODAL$TITLE = "HOOKS_MODAL$TITLE",
HOOKS_MODAL$WARNING = "HOOKS_MODAL$WARNING",
HOOKS_MODAL$MATCHER = "HOOKS_MODAL$MATCHER",

View File

@@ -13,12 +13,14 @@ import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { I18nKey } from "#/i18n/declaration";
import { createPermissionGuard } from "#/utils/org/permission-guard";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export const clientLoader = createPermissionGuard("manage_secrets");
function SecretsSettingsScreen() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { organizationId } = useSelectedOrganizationId();
const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets();
const { mutate: deleteSecret } = useDeleteSecret();
@@ -34,7 +36,7 @@ function SecretsSettingsScreen() {
const deleteSecretOptimistically = (secret: string) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
["secrets", organizationId],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.filter((s) => s.name !== secret);
@@ -43,7 +45,7 @@ function SecretsSettingsScreen() {
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] });
};
const handleDeleteSecret = (secret: string) => {

View File

@@ -30,7 +30,6 @@ const SAAS_ONLY_PATHS = [
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
const { pathname } = url;
console.log("clientLoader", { pathname });
// Step 1: Get config first (needed for all checks, no user data required)
let config = queryClient.getQueryData<WebClientConfig>(["web-client-config"]);
@@ -51,7 +50,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
// This handles hide_llm_settings, hide_users_page, hide_billing_page, hide_integrations_page
if (isSettingsPageHidden(pathname, featureFlags)) {
const fallbackPath = getFirstAvailablePath(isSaas, featureFlags);
console.log("fallbackPath", fallbackPath);
if (fallbackPath && fallbackPath !== pathname) {
return redirect(fallbackPath);
}

View File

@@ -5,6 +5,7 @@ import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useEmailVerification } from "#/hooks/use-email-verification";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
@@ -113,6 +114,7 @@ function VerificationAlert() {
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading, refetch } = useSettings();
const { organizationId } = useSelectedOrganizationId();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -144,7 +146,9 @@ function UserSettingsScreen() {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
}, 2000);
}
@@ -162,7 +166,7 @@ function UserSettingsScreen() {
pollingIntervalRef.current = null;
}
};
}, [settings?.email_verified, refetch, queryClient, t]);
}, [settings?.email_verified, refetch, queryClient, t, organizationId]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
@@ -178,7 +182,9 @@ function UserSettingsScreen() {
setOriginalEmail(email);
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);

View File

@@ -36,6 +36,15 @@ vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
useIsOnIntermediatePage: () => false,
}));
// Mock useRevalidator from react-router to allow direct store manipulation
// in tests instead of mocking useSelectedOrganizationId hook
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useRevalidator: () => ({
revalidate: vi.fn(),
}),
}));
// Import the Zustand mock to enable automatic store resets
vi.mock("zustand");