From d72b3389a1d6304cf4e0cc45fc04e1574f30b986 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sat, 19 Jul 2025 11:04:23 -0700 Subject: [PATCH] Bulk Edit Users on UI (#12763) * feat(bulk_edit_user.tsx): initial working ui for editing users in bulk on the ui easier to give access / assign to a default team * feat(team_endpoints-+-bulk_edit_users.tsx): add bulk adding users to teams make it easier to add existing users to a default team * fix(bulk_edit_user.tsx): fix ui linting error * fix: fix linting error --- .../common_utils/encrypt_decrypt_utils.py | 13 +- .../management_endpoints/team_endpoints.py | 151 ++++++++ .../management_endpoints/team_endpoints.py | 39 +- .../src/components/bulk_edit_user.tsx | 344 ++++++++++++++++++ .../src/components/networking.tsx | 109 ++++++ .../src/components/user_edit_view.tsx | 30 +- .../src/components/view_users.tsx | 86 ++++- .../src/components/view_users/columns.tsx | 49 ++- .../src/components/view_users/table.tsx | 46 ++- 9 files changed, 842 insertions(+), 25 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/bulk_edit_user.tsx diff --git a/litellm/proxy/common_utils/encrypt_decrypt_utils.py b/litellm/proxy/common_utils/encrypt_decrypt_utils.py index e07d9041192..c44daafaedb 100644 --- a/litellm/proxy/common_utils/encrypt_decrypt_utils.py +++ b/litellm/proxy/common_utils/encrypt_decrypt_utils.py @@ -58,7 +58,7 @@ def decrypt_value_helper( verbose_proxy_logger.debug(error_message) return None - verbose_proxy_logger.error(error_message) + verbose_proxy_logger.exception(error_message) # [Non-Blocking Exception. - this should not block decrypting other values] return None @@ -98,7 +98,12 @@ def decrypt_value(value: bytes, signing_key: str) -> str: box = nacl.secret.SecretBox(hash_bytes) # Convert the bytes object to a string - plaintext = box.decrypt(value) + try: + if len(value) == 0: + return "" - plaintext = plaintext.decode("utf-8") # type: ignore - return plaintext # type: ignore + plaintext = box.decrypt(value) + plaintext = plaintext.decode("utf-8") # type: ignore + return plaintext # type: ignore + except Exception as e: + raise e diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index a5cd2321fa5..c0f267a6bb7 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -92,8 +92,11 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.team_endpoints import ( + BulkTeamMemberAddRequest, + BulkTeamMemberAddResponse, GetTeamMemberPermissionsResponse, TeamListResponse, + TeamMemberAddResult, UpdateTeamMemberPermissionsRequest, ) @@ -1541,6 +1544,154 @@ async def team_member_update( ) +def _create_results_from_response( + members: List[Member], + response: TeamAddMemberResponse, +) -> List[TeamMemberAddResult]: + """ + Convert TeamAddMemberResponse into individual TeamMemberAddResult objects + """ + results: List[TeamMemberAddResult] = [] + + for member in members: + # Find corresponding updated user + updated_user = None + for user in response.updated_users: + if (member.user_id and user.user_id == member.user_id) or ( + member.user_email and user.user_email == member.user_email + ): + updated_user = user.model_dump() + break + + # Find corresponding updated team membership + updated_team_membership = None + for tm in response.updated_team_memberships: + if member.user_id and tm.user_id == member.user_id: + updated_team_membership = tm.model_dump() + break + + results.append( + TeamMemberAddResult( + user_id=member.user_id, + user_email=member.user_email, + success=True, + updated_user=updated_user, + updated_team_membership=updated_team_membership, + ) + ) + + return results + + +@router.post( + "/team/bulk_member_add", + tags=["team management"], + dependencies=[Depends(user_api_key_auth)], + response_model=BulkTeamMemberAddResponse, +) +@management_endpoint_wrapper +async def bulk_team_member_add( + data: BulkTeamMemberAddRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Bulk add multiple members to a team at once. + + This endpoint reuses the same logic as /team/member_add but provides a bulk-friendly response format. + + Parameters: + - team_id: str - The ID of the team to add members to + - members: List[Member] - List of members to add to the team + - max_budget_in_team: Optional[float] - Maximum budget allocated to each user within the team + + Returns: + - results: List of individual member addition results + - total_requested: Total number of members requested for addition + - successful_additions: Number of successful additions + - failed_additions: Number of failed additions + - updated_team: The updated team object + + Example request: + ```bash + curl --location 'http://0.0.0.0:4000/team/bulk_member_add' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'Content-Type: application/json' \ + --data '{ + "team_id": "team-1234", + "members": [ + { + "user_id": "user1", + "role": "user" + }, + { + "user_email": "user2@example.com", + "role": "admin" + } + ], + "max_budget_in_team": 100.0 + }' + ``` + """ + if not data.members: + raise HTTPException( + status_code=400, + detail={"error": "At least one member is required"}, + ) + + # Limit batch size to prevent overwhelming the system + MAX_BATCH_SIZE = 100 + if len(data.members) > MAX_BATCH_SIZE: + raise HTTPException( + status_code=400, + detail={"error": f"Maximum {MAX_BATCH_SIZE} members can be added at once"}, + ) + + try: + # Reuse the existing team_member_add logic directly + response = await team_member_add( + data=TeamMemberAddRequest( + team_id=data.team_id, + member=data.members, # Pass the entire list + max_budget_in_team=data.max_budget_in_team, + ), + user_api_key_dict=user_api_key_dict, + ) + + # Convert to bulk response format + results = _create_results_from_response(data.members, response) + + return BulkTeamMemberAddResponse( + team_id=data.team_id, + results=results, + total_requested=len(data.members), + successful_additions=len(results), # All succeeded if we got here + failed_additions=0, + updated_team=response.model_dump(), + ) + + except Exception as e: + # If the entire operation fails, mark all members as failed + error_message = str(e) + results = [ + TeamMemberAddResult( + user_id=member.user_id, + user_email=member.user_email, + success=False, + error=error_message, + ) + for member in data.members + ] + + return BulkTeamMemberAddResponse( + team_id=data.team_id, + results=results, + total_requested=len(data.members), + successful_additions=0, + failed_additions=len(data.members), + updated_team=None, + ) + + @router.post( "/team/delete", tags=["team management"], dependencies=[Depends(user_api_key_auth)] ) diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index ebeadd47c33..581655f0ec0 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,8 +1,13 @@ -from typing import List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel -from litellm.proxy._types import LiteLLM_TeamTable +from litellm.proxy._types import ( + LiteLLM_TeamMembership, + LiteLLM_TeamTable, + LiteLLM_UserTable, + Member, +) class GetTeamMemberPermissionsRequest(BaseModel): @@ -45,3 +50,33 @@ class TeamListResponse(BaseModel): page: int page_size: int total_pages: int + + +class BulkTeamMemberAddRequest(BaseModel): + """Request for bulk team member addition""" + + team_id: str + members: List[Member] # List of members to add + max_budget_in_team: Optional[float] = None + + +class TeamMemberAddResult(BaseModel): + """Result of a single team member add operation""" + + user_id: Optional[str] = None + user_email: Optional[str] = None + success: bool + error: Optional[str] = None + updated_user: Optional[Dict[str, Any]] = None + updated_team_membership: Optional[Dict[str, Any]] = None + + +class BulkTeamMemberAddResponse(BaseModel): + """Response for bulk team member add operations""" + + team_id: str + results: List[TeamMemberAddResult] + total_requested: int + successful_additions: int + failed_additions: int + updated_team: Optional[Dict[str, Any]] = None diff --git a/ui/litellm-dashboard/src/components/bulk_edit_user.tsx b/ui/litellm-dashboard/src/components/bulk_edit_user.tsx new file mode 100644 index 00000000000..2ed4ef196a7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/bulk_edit_user.tsx @@ -0,0 +1,344 @@ +import React, { useState } from 'react'; +import { + Button as Button2, + Modal, + Typography, + Divider, + message, + Table, + Select, + Form, + InputNumber, + Card, + Space, + Checkbox, +} from "antd"; +import { Button } from '@tremor/react'; +import { userBulkUpdateUserCall, teamBulkMemberAddCall } from "./networking"; +import { UserEditView } from "./user_edit_view"; + +const { Text, Title } = Typography; + +interface BulkEditUserModalProps { + visible: boolean; + onCancel: () => void; + selectedUsers: any[]; + possibleUIRoles: Record> | null; + accessToken: string | null; + onSuccess: () => void; + teams: any[] | null; + userRole: string | null; + userModels: string[]; +} + +const BulkEditUserModal: React.FC = ({ + visible, + onCancel, + selectedUsers, + possibleUIRoles, + accessToken, + onSuccess, + teams, + userRole, + userModels, +}) => { + const [loading, setLoading] = useState(false); + const [selectedTeams, setSelectedTeams] = useState([]); + const [teamBudget, setTeamBudget] = useState(null); + const [addToTeams, setAddToTeams] = useState(false); + + const handleCancel = () => { + // Reset team management state + setSelectedTeams([]); + setTeamBudget(null); + setAddToTeams(false); + onCancel(); + }; + + // Create a mock userData object for the UserEditView + const mockUserData = { + user_id: "bulk_edit", + user_info: { + user_email: "", + user_role: "", + teams: [], + models: [], + max_budget: null, + spend: 0, + metadata: {}, + created_at: null, + updated_at: null, + }, + keys: [], + teams: teams || [], + }; + + const handleSubmit = async (formValues: any) => { + if (!accessToken) { + message.error("Access token not found"); + return; + } + + setLoading(true); + try { + const userIds = selectedUsers.map(user => user.user_id); + + // Build the update payload - only include fields that have been changed from default/empty values + const updatePayload: any = {}; + + if (formValues.user_role && formValues.user_role !== "") { + updatePayload.user_role = formValues.user_role; + } + + if (formValues.max_budget !== null && formValues.max_budget !== undefined) { + updatePayload.max_budget = formValues.max_budget; + } + + if (formValues.models && formValues.models.length > 0) { + updatePayload.models = formValues.models; + } + + if (formValues.metadata && Object.keys(formValues.metadata).length > 0) { + updatePayload.metadata = formValues.metadata; + } + + // Check if any operations were requested + const hasUserUpdates = Object.keys(updatePayload).length > 0; + const hasTeamAdditions = addToTeams && selectedTeams.length > 0; + + if (!hasUserUpdates && !hasTeamAdditions) { + message.error("Please modify at least one field or select teams to add users to"); + return; + } + + let successMessages: string[] = []; + + // Handle user property updates + if (hasUserUpdates) { + await userBulkUpdateUserCall(accessToken, updatePayload, userIds); + successMessages.push(`Updated ${userIds.length} user(s)`); + } + + // Handle team additions + if (hasTeamAdditions) { + const teamResults: any[] = []; + + for (const teamId of selectedTeams) { + try { + // Create member objects for bulk add + const members = selectedUsers.map(user => ({ + user_id: user.user_id, + role: "user" as const, // Default role for bulk add + user_email: user.user_email || null, + })); + + const result = await teamBulkMemberAddCall( + accessToken, + teamId, + members, + teamBudget || undefined + ); + + teamResults.push({ + teamId, + success: true, + successfulAdditions: result.successful_additions, + failedAdditions: result.failed_additions, + }); + } catch (error) { + console.error(`Failed to add users to team ${teamId}:`, error); + teamResults.push({ + teamId, + success: false, + error: error, + }); + } + } + + // Generate team success message + const successfulTeams = teamResults.filter(r => r.success); + const failedTeams = teamResults.filter(r => !r.success); + + if (successfulTeams.length > 0) { + const totalAdditions = successfulTeams.reduce((sum, r) => sum + r.successfulAdditions, 0); + successMessages.push(`Added users to ${successfulTeams.length} team(s) (${totalAdditions} total additions)`); + } + + if (failedTeams.length > 0) { + message.warning(`Failed to add users to ${failedTeams.length} team(s)`); + } + } + + if (successMessages.length > 0) { + message.success(successMessages.join('. ')); + } + + // Reset team management state + setSelectedTeams([]); + setTeamBudget(null); + setAddToTeams(false); + + onSuccess(); + onCancel(); + } catch (error) { + console.error("Bulk operation failed:", error); + message.error("Failed to perform bulk operations"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ Selected Users ({selectedUsers.length}): + ( + + {text.length > 20 ? `${text.slice(0, 20)}...` : text} + + ), + }, + { + title: 'Email', + dataIndex: 'user_email', + key: 'user_email', + width: '25%', + render: (text: string) => ( + + {text || 'No email'} + + ), + }, + { + title: 'Current Role', + dataIndex: 'user_role', + key: 'user_role', + width: '25%', + render: (role: string) => ( + + {possibleUIRoles?.[role]?.ui_label || role} + + ), + }, + { + title: 'Budget', + dataIndex: 'max_budget', + key: 'max_budget', + width: '20%', + render: (budget: number | null) => ( + + {budget !== null ? `$${budget}` : 'Unlimited'} + + ), + }, + ]} + /> + + + + +
+ + Instructions: Fill in the fields below with the values you want to apply to all selected users. + You can bulk edit: role, budget, models, and metadata. You can also add users to teams. + +
+ + {/* Team Management Section */} + + + setAddToTeams(e.target.checked)} + > + Add selected users to teams + + + {addToTeams && ( + <> +
+ Select Teams: +