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:
parent
50522157dc
commit
248176112e
@ -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
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user