fix(frontend): show org-wide settings badge beside title on org-defaults pages (#14031)

This commit is contained in:
Hiep Le
2026-04-21 02:18:17 +07:00
committed by GitHub
parent 3892ab2b67
commit 0da1f70b91
15 changed files with 185 additions and 53 deletions

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,32 @@ describe("Settings Screen", () => {
Component: () => <div data-testid="organization-settings-screen" />,
path: "/settings/org",
},
{
Component: () => <div data-testid="condenser-settings-screen" />,
path: "/settings/condenser",
},
{
Component: () => <div data-testid="verification-settings-screen" />,
path: "/settings/verification",
},
{
Component: () => (
<div data-testid="org-default-llm-settings-screen" />
),
path: "/settings/org-defaults",
},
{
Component: () => (
<div data-testid="org-default-condenser-settings-screen" />
),
path: "/settings/org-defaults/condenser",
},
{
Component: () => (
<div data-testid="org-default-verification-settings-screen" />
),
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<OrganizationMember>,
) => {
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", () => {

View File

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

View File

@@ -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 (
<div className="flex flex-col gap-3">
<OrgWideSettingsBadge />
<p className="text-sm text-tertiary-alt">
{t(I18nKey.SETTINGS$ORG_DEFAULTS_INFO)}
</p>

View File

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

View File

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

View File

@@ -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<string | null>(
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 (
<div className="flex flex-col gap-6">
{scope === "org" ? <OrgWideSettingsBadge /> : null}
{infoMessageKey ? (
<p
data-testid="llm-settings-info-message"
@@ -401,13 +396,13 @@ export function LlmSettingsScreen({
);
}
const personalWorkspaceGuard = requirePersonalWorkspaceLoader(
const orgDefaultsRedirectGuard = requireOrgDefaultsRedirect(
"/settings/org-defaults",
);
const llmPermissionGuard = 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 llmPermissionGuard(args);
};

View File

@@ -16,7 +16,7 @@ function OrgDefaultCondenserSettingsScreen() {
}
export const clientLoader = createPermissionGuard(
"edit_llm_settings",
"view_llm_settings",
"/settings/condenser",
);

View File

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

View File

@@ -15,7 +15,7 @@ function OrgDefaultVerificationSettingsScreen() {
}
export const clientLoader = createPermissionGuard(
"edit_llm_settings",
"view_llm_settings",
"/settings/verification",
);

View File

@@ -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<string>([
"/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(() => {

View File

@@ -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 (
<div className="flex flex-col gap-6">
{renderTopContent?.()}
{scope === "org" ? <OrgWideSettingsBadge /> : null}
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
@@ -133,7 +129,6 @@ export function VerificationSettingsScreen({
const buildHeader = React.useCallback(
({ isDisabled }: SdkSectionHeaderProps) => (
<VerificationSettingsHeader
scope={scope}
confirmationMode={confirmationMode}
securityAnalyzer={securityAnalyzer}
isConversationSettingsDisabled={isDisabled}
@@ -148,7 +143,7 @@ export function VerificationSettingsScreen({
renderTopContent={renderTopContent}
/>
),
[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);
};

View File

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