fix(ui): hide LiteAdmin while Playground is open (#42755)

This commit is contained in:
tin-berri 2026-09-23 17:32:22 -07:00 • committed by GitHub
parent 5c738cc3cd
commit bab86555e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 201 additions and 7 deletions

View file

@ -0,0 +1,34 @@
import { useSyncExternalStore } from "react";
import { getProxyBaseUrl } from "@/components/networking";
import {
LOCAL_STORAGE_EVENT,
emitLocalStorageChange,
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem,
} from "@/utils/localStorageUtils";
function subscribe(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(LOCAL_STORAGE_EVENT, callback);
return () => {
window.removeEventListener("storage", callback);
window.removeEventListener(LOCAL_STORAGE_EVENT, callback);
};
}
export function useDisableLiteAdmin(userId: string | null) {
const key = userId ? `disableLiteAdmin:${JSON.stringify([getProxyBaseUrl(), userId])}` : null;
const disabled = useSyncExternalStore(
subscribe,
() => key !== null && getLocalStorageItem(key) === "true",
() => false,
);
const setDisabled = (value: boolean) => {
if (key === null) return;
if (value) setLocalStorageItem(key, "true");
else removeLocalStorageItem(key);
emitLocalStorageChange(key);
};
return [disabled, setDisabled] as const;
}

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { AuthProvider } from "@/contexts/AuthContext";
import Layout from "./layout";
@ -10,7 +11,11 @@ let searchParamsValue = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn(), replace: replaceMock })),
useSearchParams: vi.fn(() => searchParamsValue),
usePathname: vi.fn(() => "/ui/guardrails"),
usePathname: vi.fn(),
}));
vi.mock("@/components/liteadmin/LiteAdmin", () => ({
default: () => <button>LiteAdmin</button>,
}));
vi.mock("@/components/DashboardHeader", () => ({
@ -79,8 +84,34 @@ describe("(dashboard) Layout", () => {
vi.clearAllMocks();
pendingUiConfig = createDeferred();
searchParamsValue = new URLSearchParams();
vi.mocked(usePathname).mockReturnValue("/ui/guardrails");
});
it.each(["/ui/playground", "/ui/playground/"])(
"hides LiteAdmin on %s and restores it after leaving Playground",
async (pathname) => {
const dashboard = () => (
<AuthProvider>
<Layout>
<div data-testid="page-content" />
</Layout>
</AuthProvider>
);
const { rerender } = render(dashboard());
pendingUiConfig.resolve();
expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
vi.mocked(usePathname).mockReturnValue(pathname);
rerender(dashboard());
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
expect(screen.getByTestId("page-content")).toBeInTheDocument();
vi.mocked(usePathname).mockReturnValue("/ui/api-keys");
rerender(dashboard());
expect(screen.getByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
},
);
it("does not mount route content until getUiConfig has resolved", async () => {
render(
<AuthProvider>

View file

@ -15,7 +15,7 @@ import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
import LiteAdmin from "@/components/liteadmin/LiteAdmin";
import { UpgradeBanner } from "@/components/UpgradeBanner";
import { uiHref } from "@/utils/uiHref";
import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
import { getProxyBaseUrl } from "@/components/networking";
@ -103,6 +103,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
const { accessToken } = useAuth();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const { mode } = usePluginMode();
const isPlayground = routeSegmentForPathname(usePathname()) === "playground";
const isGateway = mode === "ai-gateway";
@ -142,7 +143,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
<LiteAdmin />
{!isPlayground && <LiteAdmin />}
</div>
</div>
);

View file

@ -1,6 +1,7 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import {
emitLocalStorageChange,
@ -10,6 +11,7 @@ import {
} from "@/utils/localStorageUtils";
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
import { uiHref } from "@/utils/uiHref";
import { isProxyAdminRole } from "@/utils/roles";
import { ChevronDown, ChevronsUpDown, Crown, KeyRound, LogOut, Mail, ShieldCheck, User } from "lucide-react";
import { useRouter } from "next/navigation";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -65,12 +67,22 @@ interface UserDropdownProps {
}
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar", collapsed = false }) => {
const { userId, userEmail, userRoleLabel: userRole, premiumUser, loginMethod } = useAuthorized();
const {
userId,
userEmail,
userRole: role,
userRoleLabel: userRole,
isViewOnly,
premiumUser,
loginMethod,
} = useAuthorized();
const router = useRouter();
const [open, setOpen] = useState(false);
const disableShowPrompts = useDisableShowPrompts();
const disableBlogPosts = useDisableBlogPosts();
const disableBouncingIcon = useDisableBouncingIcon();
const [disableLiteAdmin, setDisableLiteAdmin] = useDisableLiteAdmin(userId);
const canUseLiteAdmin = userId && !isViewOnly && isProxyAdminRole(role);
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
useEffect(() => {
@ -192,6 +204,17 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
aria-label="Toggle hide bouncing icon"
/>
</div>
{canUseLiteAdmin && (
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide LiteAdmin</span>
<Switch
size="sm"
checked={disableLiteAdmin}
onCheckedChange={setDisableLiteAdmin}
aria-label="Toggle hide LiteAdmin"
/>
</div>
)}
</div>
);

View file

@ -2,6 +2,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { emitLocalStorageChange, removeLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
@ -15,6 +16,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/cva.config";
import { uiHref } from "@/utils/uiHref";
import { isProxyAdminRole } from "@/utils/roles";
import { ChevronsUpDown, Crown, IdCard, KeyRound, LogOut, Mail, ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
@ -83,7 +85,16 @@ interface SidebarAccountMenuProps {
}
const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, collapsed = false }) => {
const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken, loginMethod } = useAuthorized();
const {
userId,
userEmail,
userRole: role,
userRoleLabel: userRole,
isViewOnly,
premiumUser,
accessToken,
loginMethod,
} = useAuthorized();
const router = useRouter();
const [open, setOpen] = React.useState(false);
const { data: healthData } = useHealthReadinessDetails(accessToken);
@ -92,6 +103,8 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
const disableBlogPosts = useDisableBlogPosts();
const disableBouncingIcon = useDisableBouncingIcon();
const disableShowNewBadge = useDisableShowNewBadge();
const [disableLiteAdmin, setDisableLiteAdmin] = useDisableLiteAdmin(userId);
const canUseLiteAdmin = userId && !isViewOnly && isProxyAdminRole(role);
const setFlag = (key: string, checked: boolean) => {
if (checked) {
@ -235,6 +248,17 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
/>
</div>
))}
{canUseLiteAdmin && (
<div className="flex h-[38px] items-center justify-between gap-3 px-3">
<span className="text-[13px] text-foreground">Hide LiteAdmin</span>
<Switch
size="sm"
checked={disableLiteAdmin}
onCheckedChange={setDisableLiteAdmin}
aria-label="Toggle hide LiteAdmin"
/>
</div>
)}
</div>
<Separator />

View file

@ -7,6 +7,9 @@ import { setGlobalLitellmHeaderName, switchToWorkerUrl } from "@/components/netw
import { Toaster } from "@/components/ui/sonner";
import { toast } from "@/lib/toast";
import userEvent from "@testing-library/user-event";
import type { ComponentType } from "react";
import SidebarAccountMenu from "@/components/SidebarAccountMenu/SidebarAccountMenu";
import UserDropdown from "@/components/Navbar/UserDropdown/UserDropdown";
import LiteAdmin from "./LiteAdmin";
import { MAX_INPUT_LENGTH } from "./agent";
@ -18,6 +21,7 @@ const { transport } = vi.hoisted(() => {
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
vi.unmock("@/lib/toast");
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const MANAGEMENT = "https://management.test/proxy";
const INFERENCE = "https://management.test/inference";
@ -80,13 +84,14 @@ function SessionReady() {
return <output>{authLoading ? "Session loading" : "Session ready"}</output>;
}
function renderWidget() {
function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const tree = () => (
<QueryClientProvider client={client}>
<Toaster />
<AuthProvider>
<SessionReady />
{Menu && <Menu onLogout={() => undefined} />}
<LiteAdmin />
</AuthProvider>
</QueryClientProvider>
@ -111,6 +116,7 @@ function gateway(replies: (ModelReply | Promise<ModelReply>)[], options: Gateway
const path = new URL(request.url).pathname;
if (path.endsWith("/litellm-ui-config"))
return json({ proxy_base_url: MANAGEMENT, server_root_path: "", admin_ui_disabled: false });
if (path.endsWith("/health/readiness/details")) return json({ status: "healthy" });
if (path.endsWith("/sso/get/ui_settings")) {
if (typeof settings === "function") return settings(request);
return json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: settings.target }, settings.status);
@ -176,6 +182,79 @@ afterEach(() => {
});
describe("LiteAdmin in the gateway", () => {
it.each([
["sidebar", SidebarAccountMenu],
["navbar", UserDropdown],
] as const)("persists Hide LiteAdmin from the %s account menu", async (_name, Menu) => {
gateway([]);
const user = userEvent.setup();
const view = renderWidget(Menu);
await screen.findByRole("button", { name: "LiteAdmin" });
await user.click(screen.getByRole("button", { name: /account menu/i }));
const toggle = await screen.findByRole("switch", { name: "Toggle hide LiteAdmin" });
expect(toggle).not.toBeChecked();
await user.click(toggle);
expect(toggle).toBeChecked();
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
view.unmount();
const restored = renderWidget(Menu);
await screen.findByText("Session ready");
await waitFor(() => expect(restored.client.isFetching()).toBe(0));
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /account menu/i }));
const savedToggle = await screen.findByRole("switch", { name: "Toggle hide LiteAdmin" });
expect(savedToggle).toBeChecked();
await user.click(savedToggle);
expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
});
it("isolates Hide LiteAdmin by admin and gateway and reacts to another tab clearing it", async () => {
gateway([]);
const user = userEvent.setup();
const view = renderWidget(SidebarAccountMenu);
await screen.findByRole("button", { name: "LiteAdmin" });
await user.click(screen.getByRole("button", { name: /account menu/i }));
await user.click(await screen.findByRole("switch", { name: "Toggle hide LiteAdmin" }));
session("proxy_admin", "second-admin");
view.refresh();
expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Toggle hide LiteAdmin" })).not.toBeChecked();
session();
view.refresh();
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
switchToWorkerUrl("https://other-gateway.test");
view.refresh();
expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Toggle hide LiteAdmin" })).not.toBeChecked();
switchToWorkerUrl(MANAGEMENT);
view.refresh();
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
act(() => {
localStorage.clear();
window.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Toggle hide LiteAdmin" })).not.toBeChecked();
});
it.each([
["sidebar", SidebarAccountMenu],
["navbar", UserDropdown],
] as const)("does not offer Hide LiteAdmin to a view-only admin in the %s menu", async (_name, Menu) => {
session("proxy_admin_viewer");
gateway([]);
renderWidget(Menu);
await screen.findByText("Session ready");
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /account menu/i }));
expect(await screen.findByRole("switch", { name: "Toggle hide all prompts" })).toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Toggle hide LiteAdmin" })).not.toBeInTheDocument();
});
it.each(["proxy_admin_viewer", "internal_user", "internal_user_viewer", "org_admin"])(
"does not expose operations to %s",
async (role) => {

View file

@ -4,6 +4,7 @@ import { useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { RotateCcw, Sparkles, X } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
import { ChatComposer } from "@/app/(dashboard)/playground/components/chat_ui/ChatComposer";
import { EndpointType, isModeCompatibleWithEndpoint } from "@/components/chat_ui/mode_endpoint_mapping";
@ -34,9 +35,10 @@ type ManagementSession = Omit<LiteAdminSession, "inferenceBaseUrl">;
export default function LiteAdmin() {
const auth = useAuthorized();
const [disabled] = useDisableLiteAdmin(auth.userId);
const sessionReady = !auth.isLoading && auth.isAuthorized;
const writableAdmin = !auth.isViewOnly && isProxyAdminRole(auth.userRole);
const allowed = sessionReady && writableAdmin;
const allowed = sessionReady && writableAdmin && !disabled;
if (!allowed || !auth.token || !auth.accessToken) return null;
const session = { token: auth.token, accessToken: auth.accessToken, managementBaseUrl: getProxyBaseUrl() };
return (