diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index d9a41d38b2..7db76fd31d 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -72,6 +72,11 @@ class UISettings(BaseModel): description="If true, internal users cannot add models from the UI", ) + disable_team_admin_delete_team_user: bool = Field( + default=False, + description="Prevents Team Admins from deleting users from the teams they manage. Useful for SCIM provisioning where team membership is defined externally.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -80,7 +85,7 @@ class UISettingsResponse(SettingsResponse): # Allowlist of UI settings that can be stored -ALLOWED_UI_SETTINGS_FIELDS = {"disable_model_add_for_internal_users"} +ALLOWED_UI_SETTINGS_FIELDS = {"disable_model_add_for_internal_users", "disable_team_admin_delete_team_user"} @router.get( diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index c907838348..1cc493194e 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -13,8 +13,10 @@ export default function UISettings() { const schema = data?.field_schema; const property = schema?.properties?.disable_model_add_for_internal_users; + const disableTeamAdminDeleteProperty = schema?.properties?.disable_team_admin_delete_team_user; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); + const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); const handleToggle = (checked: boolean) => { updateSettings( @@ -30,6 +32,20 @@ export default function UISettings() { ); }; + const handleToggleTeamAdminDelete = (checked: boolean) => { + updateSettings( + { disable_team_admin_delete_team_user: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + return ( {isLoading ? ( @@ -67,6 +83,22 @@ export default function UISettings() { {property?.description && {property.description}} + + + + + Disable team admin delete team user + {disableTeamAdminDeleteProperty?.description && ( + {disableTeamAdminDeleteProperty.description} + )} + + )} 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 new file mode 100644 index 0000000000..ba0f3132f6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/team_member_view.test.tsx @@ -0,0 +1,165 @@ +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"; + +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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/team_member_view.tsx b/ui/litellm-dashboard/src/components/team/team_member_view.tsx index c9f97ee148..534d4c67e6 100644 --- a/ui/litellm-dashboard/src/components/team/team_member_view.tsx +++ b/ui/litellm-dashboard/src/components/team/team_member_view.tsx @@ -17,6 +17,9 @@ import { Tooltip } from "antd"; import { TeamData } from "./team_info"; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { isUserTeamAdminForSingleTeam, isProxyAdminRole } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; interface TeamMembersComponentProps { teamData: TeamData; @@ -35,6 +38,7 @@ const TeamMembersComponent: React.FC = ({ 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"; @@ -87,6 +91,12 @@ const TeamMembersComponent: React.FC = ({ 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 (
@@ -161,12 +171,14 @@ const TeamMembersComponent: React.FC = ({ }} className="cursor-pointer hover:text-blue-600" /> - handleMemberDelete(member)} - className="cursor-pointer hover:text-red-600" - /> + {(isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)) && ( + handleMemberDelete(member)} + className="cursor-pointer hover:text-red-600" + /> + )}
)} diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 96d4b574a1..ebb664e18c 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -276,4 +276,48 @@ describe("KeyInfoView", () => { expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); }); }); + + it("should handle case when teamsData exists but no team matches key team_id", async () => { + const differentTeamId = "different-team-id"; + const mockTeam: Team = { + team_id: differentTeamId, + team_alias: "Different Team", + models: [], + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + organization_id: "org-1", + created_at: "2025-01-01T00:00:00Z", + keys: [], + members_with_roles: [ + { + user_id: "team-admin-user", + role: "admin", + }, + ], + }; + + vi.mocked(useTeams).mockReturnValue({ + teams: [mockTeam], + setTeams: vi.fn(), + }); + + vi.mocked(useAuthorized).mockReturnValue({ + ...baseUseAuthorizedMock, + userId: "team-admin-user", + userRole: "user", + }); + + // Key has a different team_id that doesn't match any team in teamsData + const keyData = { ...MOCK_KEY_DATA, team_id: "non-matching-team-id", user_id: "other-user-id" }; + render( + {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />, + ); + + await waitFor(() => { + expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); + expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 76361cca2f..e22ec226fc 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -304,7 +304,7 @@ export default function KeyInfoView({ isProxyAdminRole(userRole || "") || (teamsData && isUserTeamAdminForSingleTeam( - teamsData?.filter((team) => team.team_id === currentKeyData.team_id)[0], + teamsData?.filter((team) => team.team_id === currentKeyData.team_id)[0]?.members_with_roles, userID || "", )) || (userID === currentKeyData.user_id && userRole !== "Internal Viewer"); diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index f2d1812714..38343094b9 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -42,81 +42,32 @@ describe("roles", () => { describe("isUserTeamAdminForSingleTeam", () => { it("should return true when user is team admin", () => { - const team: Team = { - team_id: "team-1", - team_alias: "Test Team", - models: [], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-1", - created_at: "2024-01-01", - keys: [], - members_with_roles: [ - { user_id: "user-1", user_email: "user1@test.com", role: "admin" }, - { user_id: "user-2", user_email: "user2@test.com", role: "user" }, - ], - }; - expect(isUserTeamAdminForSingleTeam(team, "user-1")).toBe(true); + const members_with_roles = [ + { user_id: "user-1", user_email: "user1@test.com", role: "admin" }, + { user_id: "user-2", user_email: "user2@test.com", role: "user" }, + ]; + expect(isUserTeamAdminForSingleTeam(members_with_roles, "user-1")).toBe(true); }); it("should return false when user is not team admin", () => { - const team: Team = { - team_id: "team-1", - team_alias: "Test Team", - models: [], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-1", - created_at: "2024-01-01", - keys: [], - members_with_roles: [ - { user_id: "user-1", user_email: "user1@test.com", role: "user" }, - { user_id: "user-2", user_email: "user2@test.com", role: "user" }, - ], - }; - expect(isUserTeamAdminForSingleTeam(team, "user-1")).toBe(false); + const members_with_roles = [ + { user_id: "user-1", user_email: "user1@test.com", role: "user" }, + { user_id: "user-2", user_email: "user2@test.com", role: "user" }, + ]; + expect(isUserTeamAdminForSingleTeam(members_with_roles, "user-1")).toBe(false); }); it("should return false when user is not in team", () => { - const team: Team = { - team_id: "team-1", - team_alias: "Test Team", - models: [], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-1", - created_at: "2024-01-01", - keys: [], - members_with_roles: [{ user_id: "user-2", user_email: "user2@test.com", role: "admin" }], - }; - expect(isUserTeamAdminForSingleTeam(team, "user-1")).toBe(false); - }); - - it("should return false when team is null", () => { - expect(isUserTeamAdminForSingleTeam(null, "user-1")).toBe(false); + const members_with_roles = [{ user_id: "user-2", user_email: "user2@test.com", role: "admin" }]; + expect(isUserTeamAdminForSingleTeam(members_with_roles, "user-1")).toBe(false); }); it("should return false when members_with_roles is null", () => { - const team = { - team_id: "team-1", - team_alias: "Test Team", - models: [], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-1", - created_at: "2024-01-01", - keys: [], - members_with_roles: [], - } as Team; - expect(isUserTeamAdminForSingleTeam(team, "user-1")).toBe(false); + expect(isUserTeamAdminForSingleTeam(null, "user-1")).toBe(false); + }); + + it("should return false when members_with_roles is empty array", () => { + expect(isUserTeamAdminForSingleTeam([], "user-1")).toBe(false); }); }); diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 7667a5b207..580b4568c5 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -1,4 +1,4 @@ -import { Team } from "@/components/networking"; +import { Member, Team } from "@/components/networking"; // Define admin roles and permissions export const old_admin_roles = ["Admin", "Admin Viewer"]; @@ -22,12 +22,12 @@ export const isUserTeamAdminForAnyTeam = (teams: Team[] | null, userID: string): if (teams == null) { return false; } - return teams.some((team) => isUserTeamAdminForSingleTeam(team, userID)); + return teams.some((team) => isUserTeamAdminForSingleTeam(team.members_with_roles, userID)); }; -export const isUserTeamAdminForSingleTeam = (team: Team | null, userID: string): boolean => { - if (team == null || team.members_with_roles == null) { +export const isUserTeamAdminForSingleTeam = (teamMemberWithRoles: Member[] | null, userID: string): boolean => { + if (teamMemberWithRoles == null) { return false; } - return team.members_with_roles.some((member) => member.user_id === userID && member.role === "admin"); + return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin"); };