From ba2331341f4f368871e8d667250f9c2dfbf6630e Mon Sep 17 00:00:00 2001 From: Jesus Velarde Date: Fri, 6 Nov 2026 17:56:47 -0800 Subject: [PATCH] fix(ui): block /ui/moyai when Moyai is off, keep it shown by default, refuse config-owned PATCH Redirect the Moyai page home when enable_moyai_ui is false, mirroring the Chat route guard. Show Moyai in the switcher unless the setting is explicitly false, so it no longer disappears while settings load. PATCH /update/ui_settings now refuses an enable_moyai_ui write the config file owns. --- .../proxy_setting_endpoints.py | 5 ++ .../test_proxy_setting_endpoints.py | 24 +++++++ .../src/app/moyai/page.test.tsx | 69 +++++++++++++++++++ ui/litellm-dashboard/src/app/moyai/page.tsx | 11 ++- .../Navbar/ViewSwitcher.integration.test.tsx | 13 ++++ .../src/components/Navbar/ViewSwitcher.tsx | 2 +- 6 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/moyai/page.test.tsx diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 2cf639455d9..cca659c35c1 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -2090,6 +2090,7 @@ async def update_ui_settings( from litellm.proxy.proxy_server import ( create_config_audit_log, prisma_client, + proxy_config, store_model_in_db, ) @@ -2172,6 +2173,10 @@ async def update_ui_settings( # Enforce allowlist and drop anything unexpected incoming: Final = {k: v for k, v in settings_dict.items() if k in ALLOWED_UI_SETTINGS_FIELDS} + proxy_config.reject_config_owned_writes( + section_name="general_settings", + changed_keys={k: v for k, v in incoming.items() if k == "enable_moyai_ui"}, + ) # Merge with existing persisted settings so a partial PATCH doesn't # overwrite fields the caller didn't send. diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 6820328bb90..c6ad19784fb 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -4174,6 +4174,30 @@ class TestEnableMoyaiUISetting: upsert_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"] assert json.loads(upsert_data["create"]["ui_settings"])["enable_moyai_ui"] is False + def test_patch_refuses_moyai_ui_owned_by_config_file(self, mock_auth, monkeypatch): + from litellm.proxy import proxy_server + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + store: Final = proxy_server.proxy_config.settings + store.load_yaml({"enable_moyai_ui": False}) + monkeypatch.setattr(proxy_server.proxy_config, "_settings_stores", {"general_settings": store}) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + mock_prisma: Final = TestPtuCostAttributionUISetting._mock_prisma(monkeypatch) + + try: + response: Final = client.patch("/update/ui_settings", json={"enable_moyai_ui": True}) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert response.json()["detail"]["keys"] == ["enable_moyai_ui"] + mock_prisma.db.litellm_uisettings.upsert.assert_not_called() + class TestApplyUserBudgetToTeamKeysUISetting: """``apply_user_budget_to_team_keys`` mirrors general_settings on every GET. diff --git a/ui/litellm-dashboard/src/app/moyai/page.test.tsx b/ui/litellm-dashboard/src/app/moyai/page.test.tsx new file mode 100644 index 00000000000..9c041a11854 --- /dev/null +++ b/ui/litellm-dashboard/src/app/moyai/page.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders as render } from "@/../tests/test-utils"; +import MoyaiPage from "./page"; + +const { mockReplace, state } = vi.hoisted(() => ({ + mockReplace: vi.fn(), + state: { + values: {} as Record, + isLoading: false, + }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "token-123", userRole: "Admin" }), +})); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: state.values }, isLoading: state.isLoading, refetch: vi.fn() }), +})); +vi.mock("@/utils/uiHref", () => ({ uiHref: (segment: string) => `/mocked-ui/${segment}` })); +vi.mock("@/components/navbar", () => ({ default: () =>
})); +vi.mock("@/contexts/ThemeContext", () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/contexts/PluginModeContext", () => ({ + PluginModeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/components/moyai/MoyaiLanding", () => ({ default: () =>
})); + +describe("MoyaiPage", () => { + afterEach(() => { + state.values = {}; + state.isLoading = false; + mockReplace.mockClear(); + vi.unstubAllGlobals(); + }); + + it("should show the Moyai landing page when the setting is not stored", () => { + render(); + expect(screen.getByTestId("moyai-landing")).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("should redirect home instead of showing Moyai when an admin turns it off", () => { + state.values = { enable_moyai_ui: false }; + render(); + expect(screen.queryByTestId("moyai-landing")).not.toBeInTheDocument(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/"); + }); + + it("should not open a connected Moyai deployment when an admin turns Moyai off", () => { + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, search: "", replace }); + state.values = { enable_moyai_ui: false, moyai_url: "https://moyai.example.com" }; + render(); + expect(replace).not.toHaveBeenCalled(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/"); + }); + + it("should not redirect while UI settings are loading", () => { + state.isLoading = true; + render(); + expect(screen.queryByTestId("moyai-landing")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/moyai/page.tsx b/ui/litellm-dashboard/src/app/moyai/page.tsx index 50b755c4fad..a8b3cf9bd0f 100644 --- a/ui/litellm-dashboard/src/app/moyai/page.tsx +++ b/ui/litellm-dashboard/src/app/moyai/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useMemo, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; +import { useRouter } from "next/navigation"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import Navbar from "@/components/navbar"; @@ -17,6 +18,7 @@ function MoyaiPageContent() { const { t } = useTranslation(); const { accessToken, userRole } = useAuthorized(); const { data: uiSettings, isLoading, refetch } = useUISettings(); + const router = useRouter(); const parsed = useSyncExternalStore( () => () => {}, @@ -46,8 +48,13 @@ function MoyaiPageContent() { }, [connectedParams, refetch]); const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null; + const blocked = !isLoading && uiSettings?.values?.enable_moyai_ui === false; - const settingsReady = parsed && !isLoading; + useEffect(() => { + if (blocked) router.replace(uiHref("")); + }, [blocked, router]); + + const settingsReady = parsed && !isLoading && !blocked; const shouldOpenMoyai = settingsReady && !connectedParams && moyaiUrl; useEffect(() => { if (shouldOpenMoyai) { @@ -61,7 +68,7 @@ function MoyaiPageContent() { }; let content: React.ReactNode = null; - if (parsed && !isLoading) { + if (settingsReady) { if (connectedParams) { content = ( diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx index 9d480ababcb..0d4927736e1 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx @@ -203,6 +203,19 @@ describe("ViewSwitcher", () => { expect(screen.queryByRole("menuitem", { name: /Moyai/ })).not.toBeInTheDocument(); }); + it.each([ + ["settings are still loading", () => new Promise(() => {})], + ["the proxy does not report the setting", () => Promise.resolve({ values: { enable_chat_ui: false } })], + ])("should keep Moyai in the switcher while %s", async (_, loadSettings) => { + getUiSettingsMock.mockImplementation(loadSettings); + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" })); + await user.click(await screen.findByRole("menuitem", { name: "Moyai Cloud Coding Agent" })); + expect(assignSpy).toHaveBeenCalledWith("/ui/moyai"); + }); + it("should keep the local Moyai route available when Chat is disabled", async () => { getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: false, enable_moyai_ui: true } }); const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 329f245cc8a..6b01fcd3967 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -59,7 +59,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const pathname = usePathname(); const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); - const moyaiEnabled = Boolean(uiSettings?.values?.enable_moyai_ui); + const moyaiEnabled = uiSettings?.values?.enable_moyai_ui !== false; const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null; const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");