From c2536ee82ac3cd3847454bf453262039f0668ee8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 9 Feb 2026 14:05:05 -0800 Subject: [PATCH] refactor antd tabs and table --- .../ModelsAndEndpointsView.tsx | 2 +- .../src/app/(dashboard)/teams/TeamsView.tsx | 2 +- .../src/components/OldTeams.test.tsx | 2 +- .../src/components/OldTeams.tsx | 2 +- .../{team_info.test.tsx => TeamInfo.test.tsx} | 71 +- .../src/components/team/TeamInfo.tsx | 1294 ++++++++++++++++ .../components/team/TeamMemberTab.test.tsx | 386 +++++ .../src/components/team/TeamMemberTab.tsx | 233 +++ .../team/tabVisibilityUtils.test.ts | 74 + .../src/components/team/tabVisibilityUtils.ts | 59 + .../src/components/team/team_info.tsx | 1296 ----------------- .../components/team/team_member_view.test.tsx | 193 --- .../src/components/team/team_member_view.tsx | 193 --- 13 files changed, 2104 insertions(+), 1703 deletions(-) rename ui/litellm-dashboard/src/components/team/{team_info.test.tsx => TeamInfo.test.tsx} (88%) create mode 100644 ui/litellm-dashboard/src/components/team/TeamInfo.tsx create mode 100644 ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx create mode 100644 ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx create mode 100644 ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts create mode 100644 ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts delete mode 100644 ui/litellm-dashboard/src/components/team/team_info.tsx delete mode 100644 ui/litellm-dashboard/src/components/team/team_member_view.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/team/team_member_view.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 8bfbaa8d3a6..9d77774cb4c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index 10616e95523..88bdf3cdda0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index 91b428c8c98..0ca37cc82d7 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -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); diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 4eab197db14..ecc6a624be0 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/team/team_info.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx similarity index 88% rename from ui/litellm-dashboard/src/components/team/team_info.test.tsx rename to ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index fa1b13fbd2d..7a15a6e3f22 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -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 }) => (
@@ -89,10 +89,6 @@ vi.mock("@/components/team/member_permissions", () => ({ default: vi.fn(() =>
Member Permissions
), })); -vi.mock("@/components/team/member_permissions", () => ({ - default: vi.fn(() =>
Member Permissions
), -})); - 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(); + + 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(); + + 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( + + ); + + 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( diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx new file mode 100644 index 00000000000..d081538fb64 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -0,0 +1,1294 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import UserSearchModal from "@/components/common_components/user_search_modal"; +import { + getGuardrailsList, + getPoliciesList, + getPolicyInfoWithGuardrails, + Member, + Organization, + organizationInfoCall, + teamInfoCall, + teamMemberAddCall, + teamMemberDeleteCall, + teamMemberUpdateCall, + teamUpdateCall, +} from "@/components/networking"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; +import { isProxyAdminRole } from "@/utils/roles"; +import { EditOutlined, InfoCircleOutlined, SaveOutlined } from "@ant-design/icons"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react"; +import { Button, Form, Input, message, Select, Switch, Tabs, Tooltip } from "antd"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; +import AgentSelector from "../agent_management/AgentSelector"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import DurationSelect from "../common_components/DurationSelect"; +import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; +import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; +import LoggingSettingsView from "../logging_settings_view"; +import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; +import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; +import { ModelSelect } from "../ModelSelect/ModelSelect"; +import NotificationsManager from "../molecules/notifications_manager"; +import { fetchMCPAccessGroups } from "../networking"; +import ObjectPermissionsView from "../object_permissions_view"; +import NumericalInput from "../shared/numerical_input"; +import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; +import EditLoggingSettings from "./EditLoggingSettings"; +import MemberModal from "./EditMembership"; +import MemberPermissions from "./member_permissions"; +import { + getTeamInfoDefaultTab, + getTeamInfoVisibleTabs, + TEAM_INFO_TAB_KEYS, + TEAM_INFO_TAB_LABELS, +} from "./tabVisibilityUtils"; +import TeamMembersComponent from "./TeamMemberTab"; + +export interface TeamMembership { + user_id: string; + team_id: string; + budget_id: string; + spend: number; + litellm_budget_table: { + budget_id: string; + soft_budget: number | null; + max_budget: number | null; + max_parallel_requests: number | null; + tpm_limit: number | null; + rpm_limit: number | null; + model_max_budget: Record | null; + budget_duration: string | null; + }; +} + +export interface TeamData { + team_id: string; + team_info: { + team_alias: string; + team_id: string; + organization_id: string | null; + admins: string[]; + members: string[]; + members_with_roles: Member[]; + metadata: Record; + tpm_limit: number | null; + rpm_limit: number | null; + max_budget: number | null; + soft_budget?: number | null; + budget_duration: string | null; + models: string[]; + blocked: boolean; + spend: number; + max_parallel_requests: number | null; + budget_reset_at: string | null; + model_id: string | null; + litellm_model_table: { + model_aliases: Record; + } | null; + created_at: string; + guardrails?: string[]; + policies?: string[]; + object_permission?: { + object_permission_id: string; + mcp_servers: string[]; + mcp_access_groups?: string[]; + mcp_tool_permissions?: Record; + vector_stores: string[]; + agents?: string[]; + agent_access_groups?: string[]; + }; + team_member_budget_table: { + max_budget: number; + budget_duration: string; + tpm_limit: number | null; + rpm_limit: number | null; + } | null; + }; + keys: any[]; + team_memberships: TeamMembership[]; +} + +export interface TeamInfoProps { + teamId: string; + onUpdate: (data: any) => void; + onClose: () => void; + accessToken: string | null; + is_team_admin: boolean; + is_proxy_admin: boolean; + userModels: string[]; + editTeam: boolean; + premiumUser?: boolean; +} + +const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { + let tempModelsToPick = []; + + if (organization) { + // Check if organization has "all-proxy-models" in its models array + if (organization.models.includes("all-proxy-models")) { + // Treat as all-proxy-models (use userModels) + tempModelsToPick = userModels; + } else if (organization.models.length > 0) { + // Organization has specific models + tempModelsToPick = organization.models; + } else { + // Empty array [] is treated as all-proxy-models + tempModelsToPick = userModels; + } + } else { + // No organization, show all available models + tempModelsToPick = userModels; + } + + return unfurlWildcardModelsInList(tempModelsToPick, userModels); +}; + +const TeamInfoView: React.FC = ({ + teamId, + onClose, + accessToken, + is_team_admin, + is_proxy_admin, + userModels, + editTeam, + premiumUser = false, + onUpdate, +}) => { + const [teamData, setTeamData] = useState(null); + const [loading, setLoading] = useState(true); + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [form] = Form.useForm(); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); + const [selectedEditMember, setSelectedEditMember] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); + const [copiedStates, setCopiedStates] = useState>({}); + const [guardrailsList, setGuardrailsList] = useState([]); + const [policiesList, setPoliciesList] = useState([]); + const [policyGuardrails, setPolicyGuardrails] = useState>({}); + const [loadingPolicies, setLoadingPolicies] = useState(false); + const [memberToDelete, setMemberToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isTeamSaving, setIsTeamSaving] = useState(false); + const [organization, setOrganization] = useState(null); + const { userRole } = useAuthorized(); + + const canEditTeam = is_team_admin || is_proxy_admin; + const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); + const defaultTabKey = useMemo( + () => getTeamInfoDefaultTab(editTeam, canEditTeam), + [editTeam, canEditTeam] + ); + + const fetchTeamInfo = async () => { + try { + setLoading(true); + if (!accessToken) return; + const response = await teamInfoCall(accessToken, teamId); + setTeamData(response); + } catch (error) { + NotificationsManager.fromBackend("Failed to load team information"); + console.error("Error fetching team info:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTeamInfo(); + }, [teamId, accessToken]); + + // Fetch organization data when team has organization_id + useEffect(() => { + const fetchOrganization = async () => { + if (!accessToken || !teamData?.team_info?.organization_id) { + setOrganization(null); + return; + } + + try { + const orgData = await organizationInfoCall(accessToken, teamData.team_info.organization_id); + setOrganization(orgData); + } catch (error) { + console.error("Error fetching organization info:", error); + setOrganization(null); + } + }; + + fetchOrganization(); + }, [accessToken, teamData?.team_info?.organization_id]); + + // Compute modelsToPick based on organization and userModels + const modelsToPick = useMemo(() => { + return getOrganizationModels(organization, userModels); + }, [organization, userModels]); + + const fetchMcpAccessGroups = async () => { + if (!accessToken) return; + if (mcpAccessGroupsLoaded) return; + try { + const groups = await fetchMCPAccessGroups(accessToken); + setMcpAccessGroups(groups); + setMcpAccessGroupsLoaded(true); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + } + }; + + useEffect(() => { + const fetchGuardrails = async () => { + try { + if (!accessToken) return; + const response = await getGuardrailsList(accessToken); + const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + setGuardrailsList(guardrailNames); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + } + }; + + const fetchPolicies = async () => { + try { + if (!accessToken) return; + const response = await getPoliciesList(accessToken); + const policyNames = response.policies.map((p: { policy_name: string }) => p.policy_name); + setPoliciesList(policyNames); + } catch (error) { + console.error("Failed to fetch policies:", error); + } + }; + + fetchGuardrails(); + fetchPolicies(); + }, [accessToken]); + + // Fetch resolved guardrails for all policies + useEffect(() => { + const fetchPolicyGuardrails = async () => { + if (!accessToken || !teamData?.team_info?.policies || teamData.team_info.policies.length === 0) { + return; + } + + setLoadingPolicies(true); + const guardrailsMap: Record = {}; + + try { + await Promise.all( + teamData.team_info.policies.map(async (policyName: string) => { + try { + const policyInfo = await getPolicyInfoWithGuardrails(accessToken, policyName); + guardrailsMap[policyName] = policyInfo.resolved_guardrails || []; + } catch (error) { + console.error(`Failed to fetch guardrails for policy ${policyName}:`, error); + guardrailsMap[policyName] = []; + } + }) + ); + setPolicyGuardrails(guardrailsMap); + } catch (error) { + console.error("Failed to fetch policy guardrails:", error); + } finally { + setLoadingPolicies(false); + } + }; + + fetchPolicyGuardrails(); + }, [accessToken, teamData?.team_info?.policies]); + + const handleMemberCreate = async (values: any) => { + try { + if (accessToken == null) return; + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + }; + + await teamMemberAddCall(accessToken, teamId, member); + + NotificationsManager.success("Team member added successfully"); + setIsAddMemberModalVisible(false); + form.resetFields(); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error: any) { + let errMsg = "Failed to add team member"; + + if (error?.raw?.detail?.error?.includes("Assigning team admins is a premium feature")) { + errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; + } else if (error?.message) { + errMsg = error.message; + } + + NotificationsManager.fromBackend(errMsg); + console.error("Error adding team member:", error); + } + }; + + const handleMemberUpdate = async (values: any) => { + try { + if (accessToken == null) { + return; + } + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + max_budget_in_team: values.max_budget_in_team, + tpm_limit: values.tpm_limit, + rpm_limit: values.rpm_limit, + }; + message.destroy(); // Remove all existing toasts + + await teamMemberUpdateCall(accessToken, teamId, member); + + NotificationsManager.success("Team member updated successfully"); + setIsEditMemberModalVisible(false); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error: any) { + let errMsg = "Failed to update team member"; + if (error?.raw?.detail?.includes("Assigning team admins is a premium feature")) { + errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; + } else if (error?.message) { + errMsg = error.message; + } + setIsEditMemberModalVisible(false); + + message.destroy(); // Remove all existing toasts + + NotificationsManager.fromBackend(errMsg); + console.error("Error updating team member:", error); + } + }; + + const handleMemberDelete = (member: Member) => { + setMemberToDelete(member); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!memberToDelete || !accessToken) return; + + setIsDeleting(true); + try { + await teamMemberDeleteCall(accessToken, teamId, memberToDelete); + + NotificationsManager.success("Team member removed successfully"); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error) { + NotificationsManager.fromBackend("Failed to remove team member"); + console.error("Error removing team member:", error); + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + setMemberToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setIsDeleteModalOpen(false); + setMemberToDelete(null); + }; + + const handleTeamUpdate = async (values: any) => { + try { + if (!accessToken) return; + setIsTeamSaving(true); + + let parsedMetadata = {}; + try { + const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {}; + // Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately + const { soft_budget_alerting_emails, ...rest } = rawMetadata; + parsedMetadata = rest; + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in metadata field"); + return; + } + + let secretManagerSettings: Record | undefined; + if (typeof values.secret_manager_settings === "string") { + const trimmedSecretConfig = values.secret_manager_settings.trim(); + if (trimmedSecretConfig.length > 0) { + try { + secretManagerSettings = JSON.parse(values.secret_manager_settings); + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in secret manager settings"); + return; + } + } + } + + const sanitizeNumeric = (v: any) => { + if (v === null || v === undefined) return null; + if (typeof v === "string" && v.trim() === "") return null; + if (typeof v === "number" && Number.isNaN(v)) return null; + return v; + }; + + const updateData: any = { + team_id: teamId, + team_alias: values.team_alias, + models: values.models, + tpm_limit: sanitizeNumeric(values.tpm_limit), + rpm_limit: sanitizeNumeric(values.rpm_limit), + max_budget: values.max_budget, + soft_budget: sanitizeNumeric(values.soft_budget), + budget_duration: values.budget_duration, + metadata: { + ...parsedMetadata, + ...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}), + ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), + disable_global_guardrails: values.disable_global_guardrails || false, + soft_budget_alerting_emails: + typeof values.soft_budget_alerting_emails === "string" + ? values.soft_budget_alerting_emails + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email.length > 0) + : values.soft_budget_alerting_emails || [], + ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), + }, + ...(values.policies?.length > 0 ? { policies: values.policies } : {}), + organization_id: values.organization_id, + }; + + updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); + updateData.team_member_budget_duration = values.team_member_budget_duration; + + if (values.team_member_budget !== undefined) { + updateData.team_member_budget = Number(values.team_member_budget); + } + + if (values.team_member_key_duration !== undefined) { + updateData.team_member_key_duration = values.team_member_key_duration; + } + + if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) { + updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit); + updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit); + } + + // Handle object_permission updates + const { servers, accessGroups } = values.mcp_servers_and_groups || { + servers: [], + accessGroups: [], + }; + const serverIds = new Set(servers || []); + const mcpToolPermissions = Object.fromEntries( + Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)), + ); + + updateData.object_permission = {}; + if (servers) { + updateData.object_permission.mcp_servers = servers; + } + if (accessGroups) { + updateData.object_permission.mcp_access_groups = accessGroups; + } + if (mcpToolPermissions) { + updateData.object_permission.mcp_tool_permissions = mcpToolPermissions; + } + delete values.mcp_servers_and_groups; + delete values.mcp_tool_permissions; + + // Handle agent permissions + const { agents, accessGroups: agentAccessGroups } = values.agents_and_groups || { + agents: [], + accessGroups: [], + }; + if (agents && agents.length > 0) { + updateData.object_permission.agents = agents; + } + if (agentAccessGroups && agentAccessGroups.length > 0) { + updateData.object_permission.agent_access_groups = agentAccessGroups; + } + delete values.agents_and_groups; + + // Handle vector stores permissions + if (values.vector_stores && values.vector_stores.length > 0) { + updateData.object_permission.vector_stores = values.vector_stores; + } + + const response = await teamUpdateCall(accessToken, updateData); + + NotificationsManager.success("Team settings updated successfully"); + setIsEditing(false); + fetchTeamInfo(); + } catch (error) { + console.error("Error updating team:", error); + } finally { + setIsTeamSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + if (!teamData?.team_info) { + return
Team not found
; + } + + const { team_info: info } = teamData; + + const copyToClipboard = async (text: string, key: string) => { + const success = await utilCopyToClipboard(text); + if (success) { + setCopiedStates((prev) => ({ ...prev, [key]: true })); + setTimeout(() => { + setCopiedStates((prev) => ({ ...prev, [key]: false })); + }, 2000); + } + }; + + return ( +
+
+
+ + {info.team_alias} +
+ {info.team_id} +
+
+
+ + + + Budget Status +
+ ${formatNumberWithCommas(info.spend, 4)} + + of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} + + {info.budget_duration && Reset: {info.budget_duration}} +
+ {info.team_member_budget_table && ( + + Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} + + )} +
+
+ + + Rate Limits +
+ TPM: {info.tpm_limit || "Unlimited"} + RPM: {info.rpm_limit || "Unlimited"} + {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} +
+
+ + + Models +
+ {info.models.length === 0 ? ( + All proxy models + ) : ( + info.models.map((model, index) => ( + + {model} + + )) + )} +
+
+ + + Virtual Keys +
+ User Keys: {teamData.keys.filter((key) => key.user_id).length} + Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length} + Total: {teamData.keys.length} +
+
+ + + + + Guardrails + {info.guardrails && info.guardrails.length > 0 ? ( +
+ {info.guardrails.map((guardrail: string, index: number) => ( + + {guardrail} + + ))} +
+ ) : ( + No guardrails configured + )} + {info.metadata?.disable_global_guardrails && ( +
+ Global Guardrails Disabled +
+ )} +
+ + + Policies + {info.policies && info.policies.length > 0 ? ( +
+ {info.policies.map((policy: string, index: number) => ( +
+
+ {policy} + {loadingPolicies && Loading guardrails...} +
+ {!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && ( +
+ Resolved Guardrails: +
+ {policyGuardrails[policy].map((guardrail: string, gIndex: number) => ( + + {guardrail} + + ))} +
+
+ )} +
+ ))} +
+ ) : ( + No policies configured + )} +
+ + + + ), + }, + { + key: TEAM_INFO_TAB_KEYS.MEMBERS, + label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS], + children: ( + + ), + }, + { + key: TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, + label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS], + children: ( + + ), + }, + { + key: TEAM_INFO_TAB_KEYS.SETTINGS, + label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS], + children: ( + +
+ Team Settings + {canEditTeam && !isEditing && ( + + )} +
+ + {isEditing ? ( +
rest)(info.metadata), + null, + 2, + ) + : "", + logging_settings: info.metadata?.logging || [], + secret_manager_settings: info.metadata?.secret_manager_settings + ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) + : "", + organization_id: info.organization_id, + vector_stores: info.object_permission?.vector_stores || [], + mcp_servers: info.object_permission?.mcp_servers || [], + mcp_access_groups: info.object_permission?.mcp_access_groups || [], + mcp_servers_and_groups: { + servers: info.object_permission?.mcp_servers || [], + accessGroups: info.object_permission?.mcp_access_groups || [], + }, + mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {}, + agents_and_groups: { + agents: info.object_permission?.agents || [], + accessGroups: info.object_permission?.agent_access_groups || [], + }, + }} + layout="vertical" + > + + + + + + form.setFieldValue("models", values)} + teamID={teamId} + organizationID={teamData?.team_info?.organization_id || undefined} + options={{ + includeSpecialOptions: true, + includeUserModels: !teamData?.team_info?.organization_id, + showAllProxyModelsOverride: isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id, + }} + context="team" + dataTestId="models-select" + /> + + + + + + + + + + + + + + + + + + + + form.setFieldValue("team_member_budget_duration", value)} + value={form.getFieldValue("team_member_budget_duration")} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Guardrails{" "} + + e.stopPropagation()} + > + + + + + } + name="guardrails" + help="Select existing guardrails or enter new ones" + > + ({ value: name, label: name }))} + /> + + + + form.setFieldValue("vector_stores", values)} + value={form.getFieldValue("vector_stores")} + accessToken={accessToken || ""} + placeholder="Select vector stores" + /> + + + + form.setFieldValue("allowed_passthrough_routes", values)} + value={form.getFieldValue("allowed_passthrough_routes")} + accessToken={accessToken || ""} + placeholder="Select pass through routes" + /> + + + + form.setFieldValue("mcp_servers_and_groups", val)} + value={form.getFieldValue("mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + {/* Hidden field to register mcp_tool_permissions with the form */} + + + + prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+ + + form.setFieldValue("agents_and_groups", val)} + value={form.getFieldValue("agents_and_groups")} + accessToken={accessToken || ""} + placeholder="Select agents or access groups (optional)" + /> + + + + + + + + form.setFieldValue("logging_settings", values)} + /> + + + { + if (!value) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + + + + + + +
+
+ + +
+
+
+ ) : ( +
+
+ Team Name +
{info.team_alias}
+
+
+ Team ID +
{info.team_id}
+
+
+ Created At +
{new Date(info.created_at).toLocaleString()}
+
+
+ Models +
+ {info.models.map((model, index) => ( + + {model} + + ))} +
+
+
+ Rate Limits +
TPM: {info.tpm_limit || "Unlimited"}
+
RPM: {info.rpm_limit || "Unlimited"}
+
+
+ Team Budget +
+ Max Budget:{" "} + {info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"} +
+
+ Soft Budget:{" "} + {info.soft_budget !== null && info.soft_budget !== undefined + ? `$${formatNumberWithCommas(info.soft_budget, 4)}` + : "No Limit"} +
+
Budget Reset: {info.budget_duration || "Never"}
+ {info.metadata?.soft_budget_alerting_emails && + Array.isArray(info.metadata.soft_budget_alerting_emails) && + info.metadata.soft_budget_alerting_emails.length > 0 && ( +
+ Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")} +
+ )} +
+
+ + Team Member Settings{" "} + + + + +
Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}
+
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
+
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
+
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
+
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
+
+
+ Organization ID +
{info.organization_id}
+
+
+ Status + {info.blocked ? "Blocked" : "Active"} +
+ +
+ Disable Global Guardrails +
+ {info.metadata?.disable_global_guardrails === true ? ( + Enabled - Global guardrails bypassed + ) : ( + Disabled - Global guardrails active + )} +
+
+ + + + + + {info.metadata?.secret_manager_settings && ( +
+ Secret Manager Settings +
+                          {JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
+                        
+
+ )} +
+ )} +
+ ), + }, + ].filter(tab => visibleTabs.includes(tab.key))} + /> + + setIsEditMemberModalVisible(false)} + onSubmit={handleMemberUpdate} + initialData={selectedEditMember} + mode="edit" + config={{ + title: "Edit Member", + showEmail: true, + showUserId: true, + roleOptions: [ + { label: "Admin", value: "admin" }, + { label: "User", value: "user" }, + ], + additionalFields: [ + { + name: "max_budget_in_team", + label: ( + + Team Member Budget (USD){" "} + + + + + ), + type: "numerical" as const, + step: 0.01, + min: 0, + placeholder: "Budget limit for this member within this team", + }, + { + name: "tpm_limit", + label: ( + + Team Member TPM Limit{" "} + + + + + ), + type: "numerical" as const, + step: 1, + min: 0, + placeholder: "Tokens per minute limit for this member in this team", + }, + { + name: "rpm_limit", + label: ( + + Team Member RPM Limit{" "} + + + + + ), + type: "numerical" as const, + step: 1, + min: 0, + placeholder: "Requests per minute limit for this member in this team", + }, + ], + }} + /> + + setIsAddMemberModalVisible(false)} + onSubmit={handleMemberCreate} + accessToken={accessToken} + /> + + {/* Delete Member Confirmation Modal */} + +
+ ); +}; + +export default TeamInfoView; diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx new file mode 100644 index 00000000000..d494b874e4d --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -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 => ({ + 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( + , + ); + + expect(screen.getByRole("table")).toBeInTheDocument(); + }); + + it("should render team members table with headers", () => { + renderWithProviders( + , + ); + + 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( + , + ); + + // 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( + , + ); + + expect(screen.getByText("Add Member")).toBeInTheDocument(); + }); + + it("should display dash when user email is null", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should display Default Proxy Admin tag for default_user_id", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + }); + + it("should display spend and rate limits for member with membership", () => { + renderWithProviders( + , + ); + + 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( + , + ); + + expect(screen.getByText("No Limit")).toBeInTheDocument(); + }); + + it("should display No Limits for rate limits when member has no limits", () => { + renderWithProviders( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getAllByTestId("delete-member")).toHaveLength(2); + expect(screen.getAllByTestId("edit-member")).toHaveLength(2); + }); + + it("should hide action buttons when canEditTeam is false", () => { + renderWithProviders( + , + ); + + expect(screen.queryByTestId("edit-member")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete-member")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx new file mode 100644 index 00000000000..09a6b0f468d --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -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 = [ + { + title: "User Email", + dataIndex: "user_email", + key: "user_email", + render: (email: string | null) => ( + {email || "-"} + ), + }, + { + title: "User ID", + dataIndex: "user_id", + key: "user_id", + render: (userId: string | null) => + userId === "default_user_id" ? ( + Default Proxy Admin + ) : ( + {userId} + ), + }, + { + title: ( + + Team Role + + + + + ), + dataIndex: "role", + key: "role", + render: (role: string) => ( + + {role?.toLowerCase() === "admin" ? ( + + ) : ( + + )} + {role} + + ), + }, + { + title: ( + + Team Member Spend (USD) + + + + + ), + key: "spend", + render: (_: unknown, record: Member) => ( + + ${formatNumberWithCommas(getUserSpend(record.user_id), 4)} + + ), + }, + { + title: "Team Member Budget (USD)", + key: "budget", + render: (_: unknown, record: Member) => { + const budget = getUserBudget(record.user_id); + return ( + + {budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"} + + ); + }, + }, + { + title: ( + + Team Member Rate Limits + + + + + ), + key: "rate_limits", + render: (_: unknown, record: Member) => ( + {getUserRateLimits(record.user_id)} + ), + }, + { + title: "Actions", + key: "actions", + fixed: "right", + width: 120, + render: (_: unknown, record: Member) => + canEditTeam ? ( +
+ { + 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)) && ( + handleMemberDelete(record)} + /> + )} +
+ ) : null, + }, + ]; + + return ( +
+ record.user_id || String(index)} + pagination={false} + size="small" + scroll={{ x: "max-content" }} + /> + + + ); +}; diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts new file mode 100644 index 00000000000..5a09b4fa36c --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts @@ -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); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts new file mode 100644 index 00000000000..d77230ea09b --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts @@ -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 = { + [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); +} diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx deleted file mode 100644 index 014f8fb9010..00000000000 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ /dev/null @@ -1,1296 +0,0 @@ -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import UserSearchModal from "@/components/common_components/user_search_modal"; -import { - getGuardrailsList, - getPoliciesList, - getPolicyInfoWithGuardrails, - Member, - Organization, - organizationInfoCall, - teamInfoCall, - teamMemberAddCall, - teamMemberDeleteCall, - teamMemberUpdateCall, - teamUpdateCall, -} from "@/components/networking"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; -import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { - Badge, - Card, - Grid, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, - Title, - Button as TremorButton, -} from "@tremor/react"; -import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import React, { useEffect, useMemo, useState } from "react"; -import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; -import AgentSelector from "../agent_management/AgentSelector"; -import DeleteResourceModal from "../common_components/DeleteResourceModal"; -import DurationSelect from "../common_components/DurationSelect"; -import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; -import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; -import LoggingSettingsView from "../logging_settings_view"; -import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; -import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; -import { ModelSelect } from "../ModelSelect/ModelSelect"; -import NotificationsManager from "../molecules/notifications_manager"; -import { fetchMCPAccessGroups } from "../networking"; -import ObjectPermissionsView from "../object_permissions_view"; -import NumericalInput from "../shared/numerical_input"; -import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; -import EditLoggingSettings from "./EditLoggingSettings"; -import MemberModal from "./EditMembership"; -import MemberPermissions from "./member_permissions"; -import TeamMembersComponent from "./team_member_view"; - -export interface TeamMembership { - user_id: string; - team_id: string; - budget_id: string; - spend: number; - litellm_budget_table: { - budget_id: string; - soft_budget: number | null; - max_budget: number | null; - max_parallel_requests: number | null; - tpm_limit: number | null; - rpm_limit: number | null; - model_max_budget: Record | null; - budget_duration: string | null; - }; -} - -export interface TeamData { - team_id: string; - team_info: { - team_alias: string; - team_id: string; - organization_id: string | null; - admins: string[]; - members: string[]; - members_with_roles: Member[]; - metadata: Record; - tpm_limit: number | null; - rpm_limit: number | null; - max_budget: number | null; - soft_budget?: number | null; - budget_duration: string | null; - models: string[]; - blocked: boolean; - spend: number; - max_parallel_requests: number | null; - budget_reset_at: string | null; - model_id: string | null; - litellm_model_table: { - model_aliases: Record; - } | null; - created_at: string; - guardrails?: string[]; - policies?: string[]; - object_permission?: { - object_permission_id: string; - mcp_servers: string[]; - mcp_access_groups?: string[]; - mcp_tool_permissions?: Record; - vector_stores: string[]; - agents?: string[]; - agent_access_groups?: string[]; - }; - team_member_budget_table: { - max_budget: number; - budget_duration: string; - tpm_limit: number | null; - rpm_limit: number | null; - } | null; - }; - keys: any[]; - team_memberships: TeamMembership[]; -} - -export interface TeamInfoProps { - teamId: string; - onUpdate: (data: any) => void; - onClose: () => void; - accessToken: string | null; - is_team_admin: boolean; - is_proxy_admin: boolean; - userModels: string[]; - editTeam: boolean; - premiumUser?: boolean; -} - -const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { - let tempModelsToPick = []; - - if (organization) { - // Check if organization has "all-proxy-models" in its models array - if (organization.models.includes("all-proxy-models")) { - // Treat as all-proxy-models (use userModels) - tempModelsToPick = userModels; - } else if (organization.models.length > 0) { - // Organization has specific models - tempModelsToPick = organization.models; - } else { - // Empty array [] is treated as all-proxy-models - tempModelsToPick = userModels; - } - } else { - // No organization, show all available models - tempModelsToPick = userModels; - } - - return unfurlWildcardModelsInList(tempModelsToPick, userModels); -}; - -const TeamInfoView: React.FC = ({ - teamId, - onClose, - accessToken, - is_team_admin, - is_proxy_admin, - userModels, - editTeam, - premiumUser = false, - onUpdate, -}) => { - const [teamData, setTeamData] = useState(null); - const [loading, setLoading] = useState(true); - const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); - const [form] = Form.useForm(); - const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); - const [selectedEditMember, setSelectedEditMember] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); - const [copiedStates, setCopiedStates] = useState>({}); - const [guardrailsList, setGuardrailsList] = useState([]); - const [policiesList, setPoliciesList] = useState([]); - const [policyGuardrails, setPolicyGuardrails] = useState>({}); - const [loadingPolicies, setLoadingPolicies] = useState(false); - const [memberToDelete, setMemberToDelete] = useState(null); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [isTeamSaving, setIsTeamSaving] = useState(false); - const [organization, setOrganization] = useState(null); - const { userRole } = useAuthorized(); - - const canEditTeam = is_team_admin || is_proxy_admin; - - const fetchTeamInfo = async () => { - try { - setLoading(true); - if (!accessToken) return; - const response = await teamInfoCall(accessToken, teamId); - setTeamData(response); - } catch (error) { - NotificationsManager.fromBackend("Failed to load team information"); - console.error("Error fetching team info:", error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchTeamInfo(); - }, [teamId, accessToken]); - - // Fetch organization data when team has organization_id - useEffect(() => { - const fetchOrganization = async () => { - if (!accessToken || !teamData?.team_info?.organization_id) { - setOrganization(null); - return; - } - - try { - const orgData = await organizationInfoCall(accessToken, teamData.team_info.organization_id); - setOrganization(orgData); - } catch (error) { - console.error("Error fetching organization info:", error); - setOrganization(null); - } - }; - - fetchOrganization(); - }, [accessToken, teamData?.team_info?.organization_id]); - - // Compute modelsToPick based on organization and userModels - const modelsToPick = useMemo(() => { - return getOrganizationModels(organization, userModels); - }, [organization, userModels]); - - const fetchMcpAccessGroups = async () => { - if (!accessToken) return; - if (mcpAccessGroupsLoaded) return; - try { - const groups = await fetchMCPAccessGroups(accessToken); - setMcpAccessGroups(groups); - setMcpAccessGroupsLoaded(true); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - } - }; - - useEffect(() => { - const fetchGuardrails = async () => { - try { - if (!accessToken) return; - const response = await getGuardrailsList(accessToken); - const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - setGuardrailsList(guardrailNames); - } catch (error) { - console.error("Failed to fetch guardrails:", error); - } - }; - - const fetchPolicies = async () => { - try { - if (!accessToken) return; - const response = await getPoliciesList(accessToken); - const policyNames = response.policies.map((p: { policy_name: string }) => p.policy_name); - setPoliciesList(policyNames); - } catch (error) { - console.error("Failed to fetch policies:", error); - } - }; - - fetchGuardrails(); - fetchPolicies(); - }, [accessToken]); - - // Fetch resolved guardrails for all policies - useEffect(() => { - const fetchPolicyGuardrails = async () => { - if (!accessToken || !teamData?.team_info?.policies || teamData.team_info.policies.length === 0) { - return; - } - - setLoadingPolicies(true); - const guardrailsMap: Record = {}; - - try { - await Promise.all( - teamData.team_info.policies.map(async (policyName: string) => { - try { - const policyInfo = await getPolicyInfoWithGuardrails(accessToken, policyName); - guardrailsMap[policyName] = policyInfo.resolved_guardrails || []; - } catch (error) { - console.error(`Failed to fetch guardrails for policy ${policyName}:`, error); - guardrailsMap[policyName] = []; - } - }) - ); - setPolicyGuardrails(guardrailsMap); - } catch (error) { - console.error("Failed to fetch policy guardrails:", error); - } finally { - setLoadingPolicies(false); - } - }; - - fetchPolicyGuardrails(); - }, [accessToken, teamData?.team_info?.policies]); - - const handleMemberCreate = async (values: any) => { - try { - if (accessToken == null) return; - - const member: Member = { - user_email: values.user_email, - user_id: values.user_id, - role: values.role, - }; - - await teamMemberAddCall(accessToken, teamId, member); - - NotificationsManager.success("Team member added successfully"); - setIsAddMemberModalVisible(false); - form.resetFields(); - - // Fetch updated team info - const updatedTeamData = await teamInfoCall(accessToken, teamId); - setTeamData(updatedTeamData); - - // Notify parent component of the update - onUpdate(updatedTeamData); - } catch (error: any) { - let errMsg = "Failed to add team member"; - - if (error?.raw?.detail?.error?.includes("Assigning team admins is a premium feature")) { - errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; - } else if (error?.message) { - errMsg = error.message; - } - - NotificationsManager.fromBackend(errMsg); - console.error("Error adding team member:", error); - } - }; - - const handleMemberUpdate = async (values: any) => { - try { - if (accessToken == null) { - return; - } - - const member: Member = { - user_email: values.user_email, - user_id: values.user_id, - role: values.role, - max_budget_in_team: values.max_budget_in_team, - tpm_limit: values.tpm_limit, - rpm_limit: values.rpm_limit, - }; - console.log("Updating member with values:", member); - message.destroy(); // Remove all existing toasts - - await teamMemberUpdateCall(accessToken, teamId, member); - - NotificationsManager.success("Team member updated successfully"); - setIsEditMemberModalVisible(false); - - // Fetch updated team info - const updatedTeamData = await teamInfoCall(accessToken, teamId); - setTeamData(updatedTeamData); - - // Notify parent component of the update - onUpdate(updatedTeamData); - } catch (error: any) { - let errMsg = "Failed to update team member"; - if (error?.raw?.detail?.includes("Assigning team admins is a premium feature")) { - errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; - } else if (error?.message) { - errMsg = error.message; - } - setIsEditMemberModalVisible(false); - - message.destroy(); // Remove all existing toasts - - NotificationsManager.fromBackend(errMsg); - console.error("Error updating team member:", error); - } - }; - - const handleMemberDelete = (member: Member) => { - setMemberToDelete(member); - setIsDeleteModalOpen(true); - }; - - const handleDeleteConfirm = async () => { - if (!memberToDelete || !accessToken) return; - - setIsDeleting(true); - try { - await teamMemberDeleteCall(accessToken, teamId, memberToDelete); - - NotificationsManager.success("Team member removed successfully"); - - // Fetch updated team info - const updatedTeamData = await teamInfoCall(accessToken, teamId); - setTeamData(updatedTeamData); - - // Notify parent component of the update - onUpdate(updatedTeamData); - } catch (error) { - NotificationsManager.fromBackend("Failed to remove team member"); - console.error("Error removing team member:", error); - } finally { - setIsDeleting(false); - setIsDeleteModalOpen(false); - setMemberToDelete(null); - } - }; - - const handleDeleteCancel = () => { - setIsDeleteModalOpen(false); - setMemberToDelete(null); - }; - - const handleTeamUpdate = async (values: any) => { - try { - if (!accessToken) return; - setIsTeamSaving(true); - - let parsedMetadata = {}; - try { - const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {}; - // Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately - const { soft_budget_alerting_emails, ...rest } = rawMetadata; - parsedMetadata = rest; - } catch (e) { - NotificationsManager.fromBackend("Invalid JSON in metadata field"); - return; - } - - let secretManagerSettings: Record | undefined; - if (typeof values.secret_manager_settings === "string") { - const trimmedSecretConfig = values.secret_manager_settings.trim(); - if (trimmedSecretConfig.length > 0) { - try { - secretManagerSettings = JSON.parse(values.secret_manager_settings); - } catch (e) { - NotificationsManager.fromBackend("Invalid JSON in secret manager settings"); - return; - } - } - } - - const sanitizeNumeric = (v: any) => { - if (v === null || v === undefined) return null; - if (typeof v === "string" && v.trim() === "") return null; - if (typeof v === "number" && Number.isNaN(v)) return null; - return v; - }; - - const updateData: any = { - team_id: teamId, - team_alias: values.team_alias, - models: values.models, - tpm_limit: sanitizeNumeric(values.tpm_limit), - rpm_limit: sanitizeNumeric(values.rpm_limit), - max_budget: values.max_budget, - soft_budget: sanitizeNumeric(values.soft_budget), - budget_duration: values.budget_duration, - metadata: { - ...parsedMetadata, - ...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}), - ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), - disable_global_guardrails: values.disable_global_guardrails || false, - soft_budget_alerting_emails: - typeof values.soft_budget_alerting_emails === "string" - ? values.soft_budget_alerting_emails - .split(",") - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - : values.soft_budget_alerting_emails || [], - ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), - }, - ...(values.policies?.length > 0 ? { policies: values.policies } : {}), - organization_id: values.organization_id, - }; - - updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); - updateData.team_member_budget_duration = values.team_member_budget_duration; - - if (values.team_member_budget !== undefined) { - updateData.team_member_budget = Number(values.team_member_budget); - } - - if (values.team_member_key_duration !== undefined) { - updateData.team_member_key_duration = values.team_member_key_duration; - } - - if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) { - updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit); - updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit); - } - - // Handle object_permission updates - const { servers, accessGroups } = values.mcp_servers_and_groups || { - servers: [], - accessGroups: [], - }; - const serverIds = new Set(servers || []); - const mcpToolPermissions = Object.fromEntries( - Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)), - ); - - updateData.object_permission = {}; - if (servers) { - updateData.object_permission.mcp_servers = servers; - } - if (accessGroups) { - updateData.object_permission.mcp_access_groups = accessGroups; - } - if (mcpToolPermissions) { - updateData.object_permission.mcp_tool_permissions = mcpToolPermissions; - } - delete values.mcp_servers_and_groups; - delete values.mcp_tool_permissions; - - // Handle agent permissions - const { agents, accessGroups: agentAccessGroups } = values.agents_and_groups || { - agents: [], - accessGroups: [], - }; - if (agents && agents.length > 0) { - updateData.object_permission.agents = agents; - } - if (agentAccessGroups && agentAccessGroups.length > 0) { - updateData.object_permission.agent_access_groups = agentAccessGroups; - } - delete values.agents_and_groups; - - // Handle vector stores permissions - if (values.vector_stores && values.vector_stores.length > 0) { - updateData.object_permission.vector_stores = values.vector_stores; - } - - const response = await teamUpdateCall(accessToken, updateData); - - NotificationsManager.success("Team settings updated successfully"); - setIsEditing(false); - fetchTeamInfo(); - } catch (error) { - console.error("Error updating team:", error); - } finally { - setIsTeamSaving(false); - } - }; - - if (loading) { - return
Loading...
; - } - - if (!teamData?.team_info) { - return
Team not found
; - } - - const { team_info: info } = teamData; - - const copyToClipboard = async (text: string, key: string) => { - const success = await utilCopyToClipboard(text); - if (success) { - setCopiedStates((prev) => ({ ...prev, [key]: true })); - setTimeout(() => { - setCopiedStates((prev) => ({ ...prev, [key]: false })); - }, 2000); - } - }; - - return ( -
-
-
- - Back to Teams - - {info.team_alias} -
- {info.team_id} -
-
-
- - - - {[ - Overview, - ...(canEditTeam - ? [ - Members, - Member Permissions, - Settings, - ] - : []), - ]} - - - - {/* Overview Panel */} - - - - Budget Status -
- ${formatNumberWithCommas(info.spend, 4)} - - of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} - - {info.budget_duration && Reset: {info.budget_duration}} -
- {info.team_member_budget_table && ( - - Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} - - )} -
-
- - - Rate Limits -
- TPM: {info.tpm_limit || "Unlimited"} - RPM: {info.rpm_limit || "Unlimited"} - {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} -
-
- - - Models -
- {info.models.length === 0 ? ( - All proxy models - ) : ( - info.models.map((model, index) => ( - - {model} - - )) - )} -
-
- - - Virtual Keys -
- User Keys: {teamData.keys.filter((key) => key.user_id).length} - Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length} - Total: {teamData.keys.length} -
-
- - - - - Guardrails - {info.guardrails && info.guardrails.length > 0 ? ( -
- {info.guardrails.map((guardrail: string, index: number) => ( - - {guardrail} - - ))} -
- ) : ( - No guardrails configured - )} - {info.metadata?.disable_global_guardrails && ( -
- Global Guardrails Disabled -
- )} -
- - - Policies - {info.policies && info.policies.length > 0 ? ( -
- {info.policies.map((policy: string, index: number) => ( -
-
- {policy} - {loadingPolicies && Loading guardrails...} -
- {!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && ( -
- Resolved Guardrails: -
- {policyGuardrails[policy].map((guardrail: string, gIndex: number) => ( - - {guardrail} - - ))} -
-
- )} -
- ))} -
- ) : ( - No policies configured - )} -
- - -
-
- - {/* Members Panel */} - - - - - {/* Member Permissions Panel */} - {canEditTeam && ( - - - - )} - - {/* Settings Panel */} - - -
- Team Settings - {canEditTeam && !isEditing && ( - setIsEditing(true)}>Edit Settings - )} -
- - {isEditing ? ( -
rest)(info.metadata), - null, - 2, - ) - : "", - logging_settings: info.metadata?.logging || [], - secret_manager_settings: info.metadata?.secret_manager_settings - ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) - : "", - organization_id: info.organization_id, - vector_stores: info.object_permission?.vector_stores || [], - mcp_servers: info.object_permission?.mcp_servers || [], - mcp_access_groups: info.object_permission?.mcp_access_groups || [], - mcp_servers_and_groups: { - servers: info.object_permission?.mcp_servers || [], - accessGroups: info.object_permission?.mcp_access_groups || [], - }, - mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {}, - agents_and_groups: { - agents: info.object_permission?.agents || [], - accessGroups: info.object_permission?.agent_access_groups || [], - }, - }} - layout="vertical" - > - - - - - - form.setFieldValue("models", values)} - teamID={teamId} - organizationID={teamData?.team_info?.organization_id || undefined} - options={{ - includeSpecialOptions: true, - includeUserModels: !teamData?.team_info?.organization_id, - showAllProxyModelsOverride: isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id, - }} - context="team" - dataTestId="models-select" - /> - - - - - - - - - - - - - - - - - - - - form.setFieldValue("team_member_budget_duration", value)} - value={form.getFieldValue("team_member_budget_duration")} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - Guardrails{" "} - - e.stopPropagation()} - > - - - - - } - name="guardrails" - help="Select existing guardrails or enter new ones" - > - ({ value: name, label: name }))} - /> - - - - form.setFieldValue("vector_stores", values)} - value={form.getFieldValue("vector_stores")} - accessToken={accessToken || ""} - placeholder="Select vector stores" - /> - - - - form.setFieldValue("allowed_passthrough_routes", values)} - value={form.getFieldValue("allowed_passthrough_routes")} - accessToken={accessToken || ""} - placeholder="Select pass through routes" - /> - - - - form.setFieldValue("mcp_servers_and_groups", val)} - value={form.getFieldValue("mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers or access groups (optional)" - /> - - - {/* Hidden field to register mcp_tool_permissions with the form */} - - - - prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups || - prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions - } - > - {() => ( -
- form.setFieldsValue({ mcp_tool_permissions: toolPerms })} - /> -
- )} -
- - - form.setFieldValue("agents_and_groups", val)} - value={form.getFieldValue("agents_and_groups")} - accessToken={accessToken || ""} - placeholder="Select agents or access groups (optional)" - /> - - - - - - - - form.setFieldValue("logging_settings", values)} - /> - - - { - if (!value) { - return Promise.resolve(); - } - try { - JSON.parse(value); - return Promise.resolve(); - } catch (error) { - return Promise.reject(new Error("Please enter valid JSON")); - } - }, - }, - ]} - > - - - - - - - -
-
- setIsEditing(false)} disabled={isTeamSaving}> - Cancel - - - Save Changes - -
-
- - ) : ( -
-
- Team Name -
{info.team_alias}
-
-
- Team ID -
{info.team_id}
-
-
- Created At -
{new Date(info.created_at).toLocaleString()}
-
-
- Models -
- {info.models.map((model, index) => ( - - {model} - - ))} -
-
-
- Rate Limits -
TPM: {info.tpm_limit || "Unlimited"}
-
RPM: {info.rpm_limit || "Unlimited"}
-
-
- Team Budget -
- Max Budget:{" "} - {info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"} -
-
- Soft Budget:{" "} - {info.soft_budget !== null && info.soft_budget !== undefined - ? `$${formatNumberWithCommas(info.soft_budget, 4)}` - : "No Limit"} -
-
Budget Reset: {info.budget_duration || "Never"}
- {info.metadata?.soft_budget_alerting_emails && - Array.isArray(info.metadata.soft_budget_alerting_emails) && - info.metadata.soft_budget_alerting_emails.length > 0 && ( -
- Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")} -
- )} -
-
- - Team Member Settings{" "} - - - - -
Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}
-
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
-
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
-
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
-
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
-
-
- Organization ID -
{info.organization_id}
-
-
- Status - {info.blocked ? "Blocked" : "Active"} -
- -
- Disable Global Guardrails -
- {info.metadata?.disable_global_guardrails === true ? ( - Enabled - Global guardrails bypassed - ) : ( - Disabled - Global guardrails active - )} -
-
- - - - - - {info.metadata?.secret_manager_settings && ( -
- Secret Manager Settings -
-                        {JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
-                      
-
- )} -
- )} -
-
-
-
- - setIsEditMemberModalVisible(false)} - onSubmit={handleMemberUpdate} - initialData={selectedEditMember} - mode="edit" - config={{ - title: "Edit Member", - showEmail: true, - showUserId: true, - roleOptions: [ - { label: "Admin", value: "admin" }, - { label: "User", value: "user" }, - ], - additionalFields: [ - { - name: "max_budget_in_team", - label: ( - - Team Member Budget (USD){" "} - - - - - ), - type: "numerical" as const, - step: 0.01, - min: 0, - placeholder: "Budget limit for this member within this team", - }, - { - name: "tpm_limit", - label: ( - - Team Member TPM Limit{" "} - - - - - ), - type: "numerical" as const, - step: 1, - min: 0, - placeholder: "Tokens per minute limit for this member in this team", - }, - { - name: "rpm_limit", - label: ( - - Team Member RPM Limit{" "} - - - - - ), - type: "numerical" as const, - step: 1, - min: 0, - placeholder: "Requests per minute limit for this member in this team", - }, - ], - }} - /> - - setIsAddMemberModalVisible(false)} - onSubmit={handleMemberCreate} - accessToken={accessToken} - /> - - {/* Delete Member Confirmation Modal */} - -
- ); -}; - -export default TeamInfoView; diff --git a/ui/litellm-dashboard/src/components/team/team_member_view.test.tsx b/ui/litellm-dashboard/src/components/team/team_member_view.test.tsx deleted file mode 100644 index 30a06179c2f..00000000000 --- a/ui/litellm-dashboard/src/components/team/team_member_view.test.tsx +++ /dev/null @@ -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( - , - ); - - 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( - , - ); - - // 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( - , - ); - - 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( - , - ); - - // 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); - }); -}); diff --git a/ui/litellm-dashboard/src/components/team/team_member_view.tsx b/ui/litellm-dashboard/src/components/team/team_member_view.tsx deleted file mode 100644 index 10b3cbd83e6..00000000000 --- a/ui/litellm-dashboard/src/components/team/team_member_view.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
- -
-
- - - User ID - User Email - Role - - Team Member Spend (USD){" "} - - - - - Team Member Budget (USD) - - Team Member Rate Limits{" "} - - - - - - Actions - - - - - - {teamData.team_info.members_with_roles.map((member: Member, index: number) => ( - - - {member.user_id} - - - {member.user_email ? member.user_email : "No Email"} - - - {member.role} - - - ${formatNumberWithCommas(getUserSpend(member.user_id), 4)} - - - - {getUserBudget(member.user_id) - ? `$${formatNumberWithCommas(Number(getUserBudget(member.user_id)), 4)}` - : "No Limit"} - - - - {getUserRateLimits(member.user_id)} - - - {canEditTeam && ( -
- { - // 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)) && ( - handleMemberDelete(member)} - /> - )} -
- )} -
-
- ))} -
-
-
- - setIsAddMemberModalVisible(true)}>Add Member -
- ); -}; - -export default TeamMembersComponent;