diff --git a/frontend/__tests__/components/features/user/user-context-menu.test.tsx b/frontend/__tests__/components/features/user/user-context-menu.test.tsx index ea022efeea..a1953bbb03 100644 --- a/frontend/__tests__/components/features/user/user-context-menu.test.tsx +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -192,12 +192,21 @@ describe("UserContextMenu", () => { renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); - // Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out) + // In SaaS, personal LLM/Condenser/Verification routes are hidden in favor + // of /settings/org-defaults/* (visible only when an org is selected, which + // this test does not seed). Org-only and billing routes are also filtered. + const personalLlmPaths = new Set([ + "/settings", + "/settings/condenser", + "/settings/verification", + ]); const expectedItems = SAAS_NAV_ITEMS.filter( (item) => item.to !== "/settings/org-members" && item.to !== "/settings/org" && - item.to !== "/settings/billing", + item.to !== "/settings/billing" && + !item.to.startsWith("/settings/org-defaults") && + !personalLlmPaths.has(item.to), ); await waitFor(() => { @@ -366,6 +375,15 @@ describe("UserContextMenu", () => { }, }), ); + // The LLM nav item now lives under /settings/org-defaults, which only + // appears when an org is selected. Seed a personal org for that. + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ + organizationId: MOCK_PERSONAL_ORG.id, + }); renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx index f7e39c5c22..9e435189ca 100644 --- a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -113,8 +113,12 @@ describe("useSettingsNavItems", () => { expect(findItemByPath(result.current, "/settings/billing")).toBeUndefined(); expect(findItemByPath(result.current, "/settings/org")).toBeUndefined(); expect(findItemByPath(result.current, "/settings/org-members")).toBeUndefined(); - // But should see other items - expect(findItemByPath(result.current, "/settings")).toBeDefined(); + // Personal LLM/Condenser/Verification routes are hidden in SaaS; + // members see the org-defaults equivalents (read-only on the page itself). + expect(findItemByPath(result.current, "/settings")).toBeUndefined(); + expect( + findItemByPath(result.current, "/settings/org-defaults"), + ).toBeDefined(); expect(findItemByPath(result.current, "/settings/user")).toBeDefined(); }); }); @@ -375,9 +379,13 @@ describe("useSettingsNavItems", () => { expect( findItemByPath(result.current, "/settings/integrations"), ).toBeUndefined(); - // Non-hidden pages should still be present + // Personal LLM is hidden in SaaS; the org-defaults equivalent + // shows up instead (an org is selected in this test's setup). expect( findItemByPath(result.current, "/settings"), + ).toBeUndefined(); + expect( + findItemByPath(result.current, "/settings/org-defaults"), ).toBeDefined(); expect( findItemByPath(result.current, "/settings/app"), @@ -406,8 +414,12 @@ describe("useSettingsNavItems", () => { expect( findItemByPath(result.current, "/settings/integrations"), ).toBeDefined(); + // Personal LLM is hidden in SaaS; users see /settings/org-defaults instead. expect( findItemByPath(result.current, "/settings"), + ).toBeUndefined(); + expect( + findItemByPath(result.current, "/settings/org-defaults"), ).toBeDefined(); expect( findItemByPath(result.current, "/settings/app"), diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 0106dbb6e9..e99705653c 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -554,7 +554,7 @@ describe("LlmSettingsScreen", () => { ).toBeInTheDocument(); }); - it("should not show info message for personal workspace", async () => { + it("shows the personal info message for personal workspace in SaaS mode", async () => { vi.spyOn(SettingsService, "getSettings").mockResolvedValue( buildSettings(), ); @@ -566,10 +566,9 @@ describe("LlmSettingsScreen", () => { organizations: [buildOrganization({ id: "1", is_personal: true })], }); - await screen.findByTestId("llm-settings-screen"); expect( - screen.queryByTestId("llm-settings-info-message"), - ).not.toBeInTheDocument(); + await screen.findByTestId("llm-settings-info-message"), + ).toBeInTheDocument(); }); it("should not show info message in OSS mode", async () => { diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index 24c286270c..f04319af7f 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -122,6 +122,32 @@ describe("Settings Screen", () => { Component: () =>
, path: "/settings/org", }, + { + Component: () =>
, + path: "/settings/condenser", + }, + { + Component: () =>
, + path: "/settings/verification", + }, + { + Component: () => ( +
+ ), + path: "/settings/org-defaults", + }, + { + Component: () => ( +
+ ), + path: "/settings/org-defaults/condenser", + }, + { + Component: () => ( +
+ ), + path: "/settings/org-defaults/verification", + }, ], }, ]); @@ -792,6 +818,95 @@ describe("Settings Screen", () => { ).toBeInTheDocument(); }); }); + + describe("OrgWideSettingsBadge display", () => { + const seedSaasOrgContext = ( + org: typeof MOCK_TEAM_ORG_ACME | typeof MOCK_PERSONAL_ORG, + user: Partial, + ) => { + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], { + app_mode: "saas", + }); + mockQueryClient.setQueryData(["organizations"], { + items: [org], + currentOrgId: org.id, + }); + useSelectedOrganizationStore.setState({ organizationId: org.id }); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [org], + currentOrgId: org.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ ...user, org_id: org.id }), + ); + }; + + beforeEach(() => { + mockQueryClient.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + "/settings/org-defaults", + "/settings/org-defaults/condenser", + "/settings/org-defaults/verification", + ])( + "renders the org-wide settings badge beside the title on %s for an admin in a team org in SaaS mode", + async (path) => { + seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "admin" }); + + renderSettingsScreen(path); + + expect( + await screen.findByTestId("org-wide-settings-badge"), + ).toBeInTheDocument(); + }, + ); + + it("renders the badge on /settings/org-defaults for a non-admin member of a team org (read-only view)", async () => { + seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "member" }); + + renderSettingsScreen("/settings/org-defaults"); + + await screen.findByTestId("org-default-llm-settings-screen"); + expect( + await screen.findByTestId("org-wide-settings-badge"), + ).toBeInTheDocument(); + }); + + it("does not render the badge on /settings/org-defaults when the selected organization is a personal org", async () => { + seedSaasOrgContext(MOCK_PERSONAL_ORG, { role: "admin" }); + + renderSettingsScreen("/settings/org-defaults"); + + await screen.findByTestId("org-default-llm-settings-screen"); + expect( + screen.queryByTestId("org-wide-settings-badge"), + ).not.toBeInTheDocument(); + }); + + it.each(["/settings/condenser", "/settings/verification"])( + "does not render the badge on %s (personal-workspace-only route)", + async (path) => { + seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "admin" }); + + renderSettingsScreen(path); + + await screen.findByTestId( + path === "/settings/condenser" + ? "condenser-settings-screen" + : "verification-settings-screen", + ); + expect( + screen.queryByTestId("org-wide-settings-badge"), + ).not.toBeInTheDocument(); + }, + ); + }); }); describe("getFirstAvailablePath", () => { diff --git a/frontend/__tests__/utils/personal-workspace-guard.test.ts b/frontend/__tests__/utils/personal-workspace-guard.test.ts index b1a8b2d482..1c1d3e035e 100644 --- a/frontend/__tests__/utils/personal-workspace-guard.test.ts +++ b/frontend/__tests__/utils/personal-workspace-guard.test.ts @@ -41,7 +41,7 @@ vi.mock("#/query-client-config", () => ({ })); import { redirect } from "react-router"; -import { requirePersonalWorkspaceLoader } from "#/utils/org/personal-workspace-guard"; +import { requireOrgDefaultsRedirect as requirePersonalWorkspaceLoader } from "#/utils/org/saas-redirect-to-org-defaults-guard"; const createRequest = (pathname: string) => ({ request: new Request(`http://localhost${pathname}`), @@ -65,14 +65,14 @@ describe("requirePersonalWorkspaceLoader", () => { expect(result).toEqual({ type: "redirect", path: "/settings/org-defaults" }); }); - it("allows access when the active org is the personal workspace", async () => { + it("redirects to the org-defaults equivalent even when the active org is the personal workspace", async () => { storeOrgId = "personal-org"; const guard = requirePersonalWorkspaceLoader("/settings/org-defaults"); const result = await guard(createRequest("/settings")); - expect(result).toBeNull(); - expect(redirect).not.toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/settings/org-defaults"); + expect(result).toEqual({ type: "redirect", path: "/settings/org-defaults" }); }); it("skips the guard entirely in OSS mode", async () => { diff --git a/frontend/src/components/features/settings/org-defaults-banner.tsx b/frontend/src/components/features/settings/org-defaults-banner.tsx index b6d2c7a877..ccf9c35796 100644 --- a/frontend/src/components/features/settings/org-defaults-banner.tsx +++ b/frontend/src/components/features/settings/org-defaults-banner.tsx @@ -1,13 +1,11 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge"; export function OrgDefaultsBanner() { const { t } = useTranslation(); return (
-

{t(I18nKey.SETTINGS$ORG_DEFAULTS_INFO)}

diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts index 2e6f43996c..19737f6749 100644 --- a/frontend/src/hooks/use-settings-nav-items.ts +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -71,7 +71,7 @@ export function useSettingsNavItems(): SettingsNavRenderedItem[] { items = items.filter((item) => item.to !== "/settings/org-members"); } - if (!hasPermission("edit_llm_settings") || !organizationId || isPersonalOrg) { + if (!organizationId) { items = items.filter( (item) => !item.to.startsWith("/settings/org-defaults"), ); @@ -82,7 +82,7 @@ export function useSettingsNavItems(): SettingsNavRenderedItem[] { "/settings/condenser", "/settings/verification", ]); - if (isTeamOrg) { + if (isSaasMode) { items = items.filter((item) => !PERSONAL_LLM_PATHS.has(item.to)); } diff --git a/frontend/src/routes/condenser-settings.tsx b/frontend/src/routes/condenser-settings.tsx index 4cef893712..8222b18f5c 100644 --- a/frontend/src/routes/condenser-settings.tsx +++ b/frontend/src/routes/condenser-settings.tsx @@ -1,6 +1,6 @@ import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page"; import { createPermissionGuard } from "#/utils/org/permission-guard"; -import { requirePersonalWorkspaceLoader } from "#/utils/org/personal-workspace-guard"; +import { requireOrgDefaultsRedirect } from "#/utils/org/saas-redirect-to-org-defaults-guard"; function CondenserSettingsScreen() { return ( @@ -11,13 +11,13 @@ function CondenserSettingsScreen() { ); } -const personalWorkspaceGuard = requirePersonalWorkspaceLoader( +const orgDefaultsRedirectGuard = requireOrgDefaultsRedirect( "/settings/org-defaults/condenser", ); const condenserPermissionGuard = createPermissionGuard("view_llm_settings"); export const clientLoader = async (args: { request: Request }) => { - const blocked = await personalWorkspaceGuard(args); + const blocked = await orgDefaultsRedirectGuard(args); if (blocked) return blocked; return condenserPermissionGuard(args); }; diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index e73b53691e..67b0cddfdf 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -3,14 +3,13 @@ import { useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; import { createPermissionGuard } from "#/utils/org/permission-guard"; -import { requirePersonalWorkspaceLoader } from "#/utils/org/personal-workspace-guard"; +import { requireOrgDefaultsRedirect } from "#/utils/org/saas-redirect-to-org-defaults-guard"; import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema"; import { useSettings } from "#/hooks/query/use-settings"; import { SettingsInput } from "#/components/features/settings/settings-input"; import { HelpLink } from "#/ui/help-link"; import { useConfig } from "#/hooks/query/use-config"; import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; -import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; import { SdkSectionHeaderProps, SdkSectionPage, @@ -21,7 +20,6 @@ import { displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { Settings, SettingsSchema, SettingsScope } from "#/types/settings"; -import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge"; import { extractModelAndProvider } from "#/utils/extract-model-and-provider"; import { inferInitialView, @@ -126,7 +124,6 @@ export function LlmSettingsScreen({ settings?.agent_settings_schema, ); const { data: config } = useConfig(); - const { isTeamOrg } = useOrgTypeAndAccess(); const [selectedProvider, setSelectedProvider] = React.useState( null, @@ -166,11 +163,11 @@ export function LlmSettingsScreen({ }, [searchParams, setSearchParams, t]); const infoMessageKey = React.useMemo((): I18nKey | null => { - if (!isSaasMode || !isTeamOrg) return null; + if (!isSaasMode) return null; return scope === "org" ? I18nKey.SETTINGS$ORG_DEFAULTS_INFO : I18nKey.SETTINGS$PERSONAL_AGENT_INFO; - }, [isSaasMode, isTeamOrg, scope]); + }, [isSaasMode, scope]); const getInitialView = React.useCallback( ( @@ -259,8 +256,6 @@ export function LlmSettingsScreen({ return (
- {scope === "org" ? : null} - {infoMessageKey ? (

{ - const blocked = await personalWorkspaceGuard(args); + const blocked = await orgDefaultsRedirectGuard(args); if (blocked) return blocked; return llmPermissionGuard(args); }; diff --git a/frontend/src/routes/org-default-condenser-settings.tsx b/frontend/src/routes/org-default-condenser-settings.tsx index 958f269344..fe962f9ea7 100644 --- a/frontend/src/routes/org-default-condenser-settings.tsx +++ b/frontend/src/routes/org-default-condenser-settings.tsx @@ -16,7 +16,7 @@ function OrgDefaultCondenserSettingsScreen() { } export const clientLoader = createPermissionGuard( - "edit_llm_settings", + "view_llm_settings", "/settings/condenser", ); diff --git a/frontend/src/routes/org-default-llm-settings.tsx b/frontend/src/routes/org-default-llm-settings.tsx index 9bcfb0e183..48507b3fcc 100644 --- a/frontend/src/routes/org-default-llm-settings.tsx +++ b/frontend/src/routes/org-default-llm-settings.tsx @@ -2,7 +2,7 @@ import { createPermissionGuard } from "#/utils/org/permission-guard"; import { LlmSettingsScreen } from "./llm-settings"; export const clientLoader = createPermissionGuard( - "edit_llm_settings", + "view_llm_settings", "/settings", ); diff --git a/frontend/src/routes/org-default-verification-settings.tsx b/frontend/src/routes/org-default-verification-settings.tsx index d608473c4a..f46c62f2bb 100644 --- a/frontend/src/routes/org-default-verification-settings.tsx +++ b/frontend/src/routes/org-default-verification-settings.tsx @@ -15,7 +15,7 @@ function OrgDefaultVerificationSettingsScreen() { } export const clientLoader = createPermissionGuard( - "edit_llm_settings", + "view_llm_settings", "/settings/verification", ); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index f4e037c864..c5e15f3637 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -18,7 +18,6 @@ import { isSettingsPageHidden, getFirstAvailablePath, } from "#/utils/settings-utils"; -import { useMe } from "#/hooks/query/use-me"; import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; import { useConfig } from "#/hooks/query/use-config"; import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge"; @@ -32,6 +31,12 @@ const SAAS_ONLY_PATHS = [ "/settings/org", ]; +const ORG_WIDE_BADGE_PATHS = new Set([ + "/settings/org-defaults", + "/settings/org-defaults/condenser", + "/settings/org-defaults/verification", +]); + export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; @@ -124,17 +129,14 @@ function SettingsScreen() { const location = useLocation(); const matches = useMatches(); const navItems = useSettingsNavItems(); - const { data: me } = useMe(); const { data: config } = useConfig(); const { isTeamOrg } = useOrgTypeAndAccess(); // Determine if we should show the org-wide settings badge - // Only show for Admin/Owner roles on the LLM settings page in team orgs - const isLlmSettingsPage = location.pathname === "/settings"; - const isAdminOrOwner = me?.role === "admin" || me?.role === "owner"; + // Only show for Admin/Owner roles on LLM/org-defaults pages in team orgs + const isOrgWideBadgePath = ORG_WIDE_BADGE_PATHS.has(location.pathname); const isSaasMode = config?.app_mode === "saas"; - const shouldShowOrgWideBadge = - isLlmSettingsPage && isAdminOrOwner && isTeamOrg && isSaasMode; + const shouldShowOrgWideBadge = isOrgWideBadgePath && isTeamOrg && isSaasMode; // Current section title for the main content area const currentSectionTitle = useMemo(() => { diff --git a/frontend/src/routes/verification-settings.tsx b/frontend/src/routes/verification-settings.tsx index 4f953d119f..3c11bcad42 100644 --- a/frontend/src/routes/verification-settings.tsx +++ b/frontend/src/routes/verification-settings.tsx @@ -6,13 +6,12 @@ import { SdkSectionHeaderProps, SdkSectionPage, } from "#/components/features/settings/sdk-settings/sdk-section-page"; -import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge"; import { useSettings } from "#/hooks/query/use-settings"; import { I18nKey } from "#/i18n/declaration"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { SettingsScope } from "#/types/settings"; import { createPermissionGuard } from "#/utils/org/permission-guard"; -import { requirePersonalWorkspaceLoader } from "#/utils/org/personal-workspace-guard"; +import { requireOrgDefaultsRedirect } from "#/utils/org/saas-redirect-to-org-defaults-guard"; const VERIFICATION_SCHEMA_EXCLUDE_KEYS = new Set([ "confirmation_mode", @@ -20,7 +19,6 @@ const VERIFICATION_SCHEMA_EXCLUDE_KEYS = new Set([ ]); function VerificationSettingsHeader({ - scope, confirmationMode, securityAnalyzer, isConversationSettingsDisabled, @@ -28,7 +26,6 @@ function VerificationSettingsHeader({ onSecurityAnalyzerChange, renderTopContent, }: { - scope: SettingsScope; confirmationMode: boolean; securityAnalyzer: string | null; isConversationSettingsDisabled: boolean; @@ -57,7 +54,6 @@ function VerificationSettingsHeader({ return (

{renderTopContent?.()} - {scope === "org" ? : null}
@@ -133,7 +129,6 @@ export function VerificationSettingsScreen({ const buildHeader = React.useCallback( ({ isDisabled }: SdkSectionHeaderProps) => ( ), - [confirmationMode, renderTopContent, scope, securityAnalyzer], + [confirmationMode, renderTopContent, securityAnalyzer], ); const buildPayload = React.useCallback( @@ -194,13 +189,13 @@ export function VerificationSettingsScreen({ ); } -const personalWorkspaceGuard = requirePersonalWorkspaceLoader( +const orgDefaultsRedirectGuard = requireOrgDefaultsRedirect( "/settings/org-defaults/verification", ); const verificationPermissionGuard = createPermissionGuard("view_llm_settings"); export const clientLoader = async (args: { request: Request }) => { - const blocked = await personalWorkspaceGuard(args); + const blocked = await orgDefaultsRedirectGuard(args); if (blocked) return blocked; return verificationPermissionGuard(args); }; diff --git a/frontend/src/utils/org/personal-workspace-guard.ts b/frontend/src/utils/org/saas-redirect-to-org-defaults-guard.ts similarity index 92% rename from frontend/src/utils/org/personal-workspace-guard.ts rename to frontend/src/utils/org/saas-redirect-to-org-defaults-guard.ts index ed177f435e..f056b9bbae 100644 --- a/frontend/src/utils/org/personal-workspace-guard.ts +++ b/frontend/src/utils/org/saas-redirect-to-org-defaults-guard.ts @@ -25,8 +25,8 @@ const fetchOrganizations = () => // Fails open (returns null, allowing access) when org context cannot be // resolved — config fetch, org fetch, or active-org lookup. Backend permission // checks remain authoritative, so an outage degrades to "show the page" rather -// than locking every user out of personal LLM settings. -export const requirePersonalWorkspaceLoader = +// than locking every user out of LLM settings. +export const requireOrgDefaultsRedirect = (redirectPath: string = FALLBACK_REDIRECT_PATH) => async ({ request }: { request: Request }) => { const config = await fetchConfig(); @@ -51,8 +51,6 @@ export const requirePersonalWorkspaceLoader = const activeOrg = organizationsData?.items.find((o) => o.id === orgId); if (!activeOrg) return null; - if (activeOrg.is_personal === true) return null; - const currentPath = new URL(request.url).pathname; if (currentPath === redirectPath) return null;