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:
yuneng-jiang
2026-01-15 15:50:35 -08:00
committed by GitHub
8 changed files with 288 additions and 79 deletions
@@ -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");
+17 -66
View File
@@ -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);
});
});
+5 -5
View File
@@ -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");
};