From b861fd3fd42f27557dec7f01fc98c321ac88b93d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 10:15:09 +0000 Subject: [PATCH] feat(ui): migrate users forms + modals to shadcn Migrates user_edit_view, CreateUserButton, BulkEditUsers, bulk_create_users_button, and DefaultUserSettings off antd Form/Select/ Modal/Input/Typography and off Tremor Card/Text/Title. All five now use shadcn Dialog + react-hook-form + shadcn Select/Input/Textarea/Badge/ Table, with a local multi-select shim (shadcn Select + badge chips). The categorical status palette used by the CSV bulk import (red/green/ blue/yellow for invalid/success/pending/warning) and the bulk-edit amber warning are preserved as raw Tailwind classes and the owning files are added to the no-raw-tailwind-colors override list and DEVIATIONS.md per blueprint rule 3. Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 6 +- ui/litellm-dashboard/docs/DEVIATIONS.md | 20 + .../src/components/BulkEditUsers.tsx | 468 ++++--- .../src/components/CreateUserButton.test.tsx | 22 +- .../src/components/CreateUserButton.tsx | 689 +++++++--- .../src/components/DefaultUserSettings.tsx | 441 ++++-- .../components/bulk_create_users_button.tsx | 1221 ++++++++++------- .../src/components/user_edit_view.tsx | 441 ++++-- 8 files changed, 2138 insertions(+), 1170 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 73e88197b19..47a25bf9748 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -146,7 +146,11 @@ "src/components/price_data_reload.tsx", "src/components/mcp_tools/mcp_connect.tsx", "src/components/mcp_tools/mcp_tools.tsx", - "src/components/mcp_tools/ToolTestPanel.tsx" + "src/components/mcp_tools/ToolTestPanel.tsx", + "src/components/bulk_create_users_button.tsx", + "src/components/BulkEditUsers.tsx", + "src/components/DefaultUserSettings.tsx", + "src/components/view_users/user_info_view.tsx" ], "rules": { "litellm-ui/no-banned-ui-imports": "off", diff --git a/ui/litellm-dashboard/docs/DEVIATIONS.md b/ui/litellm-dashboard/docs/DEVIATIONS.md index ce9d873f0e6..6ddbfddb571 100644 --- a/ui/litellm-dashboard/docs/DEVIATIONS.md +++ b/ui/litellm-dashboard/docs/DEVIATIONS.md @@ -60,6 +60,26 @@ Format per entry: policy tokens (`--policy-untrusted`, `--policy-trusted`, `--policy-blocked`) backed by CSS variables; out of scope for phase 1. +## Users section — categorical colors (red/green/blue/yellow) in bulk-create + edit status UI + +- **Type:** `blueprint deviation` +- **Blueprint rule deviated from:** §1 "semantic Tailwind tokens only". +- **What the agent did instead:** `bulk_create_users_button.tsx`, + `BulkEditUsers.tsx`, `DefaultUserSettings.tsx`, and + `view_users/user_info_view.tsx` keep raw palette classes + (`bg-red-50`/`text-red-500`, `bg-green-100`/`text-green-500`, + `bg-blue-50`/`text-blue-500`, `bg-yellow-50`/`text-yellow-700`, + `bg-blue-100` model chips, `text-amber-600` warning callout, etc.) and + are added to the `litellm-ui/no-raw-tailwind-colors` override list in + `.eslintrc.json`. +- **Why:** The CSV import flow uses red = invalid / failed, green = + success, blue = info / pending, yellow = structural warning — a + categorical, status-driven palette with four distinct hues. Only + `destructive` maps cleanly; `primary`/`muted` don't cover "success" or + "info/pending" as separate categories. A phase-2 task could introduce + semantic status tokens (`--status-success`, `--status-info`, + `--status-warning`, `--status-failure`); out of scope for phase 1. + ## Section 1 (Access Groups) — custom MultiSelect shim in lieu of antd Select mode=multiple - **Type:** `blueprint deviation` diff --git a/ui/litellm-dashboard/src/components/BulkEditUsers.tsx b/ui/litellm-dashboard/src/components/BulkEditUsers.tsx index 2ca69065bc1..c7db2842813 100644 --- a/ui/litellm-dashboard/src/components/BulkEditUsers.tsx +++ b/ui/litellm-dashboard/src/components/BulkEditUsers.tsx @@ -1,21 +1,42 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; +import { X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; import { - Modal, - Typography, - Divider, - Table, Select, - InputNumber, - Card, - Space, - Checkbox, -} from "antd"; -import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "./networking"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import MessageManager from "@/components/molecules/message_manager"; +import { + userBulkUpdateUserCall, + teamBulkMemberAddCall, + Member, +} from "./networking"; import { UserEditView } from "./user_edit_view"; import NotificationsManager from "./molecules/notifications_manager"; -import MessageManager from "@/components/molecules/message_manager"; - -const { Text, Title } = Typography; +import NumericalInput from "./shared/numerical_input"; interface BulkEditUserModalProps { open: boolean; @@ -30,6 +51,79 @@ interface BulkEditUserModalProps { allowAllUsers?: boolean; // Optional flag to enable "all users" mode } +/** + * shadcn Select + Badge chip multi-select for teams. + */ +function TeamMultiSelect({ + value, + onChange, + options, + placeholder, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; +}) { + const selected = useMemo(() => value ?? [], [value]); + const remaining = useMemo( + () => options.filter((o) => !selected.includes(o.value)), + [options, selected], + ); + + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + const BulkEditUserModal: React.FC = ({ open, onCancel, @@ -49,7 +143,6 @@ const BulkEditUserModal: React.FC = ({ const [updateAllUsers, setUpdateAllUsers] = useState(false); const handleCancel = () => { - // Reset team management state setSelectedTeams([]); setTeamBudget(null); setAddToTeams(false); @@ -75,6 +168,7 @@ const BulkEditUserModal: React.FC = ({ keys: [], teams: teams || [], }), + // eslint-disable-next-line react-hooks/exhaustive-deps [teams, open], ); @@ -89,7 +183,6 @@ const BulkEditUserModal: React.FC = ({ 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 !== "") { @@ -108,46 +201,54 @@ const BulkEditUserModal: React.FC = ({ updatePayload.budget_duration = formValues.budget_duration; } - if (formValues.metadata && Object.keys(formValues.metadata).length > 0) { + 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) { - NotificationsManager.fromBackend("Please modify at least one field or select teams to add users to"); + NotificationsManager.fromBackend( + "Please modify at least one field or select teams to add users to", + ); return; } let successMessages: string[] = []; - // Handle user property updates if (hasUserUpdates) { if (updateAllUsers) { - const result = await userBulkUpdateUserCall(accessToken, updatePayload, undefined, true); - successMessages.push(`Updated all users (${result.total_requested} total)`); + const result = await userBulkUpdateUserCall( + accessToken, + updatePayload, + undefined, + true, + ); + successMessages.push( + `Updated all users (${result.total_requested} total)`, + ); } else { 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 let members: Member[] | null = null; if (updateAllUsers) { members = null; } else { members = selectedUsers.map((user) => ({ user_id: user.user_id, - role: "user" as const, // Default role for bulk add + role: "user" as const, user_email: user.user_email || null, })); } @@ -178,17 +279,23 @@ const BulkEditUserModal: React.FC = ({ } } - // 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)`); + 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) { - MessageManager.warning(`Failed to add users to ${failedTeams.length} team(s)`); + MessageManager.warning( + `Failed to add users to ${failedTeams.length} team(s)`, + ); } } @@ -196,7 +303,6 @@ const BulkEditUserModal: React.FC = ({ NotificationsManager.success(successMessages.join(". ")); } - // Reset team management state setSelectedTeams([]); setTeamBudget(null); setAddToTeams(false); @@ -212,164 +318,176 @@ const BulkEditUserModal: React.FC = ({ } }; + const teamOptions = (teams || []).map((team: any) => ({ + label: team.team_alias || team.team_id, + value: team.team_id, + })); + return ( - (!o ? handleCancel() : undefined)} > - {allowAllUsers && ( -
- setUpdateAllUsers(e.target.checked)}> - Update ALL users in the system - - {updateAllUsers && ( -
- - ⚠️ This will apply changes to ALL users in the system, not just the selected ones. - + + + + {updateAllUsers + ? "Bulk Edit All Users" + : `Bulk Edit ${selectedUsers.length} User(s)`} + + + + {allowAllUsers && ( +
+ + {updateAllUsers && ( +
+ + ⚠️ This will apply changes to ALL users in the system, not + just the selected ones. + +
+ )} +
+ )} + + {!updateAllUsers && ( +
+
+ Selected Users ({selectedUsers.length}): +
+
+ + + + User ID + Email + Current Role + Budget + + + + {selectedUsers.map((user) => ( + + + {user.user_id.length > 20 + ? `${user.user_id.slice(0, 20)}...` + : user.user_id} + + + {user.user_email || "No email"} + + + {possibleUIRoles?.[user.user_role]?.ui_label || + user.user_role} + + + {user.max_budget !== null + ? `$${user.max_budget}` + : "Unlimited"} + + + ))} + +
- )} -
- )} +
+ )} + + - {!updateAllUsers && (
- 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 */} + +

Team Management

+
+ -
- - 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. - -
+ {addToTeams && ( + <> +
+ +
+ +
+
- {/* Team Management Section */} - - - setAddToTeams(e.target.checked)}> - Add selected users to teams - +
+ +
+ setTeamBudget(v)} + min={0} + step={0.01} + precision={2} + /> +
+ + Leave empty for unlimited budget within team limits + +
- {addToTeams && ( - <> -
- Select Teams: - { + if (v) onChange([...selected, v]); + }} + > + + + + + {remaining.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + remaining.map((opt) => ( + + {opt.label} + + )) + )} +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + export const CreateUserButton: React.FC = ({ userID, accessToken, @@ -59,16 +187,26 @@ export const CreateUserButton: React.FC = ({ }) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); - const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); const [apiuser, setApiuser] = useState(false); const [userModels, setUserModels] = useState([]); - const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); - const [invitationLinkData, setInvitationLinkData] = useState(null); + const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = + useState(false); + const [invitationLinkData, setInvitationLinkData] = + useState(null); const [baseUrl, setBaseUrl] = useState(null); const { data: organizations = [] } = useOrganizations(); + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: defaultFormValues }); + // Derive teams from the user's organizations, falling back to the teams prop + // eslint-disable-next-line @typescript-eslint/no-unused-vars const availableTeams = useMemo(() => { const orgTeams = organizations.flatMap((org) => org.teams || []); if (orgTeams.length > 0) return orgTeams; @@ -79,7 +217,11 @@ export const CreateUserButton: React.FC = ({ const fetchData = async () => { try { const userRole = "any"; - const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole); + const modelDataResponse = await modelAvailableCall( + accessToken, + userID, + userRole, + ); const availableModels = []; for (let i = 0; i < modelDataResponse.data.length; i++) { const model = modelDataResponse.data[i]; @@ -95,46 +237,47 @@ export const CreateUserButton: React.FC = ({ setBaseUrl(getProxyBaseUrl()); fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleOk = () => { - setIsModalVisible(false); - form.resetFields(); - }; - const handleCancel = () => { setIsModalVisible(false); setApiuser(false); - form.resetFields(); + reset(defaultFormValues); }; - const handleCreate = async (formValues: { - user_id: string; - models?: string[]; - user_role: string; - organization_ids?: string[]; - organizations?: string[]; - }) => { + const handleCreate = async (formValues: CreateUserFormValues) => { try { NotificationsManager.info("Making API Call"); if (!isEmbedded) { setIsModalVisible(true); } - if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") { - formValues.models = ["no-default-models"]; + + const payload: Record = { + user_email: formValues.user_email, + user_role: formValues.user_role, + team_id: formValues.team_id, + models: formValues.models, + metadata: formValues.metadata, + }; + + if ( + (!payload.models || payload.models.length === 0) && + payload.user_role !== "proxy_admin" + ) { + payload.models = ["no-default-models"]; } - if (formValues.organization_ids) { - formValues.organizations = formValues.organization_ids; - delete formValues.organization_ids; + if (formValues.organization_ids && formValues.organization_ids.length > 0) { + payload.organizations = formValues.organization_ids; } - const response = await userCreateCall(accessToken, null, formValues); + const response = await userCreateCall(accessToken, null, payload); await queryClient.invalidateQueries({ queryKey: ["userList"] }); setApiuser(true); const user_id = response.data?.user_id || response.user_id; if (onUserCreated && isEmbedded) { onUserCreated(user_id); - form.resetFields(); + reset(defaultFormValues); return; } @@ -148,13 +291,13 @@ export const CreateUserButton: React.FC = ({ // create an InvitationLink Object for this user for the SSO flow // for SSO the invite link is the proxy base url since the User just needs to login const invitationLink: InvitationLink = { - id: generateUUID(), // Generate a unique ID + id: generateUUID(), user_id: user_id, is_accepted: false, accepted_at: null, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), created_at: new Date(), - created_by: userID, // Assuming userID is the current user creating the invitation + created_by: userID, updated_at: new Date(), updated_by: userID, has_user_setup_sso: true, @@ -164,210 +307,322 @@ export const CreateUserButton: React.FC = ({ } NotificationsManager.success("API user Created"); - form.resetFields(); + reset(defaultFormValues); localStorage.removeItem("userData" + userID); } catch (error: any) { - const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user"; + const errorMessage = + error.response?.data?.detail || error?.message || "Error creating the user"; NotificationsManager.fromBackend(errorMessage); console.error("Error creating the user:", error); } }; + const orgOptions = organizations.map((org) => ({ + label: `${org.organization_alias} (${org.organization_id})`, + value: org.organization_id, + })); + + const modelOptions = useMemo(() => { + const base = [ + { label: "All Proxy Models", value: "all-proxy-models" }, + { label: "No Default Models", value: "no-default-models" }, + ]; + const extras = userModels.map((model) => ({ + label: getModelDisplayName(model), + value: model, + })); + return [...base, ...extras]; + }, [userModels]); + // Modify the return statement to handle embedded mode if (isEmbedded) { return ( -
- - New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is - configured.{" "} - - Learn how to set up email notifications - - - } - type="info" - showIcon - className="mb-4" - /> - - - - - - {possibleUIRoles && - Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( - -
- {ui_label}{" "} - - {description} - -
-
- ))} -
-
- - - + + + Email invitations + + New users receive an email invite only when an email integration + (SMTP, Resend, or SendGrid) is configured.{" "} + + Learn how to set up email notifications + + + - - - - -
- +
+ +
- + +
+ + ( + + )} + /> +
+ +
+ + ( + field.onChange(v)} + /> + )} + /> +
+ +
+ +