mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat: hide the users, billing, and integration pages for self-hosted customers (#13199)
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
const mockConfigWithFeatureFlags = (
|
||||
appMode: "saas" | "oss",
|
||||
featureFlags: Partial<WebClientFeatureFlags>,
|
||||
) => {
|
||||
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<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user