mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
refactor antd tabs and table
This commit is contained in:
parent
f8660a8ab0
commit
c2536ee82a
13 changed files with 2104 additions and 1703 deletions
|
|
@ -26,7 +26,7 @@ import ModelGroupAliasSettings from "../../../components/model_group_alias_setti
|
|||
import ModelInfoView from "../../../components/model_info_view";
|
||||
import NotificationsManager from "../../../components/molecules/notifications_manager";
|
||||
import PassThroughSettings from "../../../components/pass_through_settings";
|
||||
import TeamInfoView from "../../../components/team/team_info";
|
||||
import TeamInfoView from "../../../components/team/TeamInfo";
|
||||
import useAuthorized from "../hooks/useAuthorized";
|
||||
|
||||
interface ModelDashboardProps {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||
import { teamDeleteCall, Organization } from "@/components/networking";
|
||||
import { fetchTeams } from "@/components/common_components/fetch_teams";
|
||||
import { Form } from "antd";
|
||||
import TeamInfoView from "@/components/team/team_info";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import TeamSSOSettings from "@/components/TeamSSOSettings";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { Card, Button, Col, Text, Grid, TabPanel } from "@tremor/react";
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/team/team_info", () => ({
|
||||
vi.mock("@/components/team/TeamInfo", () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => {
|
||||
mockTeamInfoView(props);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import AvailableTeamsPanel from "@/components/team/available_teams";
|
||||
import TeamInfoView from "@/components/team/team_info";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import TeamSSOSettings from "@/components/TeamSSOSettings";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import TeamInfoView from "./team_info";
|
||||
import TeamInfoView from "./TeamInfo";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
teamInfoCall: vi.fn(),
|
||||
|
|
@ -40,7 +40,7 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
|||
useCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/team/team_member_view", () => ({
|
||||
vi.mock("@/components/team/TeamMemberTab", () => ({
|
||||
default: vi.fn(({ setIsAddMemberModalVisible }) => (
|
||||
<div>
|
||||
<button onClick={() => setIsAddMemberModalVisible(true)}>Add Member</button>
|
||||
|
|
@ -89,10 +89,6 @@ vi.mock("@/components/team/member_permissions", () => ({
|
|||
default: vi.fn(() => <div>Member Permissions</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/team/member_permissions", () => ({
|
||||
default: vi.fn(() => <div>Member Permissions</div>),
|
||||
}));
|
||||
|
||||
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
|
@ -335,10 +331,10 @@ describe("TeamInfoView", () => {
|
|||
await user.click(settingsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
const editButton = screen.getByRole("button", { name: /edit settings/i });
|
||||
await user.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -361,17 +357,17 @@ describe("TeamInfoView", () => {
|
|||
await user.click(settingsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
const editButton = screen.getByRole("button", { name: /edit settings/i });
|
||||
await user.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -438,10 +434,10 @@ describe("TeamInfoView", () => {
|
|||
await user.click(settingsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
const editButton = screen.getByRole("button", { name: /edit settings/i });
|
||||
await user.click(editButton);
|
||||
|
||||
const secretField = await screen.findByPlaceholderText(
|
||||
|
|
@ -472,10 +468,10 @@ describe("TeamInfoView", () => {
|
|||
await user.click(settingsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
const editButton = screen.getByRole("button", { name: /edit settings/i });
|
||||
await user.click(editButton);
|
||||
|
||||
const secretField = await screen.findByPlaceholderText(
|
||||
|
|
@ -502,10 +498,10 @@ describe("TeamInfoView", () => {
|
|||
await user.click(membersTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Add Member" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add member/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole("button", { name: "Add Member" });
|
||||
const addButton = screen.getByRole("button", { name: /add member/i });
|
||||
await user.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -603,6 +599,47 @@ describe("TeamInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should open Settings tab by default when editTeam is true and user can edit", async () => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} editTeam={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const teamNameElements = screen.queryAllByText("Test Team");
|
||||
expect(teamNameElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Team Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open Overview tab by default when editTeam is false", async () => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} editTeam={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const teamNameElements = screen.queryAllByText("Test Team");
|
||||
expect(teamNameElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Budget Status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open Overview tab by default when editTeam is true but user cannot edit", async () => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
|
||||
|
||||
renderWithProviders(
|
||||
<TeamInfoView {...defaultProps} editTeam={true} is_team_admin={false} is_proxy_admin={false} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const teamNameElements = screen.queryAllByText("Test Team");
|
||||
expect(teamNameElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Budget Status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display soft budget alerting emails in settings view when present", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
1294
ui/litellm-dashboard/src/components/team/TeamInfo.tsx
Normal file
1294
ui/litellm-dashboard/src/components/team/TeamInfo.tsx
Normal file
File diff suppressed because it is too large
Load diff
386
ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
Normal file
386
ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { TeamData } from "./TeamInfo";
|
||||
import TeamMembersComponent from "./TeamMemberTab";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/roles", () => ({
|
||||
isUserTeamAdminForSingleTeam: vi.fn(() => false),
|
||||
isProxyAdminRole: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
|
||||
const mockHandleMemberDelete = vi.fn();
|
||||
const mockSetSelectedEditMember = vi.fn();
|
||||
const mockSetIsEditMemberModalVisible = vi.fn();
|
||||
const mockSetIsAddMemberModalVisible = vi.fn();
|
||||
|
||||
const createMockTeamData = (overrides: Partial<TeamData> = {}): TeamData => ({
|
||||
team_id: "team-123",
|
||||
team_info: {
|
||||
team_alias: "Test Team",
|
||||
team_id: "team-123",
|
||||
organization_id: null,
|
||||
admins: ["admin@test.com"],
|
||||
members: ["user1@test.com"],
|
||||
members_with_roles: [
|
||||
{
|
||||
user_id: "user1@test.com",
|
||||
user_email: "user1@test.com",
|
||||
role: "member",
|
||||
},
|
||||
{
|
||||
user_id: "user2@test.com",
|
||||
user_email: "user2@test.com",
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
models: [],
|
||||
blocked: false,
|
||||
spend: 0,
|
||||
max_parallel_requests: null,
|
||||
budget_reset_at: null,
|
||||
model_id: null,
|
||||
litellm_model_table: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
team_member_budget_table: null,
|
||||
},
|
||||
keys: [],
|
||||
team_memberships: [
|
||||
{
|
||||
user_id: "user1@test.com",
|
||||
team_id: "team-123",
|
||||
budget_id: "budget1",
|
||||
spend: 100.5,
|
||||
litellm_budget_table: {
|
||||
budget_id: "budget1",
|
||||
soft_budget: null,
|
||||
max_budget: 1000,
|
||||
max_parallel_requests: null,
|
||||
tpm_limit: 10000,
|
||||
rpm_limit: 100,
|
||||
model_max_budget: null,
|
||||
budget_duration: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("TeamMembersComponent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useUISettings).mockReturnValue({
|
||||
data: { values: { disable_team_admin_delete_team_user: false } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAuthorized).mockReturnValue({
|
||||
isLoading: false,
|
||||
isAuthorized: true,
|
||||
userId: "test-user-id",
|
||||
userRole: "Admin",
|
||||
accessToken: "test-token",
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render team members table with headers", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: /user email/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /user id/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /team role/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /actions/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render team members data", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// user1@test.com appears twice (User ID and User Email columns)
|
||||
expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
const table = screen.getByRole("table");
|
||||
expect(table).toHaveTextContent("member");
|
||||
expect(table).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
it("should render Add Member button", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add Member")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display dash when user email is null", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData({
|
||||
team_info: {
|
||||
...createMockTeamData().team_info,
|
||||
members_with_roles: [
|
||||
{
|
||||
user_id: "user-without-email",
|
||||
user_email: null,
|
||||
role: "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Default Proxy Admin tag for default_user_id", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData({
|
||||
team_info: {
|
||||
...createMockTeamData().team_info,
|
||||
members_with_roles: [
|
||||
{
|
||||
user_id: "default_user_id",
|
||||
user_email: "admin@proxy.com",
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display spend and rate limits for member with membership", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/\$100\.5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/100 RPM/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000 TPM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display No Limit for budget when member has no budget", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No Limit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display No Limits for rate limits when member has no limits", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No Limits")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call setIsEditMemberModalVisible and setSelectedEditMember when edit button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(isProxyAdminRole).mockReturnValue(true);
|
||||
vi.mocked(isUserTeamAdminForSingleTeam).mockReturnValue(false);
|
||||
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={true}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButtons = screen.getAllByTestId("edit-member");
|
||||
await user.click(editButtons[0]);
|
||||
|
||||
expect(mockSetIsEditMemberModalVisible).toHaveBeenCalledWith(true);
|
||||
expect(mockSetSelectedEditMember).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call setIsAddMemberModalVisible when Add Member button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={true}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /add member/i });
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockSetIsAddMemberModalVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should hide delete button when disable_team_admin_delete_team_user is true and user is team admin", () => {
|
||||
vi.mocked(isProxyAdminRole).mockReturnValue(false);
|
||||
vi.mocked(isUserTeamAdminForSingleTeam).mockReturnValue(true);
|
||||
vi.mocked(useUISettings).mockReturnValue({
|
||||
data: { values: { disable_team_admin_delete_team_user: true } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={true}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("delete-member")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("edit-member")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should show delete button for proxy admin when canEditTeam is true", () => {
|
||||
vi.mocked(isProxyAdminRole).mockReturnValue(true);
|
||||
vi.mocked(isUserTeamAdminForSingleTeam).mockReturnValue(false);
|
||||
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={true}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("delete-member")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("edit-member")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should hide action buttons when canEditTeam is false", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("edit-member")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("delete-member")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
233
ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
Normal file
233
ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Member } from "@/components/networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { TeamData } from "./TeamInfo";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TeamMemberTabProps {
|
||||
teamData: TeamData;
|
||||
canEditTeam: boolean;
|
||||
handleMemberDelete: (member: Member) => void;
|
||||
setSelectedEditMember: (member: Member) => void;
|
||||
setIsEditMemberModalVisible: (visible: boolean) => void;
|
||||
setIsAddMemberModalVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export default function TeamMemberTab({
|
||||
teamData,
|
||||
canEditTeam,
|
||||
handleMemberDelete,
|
||||
setSelectedEditMember,
|
||||
setIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible,
|
||||
}: TeamMemberTabProps) {
|
||||
const formatNumber = (value: number | null): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
|
||||
if (typeof value === "number") {
|
||||
// Convert scientific notation to normal decimal
|
||||
const normalNumber = Number(value);
|
||||
|
||||
// If it's a whole number, return it without decimals
|
||||
if (normalNumber === Math.floor(normalNumber)) {
|
||||
return normalNumber.toString();
|
||||
}
|
||||
|
||||
// For decimal numbers, use toFixed and remove trailing zeros
|
||||
return formatNumberWithCommas(normalNumber, 8).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
return "0";
|
||||
};
|
||||
|
||||
// Helper function to get spend for a user
|
||||
const getUserSpend = (userId: string | null): number | null => {
|
||||
if (!userId) return 0;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.spend || 0;
|
||||
};
|
||||
|
||||
const getUserBudget = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const maxBudget = membership?.litellm_budget_table?.max_budget;
|
||||
if (maxBudget === null || maxBudget === undefined) {
|
||||
return null;
|
||||
}
|
||||
return formatNumber(maxBudget);
|
||||
};
|
||||
|
||||
// Helper function to get rate limits for a user
|
||||
const getUserRateLimits = (userId: string | null): string => {
|
||||
if (!userId) return "No Limits";
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
|
||||
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
|
||||
|
||||
const rpmText = rpmLimit ? `${formatNumber(rpmLimit)} RPM` : null;
|
||||
const tpmText = tpmLimit ? `${formatNumber(tpmLimit)} TPM` : null;
|
||||
|
||||
const limits = [rpmText, tpmText].filter(Boolean);
|
||||
return limits.length > 0 ? limits.join(" / ") : "No Limits";
|
||||
};
|
||||
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { userId, userRole } = useAuthorized();
|
||||
const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user);
|
||||
const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || "");
|
||||
const isProxyAdmin = isProxyAdminRole(userRole || "");
|
||||
|
||||
const columns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "User Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
render: (email: string | null) => (
|
||||
<Text>{email || "-"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "User ID",
|
||||
dataIndex: "user_id",
|
||||
key: "user_id",
|
||||
render: (userId: string | null) =>
|
||||
userId === "default_user_id" ? (
|
||||
<Tag color="blue">Default Proxy Admin</Tag>
|
||||
) : (
|
||||
<Text>{userId}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Space direction="horizontal">
|
||||
Team Role
|
||||
<Tooltip title="This role applies only to this team and is independent from the user's proxy-level role.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
dataIndex: "role",
|
||||
key: "role",
|
||||
render: (role: string) => (
|
||||
<Space>
|
||||
{role?.toLowerCase() === "admin" ? (
|
||||
<CrownOutlined />
|
||||
) : (
|
||||
<UserOutlined />
|
||||
)}
|
||||
<Text style={{ textTransform: "capitalize" }}>{role}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Space direction="horizontal">
|
||||
Team Member Spend (USD)
|
||||
<Tooltip title="This is the amount spent by a user in the team.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
key: "spend",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<Text>
|
||||
${formatNumberWithCommas(getUserSpend(record.user_id), 4)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Team Member Budget (USD)",
|
||||
key: "budget",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const budget = getUserBudget(record.user_id);
|
||||
return (
|
||||
<Text >
|
||||
{budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Space direction="horizontal">
|
||||
Team Member Rate Limits
|
||||
<Tooltip title="Rate limits for this member's usage within this team.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
key: "rate_limits",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<Text>{getUserRateLimits(record.user_id)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
fixed: "right",
|
||||
width: 120,
|
||||
render: (_: unknown, record: Member) =>
|
||||
canEditTeam ? (
|
||||
<div className="flex gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => {
|
||||
const membership = teamData.team_memberships.find(
|
||||
(tm) => tm.user_id === record.user_id
|
||||
);
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team:
|
||||
membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit:
|
||||
membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit:
|
||||
membership?.litellm_budget_table?.rpm_limit || null,
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
{(isProxyAdmin ||
|
||||
(isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => handleMemberDelete(record)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={teamData.team_info.members_with_roles}
|
||||
rowKey={(record, index) => record.user_id || String(index)}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: "max-content" }}
|
||||
/>
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
type="primary"
|
||||
onClick={() => setIsAddMemberModalVisible(true)}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTeamInfoDefaultTab,
|
||||
getTeamInfoVisibleTabs,
|
||||
isTeamInfoTabVisible,
|
||||
TEAM_INFO_TAB_KEYS,
|
||||
TEAM_INFO_TAB_LABELS,
|
||||
} from "./tabVisibilityUtils";
|
||||
|
||||
describe("team_info_tabs", () => {
|
||||
describe("TEAM_INFO_TAB_LABELS", () => {
|
||||
it("should have label for every tab key", () => {
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]).toBe("Overview");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]).toBe("Members");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]).toBe("Member Permissions");
|
||||
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS]).toBe("Settings");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamInfoVisibleTabs", () => {
|
||||
it("returns only overview when user cannot edit team", () => {
|
||||
const tabs = getTeamInfoVisibleTabs(false);
|
||||
expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW]);
|
||||
});
|
||||
|
||||
it("returns all tabs when user can edit team", () => {
|
||||
const tabs = getTeamInfoVisibleTabs(true);
|
||||
expect(tabs).toEqual([
|
||||
TEAM_INFO_TAB_KEYS.OVERVIEW,
|
||||
TEAM_INFO_TAB_KEYS.MEMBERS,
|
||||
TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS,
|
||||
TEAM_INFO_TAB_KEYS.SETTINGS,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamInfoDefaultTab", () => {
|
||||
it("returns overview when editTeam is false", () => {
|
||||
expect(getTeamInfoDefaultTab(false, true)).toBe(TEAM_INFO_TAB_KEYS.OVERVIEW);
|
||||
expect(getTeamInfoDefaultTab(false, false)).toBe(TEAM_INFO_TAB_KEYS.OVERVIEW);
|
||||
});
|
||||
|
||||
it("returns settings when editTeam is true and user can edit", () => {
|
||||
expect(getTeamInfoDefaultTab(true, true)).toBe(TEAM_INFO_TAB_KEYS.SETTINGS);
|
||||
});
|
||||
|
||||
it("returns overview when editTeam is true but user cannot edit", () => {
|
||||
expect(getTeamInfoDefaultTab(true, false)).toBe(TEAM_INFO_TAB_KEYS.OVERVIEW);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTeamInfoTabVisible", () => {
|
||||
it("always returns true for overview tab", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.OVERVIEW, false)).toBe(true);
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.OVERVIEW, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for members tab when user cannot edit", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBERS, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for members tab when user can edit", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBERS, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for settings tab when user cannot edit", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.SETTINGS, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for settings tab when user can edit", () => {
|
||||
expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.SETTINGS, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Team info tab configuration and permission logic.
|
||||
* Extracted for testability - permission rules can be unit tested in isolation.
|
||||
*/
|
||||
|
||||
export const TEAM_INFO_TAB_KEYS = {
|
||||
OVERVIEW: "overview",
|
||||
MEMBERS: "members",
|
||||
MEMBER_PERMISSIONS: "member-permissions",
|
||||
SETTINGS: "settings",
|
||||
} as const;
|
||||
|
||||
export const TEAM_INFO_TAB_LABELS: Record<string, string> = {
|
||||
[TEAM_INFO_TAB_KEYS.OVERVIEW]: "Overview",
|
||||
[TEAM_INFO_TAB_KEYS.MEMBERS]: "Members",
|
||||
[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "Member Permissions",
|
||||
[TEAM_INFO_TAB_KEYS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of tab keys that should be visible based on permissions.
|
||||
* - Overview: always visible
|
||||
* - Members, Member Permissions, Settings: only when canEditTeam is true
|
||||
*/
|
||||
export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] {
|
||||
const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW];
|
||||
if (canEditTeam) {
|
||||
return [
|
||||
...baseTabs,
|
||||
TEAM_INFO_TAB_KEYS.MEMBERS,
|
||||
TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS,
|
||||
TEAM_INFO_TAB_KEYS.SETTINGS,
|
||||
];
|
||||
}
|
||||
return baseTabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default active tab key based on permissions and edit intent.
|
||||
* - When editTeam is true and user can edit: open Settings tab
|
||||
* - Otherwise: open Overview tab
|
||||
*/
|
||||
export function getTeamInfoDefaultTab(editTeam: boolean, canEditTeam: boolean): string {
|
||||
if (editTeam && canEditTeam) {
|
||||
return TEAM_INFO_TAB_KEYS.SETTINGS;
|
||||
}
|
||||
return TEAM_INFO_TAB_KEYS.OVERVIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific tab should be visible based on permissions.
|
||||
*/
|
||||
export function isTeamInfoTabVisible(
|
||||
tabKey: string,
|
||||
canEditTeam: boolean
|
||||
): boolean {
|
||||
const visibleTabs = getTeamInfoVisibleTabs(canEditTeam);
|
||||
return visibleTabs.includes(tabKey);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,193 +0,0 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { TeamData } from "./team_info";
|
||||
import TeamMembersComponent from "./team_member_view";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/roles", () => ({
|
||||
isUserTeamAdminForSingleTeam: vi.fn(() => false),
|
||||
isProxyAdminRole: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
|
||||
describe("TeamMembersComponent", () => {
|
||||
const mockHandleMemberDelete = vi.fn();
|
||||
const mockSetSelectedEditMember = vi.fn();
|
||||
const mockSetIsEditMemberModalVisible = vi.fn();
|
||||
const mockSetIsAddMemberModalVisible = vi.fn();
|
||||
|
||||
const mockTeamData: TeamData = {
|
||||
team_id: "team-123",
|
||||
team_info: {
|
||||
team_alias: "Test Team",
|
||||
team_id: "team-123",
|
||||
organization_id: null,
|
||||
admins: ["admin@test.com"],
|
||||
members: ["user1@test.com"],
|
||||
members_with_roles: [
|
||||
{
|
||||
user_id: "user1@test.com",
|
||||
user_email: "user1@test.com",
|
||||
role: "member",
|
||||
},
|
||||
{
|
||||
user_id: "user2@test.com",
|
||||
user_email: "user2@test.com",
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
models: [],
|
||||
blocked: false,
|
||||
spend: 0,
|
||||
max_parallel_requests: null,
|
||||
budget_reset_at: null,
|
||||
model_id: null,
|
||||
litellm_model_table: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
team_member_budget_table: null,
|
||||
},
|
||||
keys: [],
|
||||
team_memberships: [
|
||||
{
|
||||
user_id: "user1@test.com",
|
||||
team_id: "team-123",
|
||||
budget_id: "budget1",
|
||||
spend: 100.5,
|
||||
litellm_budget_table: {
|
||||
budget_id: "budget1",
|
||||
soft_budget: null,
|
||||
max_budget: 1000,
|
||||
max_parallel_requests: null,
|
||||
tpm_limit: 10000,
|
||||
rpm_limit: 100,
|
||||
model_max_budget: null,
|
||||
budget_duration: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useUISettings).mockReturnValue({
|
||||
data: { values: { disable_team_admin_delete_team_user: false } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAuthorized).mockReturnValue({
|
||||
userId: "test-user-id",
|
||||
userRole: "Admin",
|
||||
accessToken: "test-token",
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render team members table with headers", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("User ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("User Email")).toBeInTheDocument();
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Spend (USD)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Budget (USD)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Rate Limits")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render team members data", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// user1@test.com appears twice (User ID and User Email columns)
|
||||
expect(screen.getAllByText("user1@test.com")).toHaveLength(2);
|
||||
// user2@test.com appears twice (User ID and User Email columns)
|
||||
expect(screen.getAllByText("user2@test.com")).toHaveLength(2);
|
||||
expect(screen.getByText("member")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Add Member button", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add Member")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show delete button for proxy admin when canEditTeam is true", () => {
|
||||
vi.mocked(isProxyAdminRole).mockReturnValue(true);
|
||||
vi.mocked(isUserTeamAdminForSingleTeam).mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={true}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify that action buttons are rendered when canEditTeam is true
|
||||
// For proxy admin, both edit and delete buttons should be visible
|
||||
// Check for clickable icon elements (Tremor Icon components with cursor-pointer class)
|
||||
const clickableIcons = container.querySelectorAll('[class*="cursor-pointer"]');
|
||||
// Should have at least 4 icons: 2 edit buttons + 2 delete buttons for 2 members
|
||||
expect(clickableIcons.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify members are rendered
|
||||
expect(screen.getAllByText("user1@test.com").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("user2@test.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Member } from "@/components/networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Button as TremorButton,
|
||||
} from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React from "react";
|
||||
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { TeamData } from "./team_info";
|
||||
|
||||
interface TeamMembersComponentProps {
|
||||
teamData: TeamData;
|
||||
canEditTeam: boolean;
|
||||
handleMemberDelete: (member: Member) => void;
|
||||
setSelectedEditMember: (member: Member) => void;
|
||||
setIsEditMemberModalVisible: (visible: boolean) => void;
|
||||
setIsAddMemberModalVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const TeamMembersComponent: React.FC<TeamMembersComponentProps> = ({
|
||||
teamData,
|
||||
canEditTeam,
|
||||
handleMemberDelete,
|
||||
setSelectedEditMember,
|
||||
setIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible,
|
||||
}) => {
|
||||
console.log("Team data", teamData);
|
||||
// Helper function to convert scientific notation to normal decimal format
|
||||
const formatNumber = (value: number | null): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
|
||||
if (typeof value === "number") {
|
||||
// Convert scientific notation to normal decimal
|
||||
const normalNumber = Number(value);
|
||||
|
||||
// If it's a whole number, return it without decimals
|
||||
if (normalNumber === Math.floor(normalNumber)) {
|
||||
return normalNumber.toString();
|
||||
}
|
||||
|
||||
// For decimal numbers, use toFixed and remove trailing zeros
|
||||
return formatNumberWithCommas(normalNumber, 8).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
return "0";
|
||||
};
|
||||
|
||||
// Helper function to get spend for a user
|
||||
const getUserSpend = (userId: string | null): number | null => {
|
||||
if (!userId) return 0;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.spend || 0;
|
||||
};
|
||||
|
||||
const getUserBudget = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
console.log(`membership=${membership}`);
|
||||
const maxBudget = membership?.litellm_budget_table?.max_budget;
|
||||
if (maxBudget === null || maxBudget === undefined) {
|
||||
return null;
|
||||
}
|
||||
return formatNumber(maxBudget);
|
||||
};
|
||||
|
||||
// Helper function to get rate limits for a user
|
||||
const getUserRateLimits = (userId: string | null): string => {
|
||||
if (!userId) return "No Limits";
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
|
||||
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
|
||||
|
||||
const rpmText = rpmLimit ? `${formatNumber(rpmLimit)} RPM` : null;
|
||||
const tpmText = tpmLimit ? `${formatNumber(tpmLimit)} TPM` : null;
|
||||
|
||||
const limits = [rpmText, tpmText].filter(Boolean);
|
||||
return limits.length > 0 ? limits.join(" / ") : "No Limits";
|
||||
};
|
||||
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { userId, userRole } = useAuthorized();
|
||||
const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user);
|
||||
const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || "");
|
||||
const isProxyAdmin = isProxyAdminRole(userRole || "");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="w-full mx-auto flex-auto overflow-auto max-h-[50vh]">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-full">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
Team Member Spend (USD){" "}
|
||||
<Tooltip title="This is the amount spent by a user in the team.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Team Member Budget (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
Team Member Rate Limits{" "}
|
||||
<Tooltip title="Rate limits for this member's usage within this team.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="sticky right-0 bg-white z-10 border-l border-gray-200">
|
||||
Actions
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{teamData.team_info.members_with_roles.map((member: Member, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_id}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_email ? member.user_email : "No Email"}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.role}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">${formatNumberWithCommas(getUserSpend(member.user_id), 4)}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">
|
||||
{getUserBudget(member.user_id)
|
||||
? `$${formatNumberWithCommas(Number(getUserBudget(member.user_id)), 4)}`
|
||||
: "No Limit"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{getUserRateLimits(member.user_id)}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 bg-white z-10 border-l border-gray-200">
|
||||
{canEditTeam && (
|
||||
<div className="flex gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
onClick={() => {
|
||||
// Get budget and rate limit data from team membership
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === member.user_id);
|
||||
const enhancedMember = {
|
||||
...member,
|
||||
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
{(isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
onClick={() => handleMemberDelete(member)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
<TremorButton onClick={() => setIsAddMemberModalVisible(true)}>Add Member</TremorButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamMembersComponent;
|
||||
Loading…
Add table
Reference in a new issue