mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-18 04:28:19 +00:00
Merge pull request #19128 from BerriAI/litellm_ui_all_proxy_models
[Feature] UI - Allow Preventing Team Admins From Deleting Members From Teams
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<Card title="UI Settings">
|
||||
{isLoading ? (
|
||||
@@ -67,6 +83,22 @@ export default function UISettings() {
|
||||
{property?.description && <Typography.Text type="secondary">{property.description}</Typography.Text>}
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Space align="start" size="middle">
|
||||
<Switch
|
||||
checked={isDisabledTeamAdminDeleteTeamUser}
|
||||
disabled={isUpdating}
|
||||
loading={isUpdating}
|
||||
onChange={handleToggleTeamAdminDelete}
|
||||
aria-label={disableTeamAdminDeleteProperty?.description ?? "Disable team admin delete team user"}
|
||||
/>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>Disable team admin delete team user</Typography.Text>
|
||||
{disableTeamAdminDeleteProperty?.description && (
|
||||
<Typography.Text type="secondary">{disableTeamAdminDeleteProperty.description}</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -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(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("User ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("User Email")).toBeInTheDocument();
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Spend (USD)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Budget (USD)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Rate Limits")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render team members data", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
// user1@test.com appears twice (User ID and User Email columns)
|
||||
expect(screen.getAllByText("user1@test.com")).toHaveLength(2);
|
||||
// user2@test.com appears twice (User ID and User Email columns)
|
||||
expect(screen.getAllByText("user2@test.com")).toHaveLength(2);
|
||||
expect(screen.getByText("member")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Add Member button", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={mockTeamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add Member")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<TeamMembersComponentProps> = ({
|
||||
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<TeamMembersComponentProps> = ({
|
||||
return limits.length > 0 ? limits.join(" / ") : "No Limits";
|
||||
};
|
||||
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { userId, userRole } = useAuthorized();
|
||||
const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user);
|
||||
const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || "");
|
||||
const isProxyAdmin = isProxyAdminRole(userRole || "");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="w-full mx-auto flex-auto overflow-auto max-h-[50vh]">
|
||||
@@ -161,12 +171,14 @@ const TeamMembersComponent: React.FC<TeamMembersComponentProps> = ({
|
||||
}}
|
||||
className="cursor-pointer hover:text-blue-600"
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => handleMemberDelete(member)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
{(isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)) && (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => handleMemberDelete(member)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
@@ -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(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} keyId={"test-key-id"} onKeyDataUpdate={() => {}} teams={[]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete Key")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user