refactor antd tabs and table

This commit is contained in:
yuneng-jiang 2026-02-09 14:05:05 -08:00
parent f8660a8ab0
commit c2536ee82a
13 changed files with 2104 additions and 1703 deletions

View file

@ -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 {

View file

@ -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";

View file

@ -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);

View file

@ -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";

View file

@ -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(

File diff suppressed because it is too large Load diff

View 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();
});
});

View 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>
);
};

View file

@ -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);
});
});
});

View file

@ -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

View file

@ -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);
});
});

View file

@ -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;