From 8039807c3f4c8bd8eeea96c2fe6bd87399285317 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:18:29 +0700 Subject: [PATCH] fix(frontend): scope organization data queries by organization ID (org project) (#13459) --- .../analytics-consent-form-modal.test.tsx | 7 +- .../features/chat/messages.test.tsx | 6 +- .../features/home/repo-connector.test.tsx | 2 + .../settings/api-keys-manager.test.tsx | 7 +- .../features/sidebar/sidebar.test.tsx | 7 +- .../components/interactive-chat-box.test.tsx | 21 +- .../context/ws-client-provider.test.tsx | 2 + .../conversation-websocket-handler.test.tsx | 5 + .../hooks/mutation/use-save-settings.test.tsx | 7 +- .../organization-scoped-queries.test.tsx | 225 ++++++++++++++++++ frontend/__tests__/routes/accept-tos.test.tsx | 6 +- .../__tests__/routes/app-settings.test.tsx | 7 +- .../utils/check-hardcoded-strings.test.tsx | 21 +- .../settings/secrets-settings/secret-form.tsx | 10 +- .../hooks/mutation/use-add-git-providers.ts | 6 +- .../src/hooks/mutation/use-add-mcp-server.ts | 6 +- .../src/hooks/mutation/use-create-api-key.ts | 6 +- .../src/hooks/mutation/use-delete-api-key.ts | 6 +- .../hooks/mutation/use-delete-mcp-server.ts | 6 +- .../src/hooks/mutation/use-save-settings.ts | 6 +- .../hooks/mutation/use-switch-organization.ts | 5 +- .../hooks/mutation/use-update-mcp-server.ts | 6 +- frontend/src/hooks/query/use-api-keys.ts | 6 +- frontend/src/hooks/query/use-get-secrets.ts | 6 +- frontend/src/hooks/query/use-settings.ts | 13 +- frontend/src/i18n/declaration.ts | 8 + frontend/src/routes/secrets-settings.tsx | 6 +- frontend/src/routes/settings.tsx | 2 - frontend/src/routes/user-settings.tsx | 12 +- frontend/vitest.setup.ts | 9 + 30 files changed, 391 insertions(+), 51 deletions(-) create mode 100644 frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx diff --git a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx index eb7c39397c..d1e0fbf5dc 100644 --- a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx +++ b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx @@ -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(); diff --git a/frontend/__tests__/components/features/chat/messages.test.tsx b/frontend/__tests__/components/features/chat/messages.test.tsx index 577f6db5a1..194ad1ce46 100644 --- a/frontend/__tests__/components/features/chat/messages.test.tsx +++ b/frontend/__tests__/components/features/chat/messages.test.tsx @@ -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()), 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 = { diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 17a43c75ed..4cba3850b4 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -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, diff --git a/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx b/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx index 6c3f9884a9..b2783c6363 100644 --- a/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx +++ b/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx @@ -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( diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index b83abbfeae..cf6ce1ff9b 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -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(); }); diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index bafa673731..ecb6623806 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -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()), + 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, diff --git a/frontend/__tests__/context/ws-client-provider.test.tsx b/frontend/__tests__/context/ws-client-provider.test.tsx index 55a27732fc..3e2ac11f23 100644 --- a/frontend/__tests__/context/ws-client-provider.test.tsx +++ b/frontend/__tests__/context/ws-client-provider.test.tsx @@ -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: { diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index 393d6f68f0..e3de4572db 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -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 diff --git a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx index d2a7c798c4..e3216beb3c 100644 --- a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx +++ b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx @@ -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(), { diff --git a/frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx b/frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx new file mode 100644 index 0000000000..a32ea3500a --- /dev/null +++ b/frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx @@ -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 }) => ( + {children} + ); + }; + + 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"); + }); + }); +}); diff --git a/frontend/__tests__/routes/accept-tos.test.tsx b/frontend/__tests__/routes/accept-tos.test.tsx index 7b15081485..2e1e48a1c7 100644 --- a/frontend/__tests__/routes/accept-tos.test.tsx +++ b/frontend/__tests__/routes/accept-tos.test.tsx @@ -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()), 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: "" }); }); diff --git a/frontend/__tests__/routes/app-settings.test.tsx b/frontend/__tests__/routes/app-settings.test.tsx index 7b42844246..a40d21d8e6 100644 --- a/frontend/__tests__/routes/app-settings.test.tsx +++ b/frontend/__tests__/routes/app-settings.test.tsx @@ -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(, { diff --git a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx index ff0de34962..7c0a4e592d 100644 --- a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx +++ b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx @@ -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()), + 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", () => ({ diff --git a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx index b67e105f41..9987faced2 100644 --- a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx +++ b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx @@ -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( - ["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 = ( diff --git a/frontend/src/hooks/mutation/use-add-git-providers.ts b/frontend/src/hooks/mutation/use-add-git-providers.ts index b7788b88c4..a6b7d85f8d 100644 --- a/frontend/src/hooks/mutation/use-add-git-providers.ts +++ b/frontend/src/hooks/mutation/use-add-git-providers.ts @@ -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, diff --git a/frontend/src/hooks/mutation/use-add-mcp-server.ts b/frontend/src/hooks/mutation/use-add-mcp-server.ts index c9aaf4e446..bb90890f0d 100644 --- a/frontend/src/hooks/mutation/use-add-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-add-mcp-server.ts @@ -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 => { @@ -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], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-create-api-key.ts b/frontend/src/hooks/mutation/use-create-api-key.ts index fd3c05c975..4ab31b53df 100644 --- a/frontend/src/hooks/mutation/use-create-api-key.ts +++ b/frontend/src/hooks/mutation/use-create-api-key.ts @@ -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 => 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], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-delete-api-key.ts b/frontend/src/hooks/mutation/use-delete-api-key.ts index 4f4b566fab..9932343ce6 100644 --- a/frontend/src/hooks/mutation/use-delete-api-key.ts +++ b/frontend/src/hooks/mutation/use-delete-api-key.ts @@ -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 => { @@ -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], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-delete-mcp-server.ts b/frontend/src/hooks/mutation/use-delete-mcp-server.ts index 43d1b2a7cc..03cdc7759d 100644 --- a/frontend/src/hooks/mutation/use-delete-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-delete-mcp-server.ts @@ -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 => { @@ -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], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index f335fd83ec..9ccfc04ca3 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -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) => { const settingsToSave: Partial = { @@ -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) => { @@ -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, diff --git a/frontend/src/hooks/mutation/use-switch-organization.ts b/frontend/src/hooks/mutation/use-switch-organization.ts index 45fadedaf4..32e0f7b189 100644 --- a/frontend/src/hooks/mutation/use-switch-organization.ts +++ b/frontend/src/hooks/mutation/use-switch-organization.ts @@ -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 diff --git a/frontend/src/hooks/mutation/use-update-mcp-server.ts b/frontend/src/hooks/mutation/use-update-mcp-server.ts index 558997b500..af2a9d173f 100644 --- a/frontend/src/hooks/mutation/use-update-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-update-mcp-server.ts @@ -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], + }); }, }); } diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts index 954e22ad26..2ff496253f 100644 --- a/frontend/src/hooks/query/use-api-keys.ts +++ b/frontend/src/hooks/query/use-api-keys.ts @@ -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 : []; diff --git a/frontend/src/hooks/query/use-get-secrets.ts b/frontend/src/hooks/query/use-get-secrets.ts index e89df3d149..9c402e1c39 100644 --- a/frontend/src/hooks/query/use-get-secrets.ts +++ b/frontend/src/hooks/query/use-get-secrets.ts @@ -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), }); }; diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 6c6d766b69..2c18569081 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -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 => { const settings = await SettingsService.getSettings(); @@ -27,9 +29,13 @@ const getSettingsQueryFn = async (): Promise => { 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, }, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index a66552ff3c..fe6f248cfa 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/routes/secrets-settings.tsx b/frontend/src/routes/secrets-settings.tsx index ec6a9c3a28..48cda5ecbb 100644 --- a/frontend/src/routes/secrets-settings.tsx +++ b/frontend/src/routes/secrets-settings.tsx @@ -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( - ["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) => { diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index cc1c3563c6..9082bea730 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -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(["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); } diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx index 3e40d104a8..6fd4372ecf 100644 --- a/frontend/src/routes/user-settings.tsx +++ b/frontend/src/routes/user-settings.tsx @@ -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) => { 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); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index c43fa03553..b96506baba 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -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()), + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + // Import the Zustand mock to enable automatic store resets vi.mock("zustand");