From 6f8bf24226363b6d0e8545977af914491f6f0c6d Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:24:06 +0700 Subject: [PATCH] feat: hide the users, billing, and integration pages for self-hosted customers (#13199) --- .../chat/expandable-message.test.tsx | 3 + .../features/payment/payment-form.test.tsx | 3 + .../features/sidebar/sidebar.test.tsx | 3 + frontend/__tests__/helpers/mock-config.ts | 3 + .../hooks/use-settings-nav-items.test.tsx | 183 +++++++ frontend/__tests__/routes/_oh.test.tsx | 12 + .../__tests__/routes/git-settings.test.tsx | 6 + .../__tests__/routes/home-screen.test.tsx | 3 + .../__tests__/routes/llm-settings.test.tsx | 3 + frontend/__tests__/routes/login.test.tsx | 12 + .../routes/root-layout-refetch.test.tsx | 3 + .../__tests__/routes/root-layout.test.tsx | 3 + .../routes/settings-with-payment.test.tsx | 9 + frontend/__tests__/routes/settings.test.tsx | 490 +++++++++++++++++- .../src/api/option-service/option.types.ts | 3 + frontend/src/hooks/use-settings-nav-items.ts | 7 +- frontend/src/mocks/settings-handlers.ts | 3 + frontend/src/routes/settings.tsx | 81 ++- .../default_web_client_config_injector.py | 8 +- .../web_client/web_client_models.py | 3 + 20 files changed, 808 insertions(+), 33 deletions(-) diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index fcabd84d94..d55e926450 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -122,6 +122,9 @@ describe("ExpandableMessage", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); const RouterStub = createRoutesStub([ diff --git a/frontend/__tests__/components/features/payment/payment-form.test.tsx b/frontend/__tests__/components/features/payment/payment-form.test.tsx index 7b54876d2c..48979d3c52 100644 --- a/frontend/__tests__/components/features/payment/payment-form.test.tsx +++ b/frontend/__tests__/components/features/payment/payment-form.test.tsx @@ -38,6 +38,9 @@ describe("PaymentForm", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); }); diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index daacfe02e2..b83abbfeae 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -27,6 +27,9 @@ const createMockConfig = ( enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, ...featureFlagOverrides, }, providers_configured: [], diff --git a/frontend/__tests__/helpers/mock-config.ts b/frontend/__tests__/helpers/mock-config.ts index fa0b03b96d..36141a4773 100644 --- a/frontend/__tests__/helpers/mock-config.ts +++ b/frontend/__tests__/helpers/mock-config.ts @@ -15,6 +15,9 @@ export const createMockWebClientConfig = ( enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, ...overrides.feature_flags, }, providers_configured: [], diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx index a5bf00d14f..43205ff9d5 100644 --- a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; import OptionService from "#/api/option-service/option-service.api"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; +import { WebClientFeatureFlags } from "#/api/option-service/option.types"; const queryClient = new QueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -17,6 +18,26 @@ const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => { } as Awaited>); }; +const mockConfigWithFeatureFlags = ( + appMode: "saas" | "oss", + featureFlags: Partial, +) => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue({ + app_mode: appMode, + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + ...featureFlags, + }, + } as Awaited>); +}; + describe("useSettingsNavItems", () => { beforeEach(() => { queryClient.clear(); @@ -50,4 +71,166 @@ describe("useSettingsNavItems", () => { ).toBeUndefined(); }); }); + + describe("hide page feature flags", () => { + it("should filter out '/settings/user' when hide_users_page is true", async () => { + mockConfigWithFeatureFlags("saas", { hide_users_page: true }); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeUndefined(); + // Other pages should still be present + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeDefined(); + }); + }); + + it("should filter out '/settings/billing' when hide_billing_page is true", async () => { + mockConfigWithFeatureFlags("saas", { hide_billing_page: true }); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeUndefined(); + // Other pages should still be present + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeDefined(); + }); + }); + + it("should filter out '/settings/integrations' when hide_integrations_page is true", async () => { + mockConfigWithFeatureFlags("saas", { hide_integrations_page: true }); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeUndefined(); + // Other pages should still be present + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeDefined(); + }); + }); + + it("should filter out multiple pages when multiple flags are true", async () => { + mockConfigWithFeatureFlags("saas", { + hide_users_page: true, + hide_billing_page: true, + hide_integrations_page: true, + }); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeUndefined(); + // Non-hidden pages should still be present + expect( + result.current.find((item) => item.to === "/settings"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/app"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/secrets"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/mcp"), + ).toBeDefined(); + }); + }); + + it("should keep all pages visible when no hide flags are set", async () => { + mockConfigWithFeatureFlags("saas", {}); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + // All SAAS pages should be present + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/app"), + ).toBeDefined(); + }); + }); + + it("should filter out '/settings/integrations' in OSS mode when hide_integrations_page is true", async () => { + mockConfigWithFeatureFlags("oss", { hide_integrations_page: true }); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeUndefined(); + // Other OSS pages should still be present + expect( + result.current.find((item) => item.to === "/settings"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/mcp"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/app"), + ).toBeDefined(); + }); + }); + + it("should filter out both LLM and integrations when both flags are true in OSS mode", async () => { + mockConfigWithFeatureFlags("oss", { + hide_llm_settings: true, + hide_integrations_page: true, + }); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/integrations"), + ).toBeUndefined(); + // Other OSS pages should still be present + expect( + result.current.find((item) => item.to === "/settings/mcp"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/app"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/secrets"), + ).toBeDefined(); + }); + }); + }); }); diff --git a/frontend/__tests__/routes/_oh.test.tsx b/frontend/__tests__/routes/_oh.test.tsx index 6d441012d9..c12f278356 100644 --- a/frontend/__tests__/routes/_oh.test.tsx +++ b/frontend/__tests__/routes/_oh.test.tsx @@ -22,6 +22,9 @@ describe("frontend/routes/_oh", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }; return { @@ -139,6 +142,9 @@ describe("frontend/routes/_oh", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); @@ -177,6 +183,9 @@ describe("frontend/routes/_oh", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); useConfigMock.mockReturnValue({ @@ -265,6 +274,9 @@ describe("frontend/routes/_oh", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); useConfigMock.mockReturnValue({ diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 2f903aa3a2..0766790579 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -24,6 +24,9 @@ const VALID_OSS_CONFIG: WebClientConfig = { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, providers_configured: [], maintenance_start_time: null, @@ -44,6 +47,9 @@ const VALID_SAAS_CONFIG: WebClientConfig = { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, providers_configured: [], maintenance_start_time: null, diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index b3f037989f..df672c6343 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -21,6 +21,9 @@ const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted( enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }; return { diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 77ac4122c9..82d2085fe8 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -1020,6 +1020,9 @@ describe("View persistence after saving advanced settings", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); diff --git a/frontend/__tests__/routes/login.test.tsx b/frontend/__tests__/routes/login.test.tsx index 2b63a8c98e..35751a591e 100644 --- a/frontend/__tests__/routes/login.test.tsx +++ b/frontend/__tests__/routes/login.test.tsx @@ -115,6 +115,9 @@ describe("LoginPage", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); @@ -179,6 +182,9 @@ describe("LoginPage", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); @@ -215,6 +221,9 @@ describe("LoginPage", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); @@ -349,6 +358,9 @@ describe("LoginPage", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); diff --git a/frontend/__tests__/routes/root-layout-refetch.test.tsx b/frontend/__tests__/routes/root-layout-refetch.test.tsx index 3ab626d66e..cfbc640275 100644 --- a/frontend/__tests__/routes/root-layout-refetch.test.tsx +++ b/frontend/__tests__/routes/root-layout-refetch.test.tsx @@ -26,6 +26,9 @@ const DEFAULT_FEATURE_FLAGS = { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }; const RouterStub = createRoutesStub([ diff --git a/frontend/__tests__/routes/root-layout.test.tsx b/frontend/__tests__/routes/root-layout.test.tsx index 107841c71d..0183553534 100644 --- a/frontend/__tests__/routes/root-layout.test.tsx +++ b/frontend/__tests__/routes/root-layout.test.tsx @@ -181,6 +181,9 @@ describe("MainApp", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index cec3a0a67c..0131b86d43 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -73,6 +73,9 @@ describe("Settings Billing", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }, isLoading: false, @@ -128,6 +131,9 @@ describe("Settings Billing", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }, isLoading: false, @@ -152,6 +158,9 @@ describe("Settings Billing", () => { enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, }, isLoading: false, diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index ec2cf0974c..c39f389c3c 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -1,9 +1,30 @@ import { render, screen, within } from "@testing-library/react"; import { createRoutesStub } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClientProvider } from "@tanstack/react-query"; -import SettingsScreen, { clientLoader } from "#/routes/settings"; +import SettingsScreen, { + clientLoader, + getFirstAvailablePath, +} from "#/routes/settings"; import OptionService from "#/api/option-service/option-service.api"; +import { WebClientFeatureFlags } from "#/api/option-service/option.types"; + +// Module-level mocks using vi.hoisted +const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({ + handleLogoutMock: vi.fn(), + mockQueryClient: (() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient(); + })(), +})); + +vi.mock("#/hooks/use-app-logout", () => ({ + useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }), +})); + +vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, +})); // Mock the i18next hook vi.mock("react-i18next", async () => { @@ -22,7 +43,9 @@ vi.mock("react-i18next", async () => { SETTINGS$NAV_SECRETS: "Secrets", SETTINGS$NAV_MCP: "MCP", SETTINGS$NAV_USER: "User", + SETTINGS$NAV_BILLING: "Billing", SETTINGS$TITLE: "Settings", + COMMON$LANGUAGE_MODEL_LLM: "LLM", }; return translations[key] || key; }, @@ -34,22 +57,6 @@ vi.mock("react-i18next", async () => { }); describe("Settings Screen", () => { - const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({ - handleLogoutMock: vi.fn(), - mockQueryClient: (() => { - const { QueryClient } = require("@tanstack/react-query"); - return new QueryClient(); - })(), - })); - - vi.mock("#/hooks/use-app-logout", () => ({ - useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }), - })); - - vi.mock("#/query-client-config", () => ({ - queryClient: mockQueryClient, - })); - const RouterStub = createRoutesStub([ { Component: SettingsScreen, @@ -192,4 +199,451 @@ describe("Settings Screen", () => { }); it.todo("should not be able to access oss-only routes in saas mode"); + + describe("hide page feature flags", () => { + it("should hide users page in navbar when hide_users_page is true", async () => { + const saasConfig = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: true, + hide_billing_page: false, + hide_integrations_page: false, + }, + }; + + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], saasConfig); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect( + within(navbar).queryByText("User", { exact: false }), + ).not.toBeInTheDocument(); + // Other pages should still be visible + expect( + within(navbar).getByText("Integrations", { exact: false }), + ).toBeInTheDocument(); + expect( + within(navbar).getByText("Billing", { exact: false }), + ).toBeInTheDocument(); + }); + + it("should hide billing page in navbar when hide_billing_page is true", async () => { + const saasConfig = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: true, + hide_integrations_page: false, + }, + }; + + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], saasConfig); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect( + within(navbar).queryByText("Billing", { exact: false }), + ).not.toBeInTheDocument(); + // Other pages should still be visible + expect( + within(navbar).getByText("User", { exact: false }), + ).toBeInTheDocument(); + expect( + within(navbar).getByText("Integrations", { exact: false }), + ).toBeInTheDocument(); + }); + + it("should hide integrations page in navbar when hide_integrations_page is true", async () => { + const saasConfig = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: true, + }, + }; + + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], saasConfig); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect( + within(navbar).queryByText("Integrations", { exact: false }), + ).not.toBeInTheDocument(); + // Other pages should still be visible + expect( + within(navbar).getByText("User", { exact: false }), + ).toBeInTheDocument(); + expect( + within(navbar).getByText("Billing", { exact: false }), + ).toBeInTheDocument(); + }); + + it("should hide multiple pages when multiple flags are true", async () => { + const saasConfig = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: true, + hide_billing_page: true, + hide_integrations_page: true, + }, + }; + + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], saasConfig); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect( + within(navbar).queryByText("User", { exact: false }), + ).not.toBeInTheDocument(); + expect( + within(navbar).queryByText("Billing", { exact: false }), + ).not.toBeInTheDocument(); + expect( + within(navbar).queryByText("Integrations", { exact: false }), + ).not.toBeInTheDocument(); + // Other pages should still be visible + expect( + within(navbar).getByText("Application", { exact: false }), + ).toBeInTheDocument(); + expect( + within(navbar).getByText("LLM", { exact: false }), + ).toBeInTheDocument(); + }); + + it("should hide integrations page in OSS mode when hide_integrations_page is true", async () => { + const ossConfig = { + app_mode: "oss", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: true, + }, + }; + + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], ossConfig); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect( + within(navbar).queryByText("Integrations", { exact: false }), + ).not.toBeInTheDocument(); + // Other OSS pages should still be visible + expect( + within(navbar).getByText("LLM", { exact: false }), + ).toBeInTheDocument(); + expect( + within(navbar).getByText("Application", { exact: false }), + ).toBeInTheDocument(); + }); + }); +}); + +describe("getFirstAvailablePath", () => { + const baseFeatureFlags: WebClientFeatureFlags = { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }; + + describe("SaaS mode", () => { + it("should return /settings/user when no pages are hidden", () => { + const result = getFirstAvailablePath(true, baseFeatureFlags); + expect(result).toBe("/settings/user"); + }); + + it("should return /settings/integrations when users page is hidden", () => { + const flags = { ...baseFeatureFlags, hide_users_page: true }; + const result = getFirstAvailablePath(true, flags); + expect(result).toBe("/settings/integrations"); + }); + + it("should return /settings/app when users and integrations are hidden", () => { + const flags = { + ...baseFeatureFlags, + hide_users_page: true, + hide_integrations_page: true, + }; + const result = getFirstAvailablePath(true, flags); + expect(result).toBe("/settings/app"); + }); + + it("should return /settings/app when users, integrations, and LLM settings are hidden", () => { + const flags = { + ...baseFeatureFlags, + hide_users_page: true, + hide_integrations_page: true, + hide_llm_settings: true, + }; + const result = getFirstAvailablePath(true, flags); + expect(result).toBe("/settings/app"); + }); + + it("should return /settings/app when users, integrations, LLM, and billing are hidden", () => { + const flags = { + ...baseFeatureFlags, + hide_users_page: true, + hide_integrations_page: true, + hide_llm_settings: true, + hide_billing_page: true, + }; + // /settings/app is never hidden, so it should return that + const result = getFirstAvailablePath(true, flags); + expect(result).toBe("/settings/app"); + }); + + it("should handle undefined feature flags", () => { + const result = getFirstAvailablePath(true, undefined); + expect(result).toBe("/settings/user"); + }); + }); + + describe("OSS mode", () => { + it("should return /settings when no pages are hidden", () => { + const result = getFirstAvailablePath(false, baseFeatureFlags); + expect(result).toBe("/settings"); + }); + + it("should return /settings/mcp when LLM settings is hidden", () => { + const flags = { ...baseFeatureFlags, hide_llm_settings: true }; + const result = getFirstAvailablePath(false, flags); + expect(result).toBe("/settings/mcp"); + }); + + it("should return /settings/mcp when LLM settings and integrations are hidden", () => { + const flags = { + ...baseFeatureFlags, + hide_llm_settings: true, + hide_integrations_page: true, + }; + const result = getFirstAvailablePath(false, flags); + expect(result).toBe("/settings/mcp"); + }); + + it("should handle undefined feature flags", () => { + const result = getFirstAvailablePath(false, undefined); + expect(result).toBe("/settings"); + }); + }); +}); + +describe("clientLoader redirect behavior", () => { + const createMockRequest = (pathname: string) => ({ + request: new Request(`http://localhost${pathname}`), + }); + + beforeEach(() => { + mockQueryClient.clear(); + }); + + it("should redirect from /settings/user to first available page when hide_users_page is true", async () => { + const config = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: true, + hide_billing_page: false, + hide_integrations_page: false, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + const result = await clientLoader( + createMockRequest("/settings/user") as any, + ); + + expect(result).toBeDefined(); + expect(result?.status).toBe(302); + expect(result?.headers.get("Location")).toBe("/settings/integrations"); + }); + + it("should redirect from /settings/billing to first available page when hide_billing_page is true", async () => { + const config = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: true, + hide_integrations_page: false, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + const result = await clientLoader( + createMockRequest("/settings/billing") as any, + ); + + expect(result).toBeDefined(); + expect(result?.status).toBe(302); + expect(result?.headers.get("Location")).toBe("/settings/user"); + }); + + it("should redirect from /settings/integrations to first available page when hide_integrations_page is true", async () => { + const config = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: true, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + const result = await clientLoader( + createMockRequest("/settings/integrations") as any, + ); + + expect(result).toBeDefined(); + expect(result?.status).toBe(302); + expect(result?.headers.get("Location")).toBe("/settings/user"); + }); + + it("should redirect from /settings to /settings/app when LLM, users, and integrations are all hidden", async () => { + const config = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: true, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: true, + hide_billing_page: false, + hide_integrations_page: true, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + const result = await clientLoader(createMockRequest("/settings") as any); + + expect(result).toBeDefined(); + expect(result?.status).toBe(302); + expect(result?.headers.get("Location")).toBe("/settings/app"); + }); + + it("should redirect from /settings to /settings/mcp in OSS mode when LLM settings is hidden", async () => { + const config = { + app_mode: "oss", + feature_flags: { + enable_billing: false, + hide_llm_settings: true, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + const result = await clientLoader(createMockRequest("/settings") as any); + + expect(result).toBeDefined(); + expect(result?.status).toBe(302); + expect(result?.headers.get("Location")).toBe("/settings/mcp"); + }); + + it("should not redirect when accessing a non-hidden page", async () => { + const config = { + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: true, + hide_billing_page: true, + hide_integrations_page: true, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + // /settings/app is never hidden + const result = await clientLoader( + createMockRequest("/settings/app") as any, + ); + + expect(result).toBeNull(); + }); + + it("should redirect from /settings/integrations in OSS mode when hide_integrations_page is true", async () => { + const config = { + app_mode: "oss", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: true, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], config); + + const result = await clientLoader( + createMockRequest("/settings/integrations") as any, + ); + + expect(result).toBeDefined(); + expect(result?.status).toBe(302); + // In OSS mode, first available is /settings (LLM) + expect(result?.headers.get("Location")).toBe("/settings"); + }); }); diff --git a/frontend/src/api/option-service/option.types.ts b/frontend/src/api/option-service/option.types.ts index e0d01bb212..45fcdb2586 100644 --- a/frontend/src/api/option-service/option.types.ts +++ b/frontend/src/api/option-service/option.types.ts @@ -6,6 +6,9 @@ export interface WebClientFeatureFlags { enable_jira: boolean; enable_jira_dc: boolean; enable_linear: boolean; + hide_users_page: boolean; + hide_billing_page: boolean; + hide_integrations_page: boolean; } export interface WebClientConfig { diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts index a0a0d02503..fa0187251d 100644 --- a/frontend/src/hooks/use-settings-nav-items.ts +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -1,15 +1,14 @@ import { useConfig } from "#/hooks/query/use-config"; import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import { isSettingsPageHidden } from "#/routes/settings"; export function useSettingsNavItems() { const { data: config } = useConfig(); - const shouldHideLlmSettings = !!config?.feature_flags?.hide_llm_settings; const isSaasMode = config?.app_mode === "saas"; + const featureFlags = config?.feature_flags; const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; - return shouldHideLlmSettings - ? items.filter((item) => item.to !== "/settings") - : items; + return items.filter((item) => !isSettingsPageHidden(item.to, featureFlags)); } diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts index 1b9b34e841..e33a781c1d 100644 --- a/frontend/src/mocks/settings-handlers.ts +++ b/frontend/src/mocks/settings-handlers.ts @@ -78,6 +78,9 @@ export const SETTINGS_HANDLERS = [ enable_jira: false, enable_jira_dc: false, enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, }, providers_configured: [], maintenance_start_time: null, diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 8ccad39907..b617e43549 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -4,7 +4,10 @@ import { useTranslation } from "react-i18next"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; -import { WebClientConfig } from "#/api/option-service/option.types"; +import { + WebClientConfig, + WebClientFeatureFlags, +} from "#/api/option-service/option.types"; import { SettingsLayout } from "#/components/features/settings/settings-layout"; import { Typography } from "#/ui/typography"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; @@ -16,6 +19,62 @@ const SAAS_ONLY_PATHS = [ "/settings/api-keys", ]; +/** + * Checks if a settings page should be hidden based on feature flags. + * Used by both the route loader and navigation hook to keep logic in sync. + */ +export function isSettingsPageHidden( + path: string, + featureFlags: WebClientFeatureFlags | undefined, +): boolean { + if (featureFlags?.hide_llm_settings && path === "/settings") return true; + if (featureFlags?.hide_users_page && path === "/settings/user") return true; + if (featureFlags?.hide_billing_page && path === "/settings/billing") + return true; + if (featureFlags?.hide_integrations_page && path === "/settings/integrations") + return true; + return false; +} + +/** + * Find the first available settings page that is not hidden. + * Returns null if no page is available (shouldn't happen in practice). + */ +export function getFirstAvailablePath( + isSaas: boolean, + featureFlags: WebClientFeatureFlags | undefined, +): string | null { + const saasFallbackOrder = [ + { path: "/settings/user", hidden: !!featureFlags?.hide_users_page }, + { + path: "/settings/integrations", + hidden: !!featureFlags?.hide_integrations_page, + }, + { path: "/settings/app", hidden: false }, + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/billing", hidden: !!featureFlags?.hide_billing_page }, + { path: "/settings/secrets", hidden: false }, + { path: "/settings/api-keys", hidden: false }, + { path: "/settings/mcp", hidden: false }, + ]; + + const ossFallbackOrder = [ + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/mcp", hidden: false }, + { + path: "/settings/integrations", + hidden: !!featureFlags?.hide_integrations_page, + }, + { path: "/settings/app", hidden: false }, + { path: "/settings/secrets", hidden: false }, + ]; + + const fallbackOrder = isSaas ? saasFallbackOrder : ossFallbackOrder; + const firstAvailable = fallbackOrder.find((item) => !item.hidden); + + return firstAvailable?.path ?? null; +} + export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; @@ -27,15 +86,19 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { } const isSaas = config?.app_mode === "saas"; + const featureFlags = config?.feature_flags; - if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) { - // if in OSS mode, do not allow access to saas-only paths - return redirect("/settings"); - } - // If LLM settings are hidden and user tries to access the LLM settings page - if (config?.feature_flags?.hide_llm_settings && pathname === "/settings") { - // Redirect to the first available settings page - return isSaas ? redirect("/settings/user") : redirect("/settings/mcp"); + // Check if current page should be hidden and redirect to first available page + const isHiddenPage = + (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) || + isSettingsPageHidden(pathname, featureFlags); + + if (isHiddenPage) { + const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + if (fallbackPath && fallbackPath !== pathname) { + return redirect(fallbackPath); + } + // If no fallback available or same as current, stay on current page } return null; diff --git a/openhands/app_server/web_client/default_web_client_config_injector.py b/openhands/app_server/web_client/default_web_client_config_injector.py index 6a5982d47c..fe91d9fae0 100644 --- a/openhands/app_server/web_client/default_web_client_config_injector.py +++ b/openhands/app_server/web_client/default_web_client_config_injector.py @@ -95,8 +95,9 @@ def _get_feature_flags() -> WebClientFeatureFlags: """Get feature flags from environment variables. Reads ENABLE_BILLING, HIDE_LLM_SETTINGS, ENABLE_JIRA, ENABLE_JIRA_DC, - and ENABLE_LINEAR from environment. Each flag is True only if the - corresponding env var is exactly 'true', otherwise False. + ENABLE_LINEAR, HIDE_USERS_PAGE, HIDE_BILLING_PAGE, and HIDE_INTEGRATIONS_PAGE + from environment. Each flag is True only if the corresponding env var is + exactly 'true', otherwise False. """ return WebClientFeatureFlags( enable_billing=os.getenv('ENABLE_BILLING', 'false') == 'true', @@ -104,6 +105,9 @@ def _get_feature_flags() -> WebClientFeatureFlags: enable_jira=os.getenv('ENABLE_JIRA', 'false') == 'true', enable_jira_dc=os.getenv('ENABLE_JIRA_DC', 'false') == 'true', enable_linear=os.getenv('ENABLE_LINEAR', 'false') == 'true', + hide_users_page=os.getenv('HIDE_USERS_PAGE', 'false') == 'true', + hide_billing_page=os.getenv('HIDE_BILLING_PAGE', 'false') == 'true', + hide_integrations_page=os.getenv('HIDE_INTEGRATIONS_PAGE', 'false') == 'true', ) diff --git a/openhands/app_server/web_client/web_client_models.py b/openhands/app_server/web_client/web_client_models.py index f6d176f4f9..a42a397a9e 100644 --- a/openhands/app_server/web_client/web_client_models.py +++ b/openhands/app_server/web_client/web_client_models.py @@ -13,6 +13,9 @@ class WebClientFeatureFlags(BaseModel): enable_jira: bool = False enable_jira_dc: bool = False enable_linear: bool = False + hide_users_page: bool = False + hide_billing_page: bool = False + hide_integrations_page: bool = False class WebClientConfig(DiscriminatedUnionMixin):