diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index c522d4ce1e5..8b934e10779 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -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 ( - - ); + return ; }; export default SidebarProvider; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/organizations/useOrganizations.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/organizations/useOrganizations.ts new file mode 100644 index 00000000000..ed194203d9a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/organizations/useOrganizations.ts @@ -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 => { + return useQuery({ + queryKey: organizationKeys.list({}), + queryFn: async () => await organizationListCall(accessToken!), + enabled: Boolean(accessToken), + }); +}; diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 1512c8b9350..09109300dce 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -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(); + renderWithProviders(); 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(); + renderWithProviders(); - 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(); + const { container } = renderWithProviders(); const allRenderedKeys = getAllKeysFromMenu(container); const keySet = new Set(); @@ -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(); + + expect(screen.getByText("Organizations")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index ec000f7582e..6f716ccd66c 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -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 = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { +const Sidebar: React.FC = ({ 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 = ({ accessToken, setPage, userRole, defau Usage - ), + ), }, { key: "logs", @@ -302,7 +315,13 @@ const Sidebar: React.FC = ({ 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,