feat(ui): add admin flag to disable in-product UI nudges for everyone (#29796)

* feat(ui): add admin flag to disable in-product UI nudges for everyone

Admins can now suppress the survey and Claude Code feedback popups for
all users via a single disable_ui_nudges UI setting, instead of relying
on each user dismissing them individually.

* fix(ui): suppress nudges while ui settings are loading

Gate nudgesDisabled on the ui-settings loading state so an admin with
disable_ui_nudges on doesn't see the survey prompt flash, and the
getInProductNudgesCall fetch doesn't fire, on a cold page load before
the flag resolves. Falls back to showing nudges if the fetch errors.

* test(ui): wrap CreateKeyPage test in QueryClientProvider

page.tsx now calls useUISettings (react-query), which needs a
QueryClient that layout.tsx supplies in production but the test did
not. Add the provider and mock getUiSettings so the query resolves.
This commit is contained in:
ryan-crabbe-berri 2026-06-09 17:45:42 -07:00 committed by GitHub
parent 50522157dc
commit 248176112e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 7 deletions

View File

@ -178,6 +178,11 @@ class UISettings(BaseModel):
description="If true, org admins cannot generate API keys via /key/generate.",
)
disable_ui_nudges: bool = Field(
default=False,
description="If true, suppresses in-product UI nudges (survey and Claude Code feedback popups) for all users.",
)
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@ -201,6 +206,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"scope_user_search_to_org",
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"disable_ui_nudges",
}
# Flags that must be synced from the persisted UISettings into

View File

@ -1032,6 +1032,45 @@ class TestProxySettingEndpoints:
stored_settings = json.loads(create_data["ui_settings"])
assert stored_settings["disable_model_add_for_internal_users"] is True
def test_update_ui_settings_persists_disable_ui_nudges(
self, mock_auth, monkeypatch
):
"""disable_ui_nudges must be allowlisted so admins can suppress UI popups for everyone"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
mock_user_auth = UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
mock_prisma = MagicMock()
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
try:
response = client.patch(
"/update/ui_settings", json={"disable_ui_nudges": True}
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["settings"]["disable_ui_nudges"] is True
create_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"][
"create"
]
stored_settings = json.loads(create_data["ui_settings"])
assert stored_settings["disable_ui_nudges"] is True
def test_update_ui_settings_ignores_non_allowlisted_value(
self, mock_auth, monkeypatch
):

View File

@ -9,6 +9,7 @@ import BudgetPanel from "@/components/budgets/budget_panel";
import CacheDashboard from "@/components/cache_dashboard";
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { CostTrackingSettings } from "@/components/CostTrackingSettings";
import GeneralSettings from "@/components/general_settings";
@ -83,6 +84,9 @@ function CreateKeyPageContent() {
const [modelData, setModelData] = useState<any>({ data: [] });
const [createClicked, setCreateClicked] = useState<boolean>(false);
const { data: uiSettingsData, isLoading: uiSettingsLoading } = useUISettings();
const nudgesDisabled = uiSettingsLoading || Boolean(uiSettingsData?.values?.disable_ui_nudges);
// Survey state - always show by default
const [showSurveyPrompt, setShowSurveyPrompt] = useState(true);
const [showSurveyModal, setShowSurveyModal] = useState(false);
@ -258,6 +262,9 @@ function CreateKeyPageContent() {
// Fetch in-product nudges configuration from backend
useEffect(() => {
if (nudgesDisabled) {
return;
}
if (accessToken && token) {
(async () => {
try {
@ -277,7 +284,7 @@ function CreateKeyPageContent() {
}
})();
}
}, [accessToken, token]);
}, [accessToken, token, nudgesDisabled]);
// Auto-dismiss survey prompt after 15 seconds
useEffect(() => {
@ -541,7 +548,7 @@ function CreateKeyPageContent() {
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
isVisible={showSurveyPrompt && !nudgesDisabled}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
@ -553,7 +560,7 @@ function CreateKeyPageContent() {
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
isVisible={showClaudeCodePrompt && !nudgesDisabled}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>

View File

@ -26,6 +26,7 @@ export default function UISettings() {
const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins;
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const disableUINudgesProperty = schema?.properties?.disable_ui_nudges;
const values = data?.values ?? {};
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@ -60,6 +61,20 @@ export default function UISettings() {
);
};
const handleToggleDisableUINudges = (checked: boolean) => {
updateSettings(
{ disable_ui_nudges: checked },
{
onSuccess: () => {
NotificationManager.success("UI settings updated successfully");
},
onError: (error) => {
NotificationManager.fromBackend(error);
},
},
);
};
const handleUpdatePageVisibility = (settings: { enabled_ui_pages_internal_users: string[] | null }) => {
updateSettings(settings, {
onSuccess: () => {
@ -451,6 +466,26 @@ export default function UISettings() {
<Divider />
{/* Disable in-product UI nudges */}
<Space align="start" size="middle">
<Switch
checked={Boolean(values.disable_ui_nudges)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleDisableUINudges}
aria-label={disableUINudgesProperty?.description ?? "Disable UI nudges"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable UI nudges</Typography.Text>
<Typography.Text type="secondary">
{disableUINudgesProperty?.description ??
"If true, suppresses in-product UI nudges (survey and Claude Code feedback popups) for all users."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}

View File

@ -72,6 +72,8 @@ vi.mock("@/components/networking", () => {
return {
// Called on mount; we don't care about its contents, only that it resolves
getUiConfig: vi.fn().mockResolvedValue({}),
// Fetched by useUISettings(); resolve with empty settings so nudges stay default-on
getUiSettings: vi.fn().mockResolvedValue({ values: {}, field_schema: {} }),
// Used to build the redirect URL
proxyBaseUrl: "https://example.com",
// Called when decoding a valid token
@ -146,17 +148,22 @@ vi.mock("@/lib/cva.config", () => ({
cx: (...args: string[]) => args.join(" "),
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import CreateKeyPage from "@/app/page";
import { AuthProvider } from "@/contexts/AuthContext";
// The page consumes auth state via useAuth(). Wrap it so the hook resolves
// against a real provider — the provider's effects (cookie read, JWT decode,
// redirect-on-expired) are what these tests exercise.
// redirect-on-expired) are what these tests exercise. The QueryClientProvider
// mirrors what layout.tsx supplies in production for hooks like useUISettings.
function PageUnderTest() {
const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }));
return (
<AuthProvider>
<CreateKeyPage />
</AuthProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<CreateKeyPage />
</AuthProvider>
</QueryClientProvider>
);
}