mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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
This commit is contained in:
parent
96f7eb6f78
commit
d72b3389a1
9 changed files with 842 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
344
ui/litellm-dashboard/src/components/bulk_edit_user.tsx
Normal file
344
ui/litellm-dashboard/src/components/bulk_edit_user.tsx
Normal file
|
|
@ -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<string, Record<string, string>> | null;
|
||||
accessToken: string | null;
|
||||
onSuccess: () => void;
|
||||
teams: any[] | null;
|
||||
userRole: string | null;
|
||||
userModels: string[];
|
||||
}
|
||||
|
||||
const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
selectedUsers,
|
||||
possibleUIRoles,
|
||||
accessToken,
|
||||
onSuccess,
|
||||
teams,
|
||||
userRole,
|
||||
userModels,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTeams, setSelectedTeams] = useState<string[]>([]);
|
||||
const [teamBudget, setTeamBudget] = useState<number | null>(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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
title={`Bulk Edit ${selectedUsers.length} User(s)`}
|
||||
width={800}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Title level={5}>Selected Users ({selectedUsers.length}):</Title>
|
||||
<Table
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={selectedUsers}
|
||||
pagination={false}
|
||||
scroll={{ y: 200 }}
|
||||
rowKey="user_id"
|
||||
columns={[
|
||||
{
|
||||
title: 'User ID',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: '30%',
|
||||
render: (text: string) => (
|
||||
<Text strong style={{ fontSize: '12px' }}>
|
||||
{text.length > 20 ? `${text.slice(0, 20)}...` : text}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
dataIndex: 'user_email',
|
||||
key: 'user_email',
|
||||
width: '25%',
|
||||
render: (text: string) => (
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{text || 'No email'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Current Role',
|
||||
dataIndex: 'user_role',
|
||||
key: 'user_role',
|
||||
width: '25%',
|
||||
render: (role: string) => (
|
||||
<Text style={{ fontSize: '12px' }}>
|
||||
{possibleUIRoles?.[role]?.ui_label || role}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Budget',
|
||||
dataIndex: 'max_budget',
|
||||
key: 'max_budget',
|
||||
width: '20%',
|
||||
render: (budget: number | null) => (
|
||||
<Text style={{ fontSize: '12px' }}>
|
||||
{budget !== null ? `$${budget}` : 'Unlimited'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="mb-4">
|
||||
<Text>
|
||||
<strong>Instructions:</strong> 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.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Team Management Section */}
|
||||
<Card
|
||||
title="Team Management"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
style={{ backgroundColor: '#fafafa' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
checked={addToTeams}
|
||||
onChange={(e) => setAddToTeams(e.target.checked)}
|
||||
>
|
||||
Add selected users to teams
|
||||
</Checkbox>
|
||||
|
||||
{addToTeams && (
|
||||
<>
|
||||
<div>
|
||||
<Text strong>Select Teams:</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select teams to add users to"
|
||||
value={selectedTeams}
|
||||
onChange={setSelectedTeams}
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
options={teams?.map(team => ({
|
||||
label: team.team_alias || team.team_id,
|
||||
value: team.team_id,
|
||||
})) || []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Team Budget (Optional):</Text>
|
||||
<InputNumber
|
||||
placeholder="Max budget per user in team"
|
||||
value={teamBudget}
|
||||
onChange={(value) => setTeamBudget(value)}
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Leave empty for unlimited budget within team limits
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Users will be added with "user" role by default. All users will be added to each selected team.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<UserEditView
|
||||
userData={mockUserData}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
teams={teams}
|
||||
accessToken={accessToken}
|
||||
userID="bulk_edit"
|
||||
userRole={userRole}
|
||||
userModels={userModels}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
isBulkEdit={true}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: "center", marginTop: "10px" }}>
|
||||
<Text>Updating {selectedUsers.length} user(s)...</Text>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkEditUserModal;
|
||||
|
|
@ -3674,6 +3674,64 @@ export const teamMemberAddCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const teamBulkMemberAddCall = async (
|
||||
accessToken: string,
|
||||
teamId: string,
|
||||
members: Member[],
|
||||
maxBudgetInTeam?: number
|
||||
) => {
|
||||
try {
|
||||
console.log("Bulk add team members:", { teamId, members, maxBudgetInTeam });
|
||||
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/team/bulk_member_add`
|
||||
: `/team/bulk_member_add`;
|
||||
|
||||
const requestBody: any = {
|
||||
team_id: teamId,
|
||||
members: members,
|
||||
};
|
||||
|
||||
if (maxBudgetInTeam !== undefined && maxBudgetInTeam !== null) {
|
||||
requestBody.max_budget_in_team = maxBudgetInTeam;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Read and parse JSON error body
|
||||
const errorText = await response.text();
|
||||
let parsedError: any = {};
|
||||
|
||||
try {
|
||||
parsedError = JSON.parse(errorText);
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse error body as JSON:", errorText);
|
||||
}
|
||||
|
||||
const rawMessage =
|
||||
parsedError?.detail?.error || "Failed to bulk add team members";
|
||||
const err = new Error(rawMessage);
|
||||
(err as any).raw = parsedError;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Bulk team member add API Response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to bulk add team members:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const teamMemberUpdateCall = async (
|
||||
accessToken: string,
|
||||
teamId: string,
|
||||
|
|
@ -3933,6 +3991,57 @@ export const userUpdateUserCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const userBulkUpdateUserCall = async (
|
||||
accessToken: string,
|
||||
formValues: any, // Assuming formValues is an object
|
||||
userIds: string[]
|
||||
) => {
|
||||
try {
|
||||
console.log("Form Values in userUpdateUserCall:", formValues); // Log the form values before making the API call
|
||||
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/user/bulk_update`
|
||||
: `/user/bulk_update`;
|
||||
let request_body = []
|
||||
for (const user_id of userIds) {
|
||||
request_body.push({
|
||||
user_id: user_id,
|
||||
...formValues,
|
||||
});
|
||||
}
|
||||
let request_body_json = JSON.stringify({
|
||||
users: request_body,
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: request_body_json,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
user_id: string;
|
||||
data: UserInfo;
|
||||
};
|
||||
console.log("API Response:", data);
|
||||
//message.success("User role updated");
|
||||
return data;
|
||||
// Handle success - you might want to update some state or UI based on the created key
|
||||
} catch (error) {
|
||||
console.error("Failed to create key:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const PredictedSpendLogsCall = async (
|
||||
accessToken: string,
|
||||
requestData: any
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface UserEditViewProps {
|
|||
userRole: string | null;
|
||||
userModels: string[];
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
isBulkEdit?: boolean;
|
||||
}
|
||||
|
||||
export function UserEditView({
|
||||
|
|
@ -27,6 +28,7 @@ export function UserEditView({
|
|||
userRole,
|
||||
userModels,
|
||||
possibleUIRoles,
|
||||
isBulkEdit = false,
|
||||
}: UserEditViewProps) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
|
|
@ -62,19 +64,23 @@ export function UserEditView({
|
|||
onFinish={handleSubmit}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="User ID"
|
||||
name="user_id"
|
||||
>
|
||||
<TextInput disabled />
|
||||
</Form.Item>
|
||||
{!isBulkEdit && (
|
||||
<Form.Item
|
||||
label="User ID"
|
||||
name="user_id"
|
||||
>
|
||||
<TextInput disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="user_email"
|
||||
>
|
||||
<TextInput />
|
||||
</Form.Item>
|
||||
{!isBulkEdit && (
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="user_email"
|
||||
>
|
||||
<TextInput />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import CreateUser from "./create_user_button"
|
|||
import EditUserModal from "./edit_user"
|
||||
import OnboardingModal from "./onboarding_link"
|
||||
import { InvitationLink } from "./onboarding_link"
|
||||
import BulkEditUserModal from "./bulk_edit_user"
|
||||
|
||||
import { userDeleteCall } from "./networking"
|
||||
import { userDeleteCall, modelAvailableCall } from "./networking"
|
||||
import { columns } from "./view_users/columns"
|
||||
import { UserDataTable } from "./view_users/table"
|
||||
import { UserInfo } from "./view_users/types"
|
||||
|
|
@ -80,6 +81,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false)
|
||||
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null)
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null)
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserInfo[]>([])
|
||||
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false)
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [userModels, setUserModels] = useState<string[]>([])
|
||||
|
||||
const handleDelete = (userId: string) => {
|
||||
setUserToDelete(userId)
|
||||
|
|
@ -96,6 +101,28 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
setBaseUrl(getProxyBaseUrl())
|
||||
}, [])
|
||||
|
||||
// Fetch available models for bulk edit
|
||||
useEffect(() => {
|
||||
const fetchUserModels = async () => {
|
||||
try {
|
||||
if (!userID || !userRole || !accessToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const model_available = await modelAvailableCall(accessToken, userID, userRole)
|
||||
let available_model_names = model_available["data"].map(
|
||||
(element: { id: string }) => element.id
|
||||
)
|
||||
console.log("available_model_names:", available_model_names)
|
||||
setUserModels(available_model_names)
|
||||
} catch (error) {
|
||||
console.error("Error fetching user models:", error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUserModels()
|
||||
}, [accessToken, userID, userRole])
|
||||
|
||||
const updateFilters = (update: Partial<FilterState>) => {
|
||||
setFilters((previousFilters) => {
|
||||
const newFilters = { ...previousFilters, ...update }
|
||||
|
|
@ -189,6 +216,30 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
setCurrentPage(newPage)
|
||||
}
|
||||
|
||||
const handleToggleSelectionMode = () => {
|
||||
setSelectionMode(!selectionMode)
|
||||
setSelectedUsers([])
|
||||
}
|
||||
|
||||
const handleSelectionChange = (users: UserInfo[]) => {
|
||||
setSelectedUsers(users)
|
||||
}
|
||||
|
||||
const handleBulkEdit = () => {
|
||||
if (selectedUsers.length === 0) {
|
||||
message.error("Please select users to edit")
|
||||
return
|
||||
}
|
||||
setIsBulkEditModalVisible(true)
|
||||
}
|
||||
|
||||
const handleBulkEditSuccess = () => {
|
||||
// Refresh the user list
|
||||
queryClient.invalidateQueries({ queryKey: ["userList"] })
|
||||
setSelectedUsers([])
|
||||
setSelectionMode(false)
|
||||
}
|
||||
|
||||
const userListQuery = useQuery({
|
||||
queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage }],
|
||||
queryFn: async () => {
|
||||
|
|
@ -247,6 +298,24 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex space-x-3">
|
||||
<CreateUser userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
|
||||
|
||||
<Button
|
||||
onClick={handleToggleSelectionMode}
|
||||
variant={selectionMode ? "primary" : "secondary"}
|
||||
className="flex items-center"
|
||||
>
|
||||
{selectionMode ? "Cancel Selection" : "Select Users"}
|
||||
</Button>
|
||||
|
||||
{selectionMode && (
|
||||
<Button
|
||||
onClick={handleBulkEdit}
|
||||
disabled={selectedUsers.length === 0}
|
||||
className="flex items-center"
|
||||
>
|
||||
Bulk Edit ({selectedUsers.length} selected)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -454,6 +523,9 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleResetPassword={handleResetPassword}
|
||||
enableSelection={selectionMode}
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
|
@ -522,6 +594,18 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
|||
invitationLinkData={invitationLinkData}
|
||||
modalType="resetPassword"
|
||||
/>
|
||||
|
||||
<BulkEditUserModal
|
||||
visible={isBulkEditModalVisible}
|
||||
onCancel={() => setIsBulkEditModalVisible(false)}
|
||||
selectedUsers={selectedUsers}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
accessToken={accessToken}
|
||||
onSuccess={handleBulkEditSuccess}
|
||||
teams={teams}
|
||||
userRole={userRole}
|
||||
userModels={userModels}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Grid, Icon } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, Checkbox } from "antd";
|
||||
import { UserInfo } from "./types";
|
||||
import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
interface SelectionOptions {
|
||||
selectedUsers: UserInfo[];
|
||||
onSelectUser: (user: UserInfo, isSelected: boolean) => void;
|
||||
onSelectAll: (isSelected: boolean) => void;
|
||||
isUserSelected: (user: UserInfo) => boolean;
|
||||
isAllSelected: boolean;
|
||||
isIndeterminate: boolean;
|
||||
}
|
||||
|
||||
export const columns = (
|
||||
possibleUIRoles: Record<string, Record<string, string>>,
|
||||
handleEdit: (user: UserInfo) => void,
|
||||
handleDelete: (userId: string) => void,
|
||||
handleResetPassword: (userId: string) => void,
|
||||
handleUserClick: (userId: string, openInEditMode?: boolean) => void
|
||||
): ColumnDef<UserInfo>[] => [
|
||||
handleUserClick: (userId: string, openInEditMode?: boolean) => void,
|
||||
selectionOptions?: SelectionOptions
|
||||
): ColumnDef<UserInfo>[] => {
|
||||
const baseColumns: ColumnDef<UserInfo>[] = [
|
||||
{
|
||||
header: "User ID",
|
||||
accessorKey: "user_id",
|
||||
|
|
@ -137,4 +148,34 @@ export const columns = (
|
|||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
// Add selection column if selection is enabled
|
||||
if (selectionOptions) {
|
||||
const { onSelectUser, onSelectAll, isUserSelected, isAllSelected, isIndeterminate } = selectionOptions;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
indeterminate={isIndeterminate}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={isUserSelected(row.original)}
|
||||
onChange={(e) => onSelectUser(row.original, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...baseColumns,
|
||||
];
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
};
|
||||
|
|
@ -36,6 +36,9 @@ interface UserDataTableProps {
|
|||
handleEdit: (user: UserInfo) => void;
|
||||
handleDelete: (userId: string) => void;
|
||||
handleResetPassword: (userId: string) => void;
|
||||
selectedUsers?: UserInfo[];
|
||||
onSelectionChange?: (selectedUsers: UserInfo[]) => void;
|
||||
enableSelection?: boolean;
|
||||
}
|
||||
|
||||
export function UserDataTable({
|
||||
|
|
@ -50,6 +53,9 @@ export function UserDataTable({
|
|||
handleEdit,
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
selectedUsers = [],
|
||||
onSelectionChange,
|
||||
enableSelection = false,
|
||||
}: UserDataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([
|
||||
{
|
||||
|
|
@ -70,6 +76,34 @@ export function UserDataTable({
|
|||
setOpenInEditMode(false);
|
||||
};
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectUser = (user: UserInfo, isSelected: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
if (isSelected) {
|
||||
onSelectionChange([...selectedUsers, user]);
|
||||
} else {
|
||||
onSelectionChange(selectedUsers.filter(u => u.user_id !== user.user_id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (isSelected: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
if (isSelected) {
|
||||
onSelectionChange(data);
|
||||
} else {
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const isUserSelected = (user: UserInfo) => {
|
||||
return selectedUsers.some(u => u.user_id === user.user_id);
|
||||
};
|
||||
|
||||
const isAllSelected = data.length > 0 && selectedUsers.length === data.length;
|
||||
const isIndeterminate = selectedUsers.length > 0 && selectedUsers.length < data.length;
|
||||
|
||||
// Create columns with the handleUserClick function
|
||||
const columns = React.useMemo(() => {
|
||||
if (possibleUIRoles) {
|
||||
|
|
@ -78,11 +112,19 @@ export function UserDataTable({
|
|||
handleEdit,
|
||||
handleDelete,
|
||||
handleResetPassword,
|
||||
handleUserClick
|
||||
handleUserClick,
|
||||
enableSelection ? {
|
||||
selectedUsers,
|
||||
onSelectUser: handleSelectUser,
|
||||
onSelectAll: handleSelectAll,
|
||||
isUserSelected,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
} : undefined
|
||||
);
|
||||
}
|
||||
return originalColumns;
|
||||
}, [possibleUIRoles, handleEdit, handleDelete, handleResetPassword, handleUserClick, originalColumns]);
|
||||
}, [possibleUIRoles, handleEdit, handleDelete, handleResetPassword, handleUserClick, originalColumns, enableSelection, selectedUsers, isAllSelected, isIndeterminate]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue