Merge pull request #18400 from BerriAI/litellm_ui_org_admin

[Fix] Allow Organization Admins to See Organization Tab
This commit is contained in:
yuneng-jiang 2025-12-23 17:41:36 -08:00 committed by GitHub
commit d85bf4234e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 125 additions and 36 deletions

View file

@ -1,4 +1,3 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import Sidebar from "@/components/leftnav";
interface SidebarProviderProps {
@ -8,17 +7,7 @@ interface SidebarProviderProps {
}
const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => {
const { accessToken, userRole } = useAuthorized();
return (
<Sidebar
accessToken={accessToken}
setPage={setPage}
userRole={userRole}
defaultSelectedKey={defaultSelectedKey}
collapsed={sidebarCollapsed}
/>
);
return <Sidebar setPage={setPage} defaultSelectedKey={defaultSelectedKey} collapsed={sidebarCollapsed} />;
};
export default SidebarProvider;

View file

@ -0,0 +1,13 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { organizationListCall, Organization } from "@/components/networking";
const organizationKeys = createQueryKeys("organizations");
export const useOrganizations = (accessToken: string | null): UseQueryResult<Organization[]> => {
return useQuery<Organization[]>({
queryKey: organizationKeys.list({}),
queryFn: async () => await organizationListCall(accessToken!),
enabled: Boolean(accessToken),
});
};

View file

@ -1,15 +1,8 @@
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import Sidebar from "./leftnav";
// Stub ResizeObserver used by antd in jsdom
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
(global as any).ResizeObserver = ResizeObserver;
vi.mock("../utils/roles", () => {
return {
all_admin_roles: ["admin"],
@ -19,17 +12,53 @@ vi.mock("../utils/roles", () => {
};
});
const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
const mockUseAuthorized = vi.fn(() => ({
userId: "test-user-id",
accessToken: "test-access-token",
userRole: "admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
}));
const mockUseOrganizations = vi.fn(() => ({
data: [],
isLoading: false,
error: null,
}));
return { mockUseAuthorized, mockUseOrganizations };
});
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: mockUseAuthorized,
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: mockUseOrganizations,
}));
vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => {
return {
useUIConfig: () => ({
data: { admin_ui_disabled: false },
isLoading: false,
}),
};
});
describe("Sidebar (leftnav)", () => {
const defaultProps = {
accessToken: null as string | null,
setPage: vi.fn(),
userRole: "admin",
defaultSelectedKey: "api-keys",
collapsed: false,
};
it("renders all top-level (non-nested) tabs for admin", () => {
const { getByText } = render(<Sidebar {...defaultProps} />);
renderWithProviders(<Sidebar {...defaultProps} />);
const topLevelLabels = [
"Virtual Keys",
@ -51,19 +80,19 @@ describe("Sidebar (leftnav)", () => {
];
topLevelLabels.forEach((label) => {
expect(getByText(label)).toBeInTheDocument();
expect(screen.getByText(label)).toBeInTheDocument();
});
});
it("expands a nested tab to reveal its children (Tools > Search Tools)", async () => {
const { getByText, queryByText } = render(<Sidebar {...defaultProps} />);
renderWithProviders(<Sidebar {...defaultProps} />);
expect(queryByText("Search Tools")).not.toBeInTheDocument();
expect(screen.queryByText("Search Tools")).not.toBeInTheDocument();
act(() => {
fireEvent.click(getByText("Tools"));
fireEvent.click(screen.getByText("Tools"));
});
await waitFor(() => {
expect(getByText("Search Tools")).toBeInTheDocument();
expect(screen.getByText("Search Tools")).toBeInTheDocument();
});
});
it("has no duplicate keys among all menu items and their children", () => {
@ -82,7 +111,7 @@ describe("Sidebar (leftnav)", () => {
return allKeys;
}
const { container } = render(<Sidebar {...defaultProps} />);
const { container } = renderWithProviders(<Sidebar {...defaultProps} />);
const allRenderedKeys = getAllKeysFromMenu(container);
const keySet = new Set<string>();
@ -95,4 +124,43 @@ describe("Sidebar (leftnav)", () => {
}
expect(duplicates).toHaveLength(0);
});
it("should show Organizations tab for organization admins", () => {
mockUseAuthorized.mockReturnValueOnce({
userId: "org-admin-user-id",
accessToken: "test-access-token",
userRole: "viewer",
token: "test-token",
userEmail: "orgadmin@example.com",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
mockUseOrganizations.mockReturnValueOnce({
data: [
{
organization_id: "org-1",
organization_name: "Test Organization",
spend: 0,
max_budget: null,
models: [],
tpm_limit: null,
rpm_limit: null,
members: [
{
user_id: "org-admin-user-id",
user_role: "org_admin",
},
],
},
],
isLoading: false,
error: null,
} as any);
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByText("Organizations")).toBeInTheDocument();
});
});

View file

@ -1,3 +1,5 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
ApiOutlined,
AppstoreOutlined,
@ -21,17 +23,17 @@ import {
ToolOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Badge, ConfigProvider, Layout, Menu } from "antd";
import type { MenuProps } from "antd";
import { Badge, ConfigProvider, Layout, Menu } from "antd";
import { useMemo } from "react";
import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "../utils/roles";
import type { Organization } from "./networking";
import UsageIndicator from "./usage_indicator";
const { Sider } = Layout;
// Define the props type
interface SidebarProps {
accessToken: string | null;
setPage: (page: string) => void;
userRole: string;
defaultSelectedKey: string;
collapsed?: boolean;
}
@ -53,7 +55,18 @@ interface MenuGroup {
roles?: string[];
}
const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => {
const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapsed = false }) => {
const { userId, accessToken, userRole } = useAuthorized();
const { data: organizations } = useOrganizations(accessToken);
// Check if user is an org_admin
const isOrgAdmin = useMemo(() => {
if (!userId || !organizations) return false;
return organizations.some((org: Organization) =>
org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"),
);
}, [userId, organizations]);
// Navigate to page helper
const navigateToPage = (page: string) => {
const newSearchParams = new URLSearchParams(window.location.search);
@ -146,7 +159,7 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
<span className="flex items-center gap-4">
Usage <Badge color="blue" count="New" />
</span>
),
),
},
{
key: "logs",
@ -302,7 +315,13 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
// Filter items based on user role
const filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
return items
.filter((item) => !item.roles || item.roles.includes(userRole))
.filter((item) => {
// Special handling for organizations menu item - allow org_admins
if (item.key === "organizations") {
return !item.roles || item.roles.includes(userRole) || isOrgAdmin;
}
return !item.roles || item.roles.includes(userRole);
})
.map((item) => ({
...item,
children: item.children ? filterItemsByRole(item.children) : undefined,