feat: hide the users, billing, and integration pages for self-hosted customers (#13199)

This commit is contained in:
Hiep Le
2026-03-05 01:24:06 +07:00
committed by GitHub
parent 6e9e906946
commit 6f8bf24226
20 changed files with 808 additions and 33 deletions

View File

@@ -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([

View File

@@ -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,
},
});
});

View File

@@ -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: [],

View File

@@ -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: [],

View File

@@ -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();
});
});
});
});

View File

@@ -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({

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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([

View File

@@ -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,
},
});

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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',
)

View File

@@ -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):