mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
Merge pull request #18400 from BerriAI/litellm_ui_org_admin
[Fix] Allow Organization Admins to See Organization Tab
This commit is contained in:
commit
d85bf4234e
4 changed files with 125 additions and 36 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue