mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(ui): hide LiteAdmin while Playground is open (#42755)
This commit is contained in:
parent
5c738cc3cd
commit
bab86555e7
7 changed files with 201 additions and 7 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue