mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): drop the unreachable user edit modal (#37327)
EditUserModal was rendered by the users dashboard but nothing could ever open it. Its two pieces of state, editModalVisible and selectedUser, were only ever set to false and null, so the modal short-circuited to null on every render. The edit path users actually reach goes through the row actions menu, which routes to the user detail view and its edit form, so removing this leaves no capability behind. The submit handler that fed the dead modal goes with it, along with the imports it was the last consumer of.
This commit is contained in:
parent
aa90828811
commit
6d32d4081d
4 changed files with 0 additions and 469 deletions
|
|
@ -1471,14 +1471,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/edit_user.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/index.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import EditUserModal from "./edit_user";
|
||||
|
||||
const POSSIBLE_UI_ROLES = {
|
||||
proxy_admin: { ui_label: "Admin", description: "Can create keys, teams, users" },
|
||||
internal_user: { ui_label: "Internal User", description: "Can create keys for themselves" },
|
||||
};
|
||||
|
||||
const USER = {
|
||||
user_id: "user-123",
|
||||
user_email: "seed@example.com",
|
||||
user_role: "internal_user",
|
||||
spend: 3.5,
|
||||
max_budget: 10,
|
||||
budget_duration: "24h",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
teams: ["team-a"],
|
||||
models: ["gpt-4"],
|
||||
key_count: 7,
|
||||
};
|
||||
|
||||
const renderModal = (overrides: Partial<React.ComponentProps<typeof EditUserModal>> = {}) => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
renderWithProviders(
|
||||
<EditUserModal
|
||||
visible
|
||||
possibleUIRoles={POSSIBLE_UI_ROLES}
|
||||
onCancel={onCancel}
|
||||
user={USER}
|
||||
onSubmit={onSubmit}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onSubmit, onCancel };
|
||||
};
|
||||
|
||||
const save = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const buttons = screen.getAllByRole("button", { name: "Save" });
|
||||
await user.click(buttons[0]);
|
||||
};
|
||||
|
||||
describe("EditUserModal", () => {
|
||||
it("renders nothing when there is no user", () => {
|
||||
renderWithProviders(
|
||||
<EditUserModal visible possibleUIRoles={POSSIBLE_UI_ROLES} onCancel={vi.fn()} user={null} onSubmit={vi.fn()} />,
|
||||
);
|
||||
expect(screen.queryByText(/Edit User/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("titles the modal with the user id", async () => {
|
||||
renderModal();
|
||||
expect(await screen.findByText("Edit User user-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits exactly the six bound fields, seeded from the user, and drops every other user key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit, onCancel } = renderModal();
|
||||
await screen.findByText("Edit User user-123");
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(Object.keys(payload).sort()).toEqual([
|
||||
"budget_duration",
|
||||
"max_budget",
|
||||
"spend",
|
||||
"user_email",
|
||||
"user_id",
|
||||
"user_role",
|
||||
]);
|
||||
const seededPayload = {
|
||||
user_id: "user-123",
|
||||
user_email: "seed@example.com",
|
||||
user_role: "internal_user",
|
||||
spend: 3.5,
|
||||
max_budget: 10,
|
||||
budget_duration: "24h",
|
||||
};
|
||||
expect(payload).toEqual(seededPayload);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("submits the edited email as a string", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
const email = await screen.findByLabelText("User Email");
|
||||
await user.clear(email);
|
||||
await user.type(email, "edited@example.com");
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0][0].user_email).toBe("edited@example.com");
|
||||
});
|
||||
|
||||
it("submits spend as a number and max_budget as a string once both are retyped", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
const spend = await screen.findByLabelText("Spend (USD)");
|
||||
await user.clear(spend);
|
||||
await user.type(spend, "42.567");
|
||||
const maxBudget = screen.getByLabelText("User Budget (USD)");
|
||||
await user.clear(maxBudget);
|
||||
await user.type(maxBudget, "77.25");
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.spend).toBe(42.567);
|
||||
expect(payload.max_budget).toBe("77.25");
|
||||
});
|
||||
|
||||
it("keeps a cleared spend and a cleared max_budget distinguishable from zero", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
const spend = await screen.findByLabelText("Spend (USD)");
|
||||
await user.clear(spend);
|
||||
const maxBudget = screen.getByLabelText("User Budget (USD)");
|
||||
await user.clear(maxBudget);
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.spend).toBeNull();
|
||||
expect(payload.max_budget).toBe("");
|
||||
});
|
||||
|
||||
it("clamps a negative spend up to the minimum on blur", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderModal();
|
||||
const spend = await screen.findByLabelText("Spend (USD)");
|
||||
await user.clear(spend);
|
||||
await user.type(spend, "-5");
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0][0].spend).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks the whole submit while max_budget is below its minimum", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit, onCancel } = renderModal();
|
||||
const maxBudget = await screen.findByLabelText("User Budget (USD)");
|
||||
await user.clear(maxBudget);
|
||||
await user.type(maxBudget, "-5");
|
||||
|
||||
await save(user);
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits the selected role value, not its label", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const { onSubmit } = renderModal();
|
||||
await screen.findByText("Edit User user-123");
|
||||
await user.click(screen.getByLabelText("User Role"));
|
||||
await user.click(await screen.findByTitle("Admin"));
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0][0].user_role).toBe("proxy_admin");
|
||||
});
|
||||
|
||||
it("submits the selected budget duration code", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const { onSubmit } = renderModal();
|
||||
await screen.findByText("Edit User user-123");
|
||||
await user.click(screen.getByLabelText("Reset Budget"));
|
||||
await user.click(await screen.findByRole("option", { name: "weekly" }));
|
||||
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0][0].budget_duration).toBe("7d");
|
||||
});
|
||||
|
||||
it("does not submit when the user cancels", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit, onCancel } = renderModal();
|
||||
await screen.findByText("Edit User user-123");
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("forwards null fields from the loaded user unchanged", async () => {
|
||||
const actor = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const { onSubmit } = renderModal({ user: { ...USER, spend: null, max_budget: null, budget_duration: null } });
|
||||
|
||||
await save(actor);
|
||||
|
||||
const nulledPayload = {
|
||||
user_email: "seed@example.com",
|
||||
user_id: "user-123",
|
||||
user_role: "internal_user",
|
||||
spend: null,
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
};
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toEqual(nulledPayload);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Modal } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface EditableUser {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_role: string;
|
||||
spend: number | null;
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
}
|
||||
|
||||
interface EditUserFormValues {
|
||||
user_email: string | undefined;
|
||||
user_id: string | undefined;
|
||||
user_role: string | undefined;
|
||||
spend: number | null | undefined;
|
||||
max_budget: number | string | null | undefined;
|
||||
budget_duration: string | null | undefined;
|
||||
}
|
||||
|
||||
interface EditUserModalProps {
|
||||
visible: boolean;
|
||||
possibleUIRoles: null | Record<string, Record<string, string>>;
|
||||
onCancel: () => void;
|
||||
user: EditableUser | null;
|
||||
onSubmit: (data: EditUserFormValues) => void;
|
||||
}
|
||||
|
||||
interface EditUserFormProps extends Omit<EditUserModalProps, "user"> {
|
||||
user: EditableUser;
|
||||
}
|
||||
|
||||
const SPEND_MIN = 0;
|
||||
|
||||
const toFormValues = (user: EditableUser): EditUserFormValues => ({
|
||||
user_email: user.user_email,
|
||||
user_id: user.user_id,
|
||||
user_role: user.user_role,
|
||||
spend: user.spend,
|
||||
max_budget: user.max_budget,
|
||||
budget_duration: user.budget_duration,
|
||||
});
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const roleOption = (uiLabel: string, description: string): React.ReactNode => (
|
||||
<div className="flex">
|
||||
{uiLabel} <p className="ml-2 text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EditUserForm: React.FC<EditUserFormProps> = ({ visible, possibleUIRoles, onCancel, user, onSubmit }) => {
|
||||
const form = useForm<EditUserFormValues>({ defaultValues: toFormValues(user) });
|
||||
|
||||
const handleCancel = async () => {
|
||||
form.reset(toFormValues(user));
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (formValues: EditUserFormValues) => {
|
||||
onSubmit(formValues);
|
||||
form.reset(toFormValues(user));
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const clampSpendToMinimum = () => {
|
||||
const spend = form.getValues("spend");
|
||||
if (typeof spend === "number" && spend < SPEND_MIN) {
|
||||
form.setValue("spend", SPEND_MIN);
|
||||
}
|
||||
};
|
||||
|
||||
const roleItems: Record<string, React.ReactNode> = Object.fromEntries(
|
||||
Object.entries(possibleUIRoles ?? {}).map(([role, { ui_label, description }]) => [
|
||||
role,
|
||||
roleOption(ui_label, description),
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={visible} onCancel={handleCancel} footer={null} title={"Edit User " + user.user_id} width={1000}>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleEditSubmit)}>
|
||||
<FieldGroup className="mt-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_email"
|
||||
label={labelWithHint("User Email", "Email of the User")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="user_role" label="User Role">
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select
|
||||
items={roleItems}
|
||||
value={value ?? null}
|
||||
onValueChange={(role: string | null) => onChange(role ?? undefined)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(possibleUIRoles ?? {}).map(([role, { ui_label, description }]) => (
|
||||
<SelectItem key={role} value={role} title={ui_label}>
|
||||
{roleOption(ui_label, description)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="spend"
|
||||
label={labelWithHint("Spend (USD)", "(float) - Spend of all LLM calls completed by this user")}
|
||||
description="Across all keys (including keys with team_id)."
|
||||
>
|
||||
{({ ref, value, onChange, onBlur, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
type="number"
|
||||
min={SPEND_MIN}
|
||||
step="any"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
|
||||
onBlur={() => {
|
||||
onBlur();
|
||||
clampSpendToMinimum();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_budget"
|
||||
label={labelWithHint("User Budget (USD)", "(float) - Maximum budget of this user")}
|
||||
description="Maximum budget of this user."
|
||||
>
|
||||
{({ ref: _ref, value, ...field }) => (
|
||||
<NumericalInput {...field} min={0} step={0.01} value={value ?? ""} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="budget_duration" label="Reset Budget">
|
||||
{({ id, value, onChange }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-2.5 text-right">
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 text-right">
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const EditUserModal: React.FC<EditUserModalProps> = ({ user, ...props }) => {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EditUserForm key={user.user_id} user={user} {...props} />;
|
||||
};
|
||||
|
||||
export default EditUserModal;
|
||||
|
|
@ -7,18 +7,15 @@ import { CreateUserButton } from "@/components/CreateUserButton";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import EditUserModal from "./edit_user";
|
||||
import {
|
||||
getPossibleUserRoles,
|
||||
getProxyBaseUrl,
|
||||
invitationCreateCall,
|
||||
userListCall,
|
||||
UserListResponse,
|
||||
userUpdateUserCall,
|
||||
} from "@/components/networking";
|
||||
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
|
||||
|
||||
import { updateExistingKeys } from "@/utils/dataUtils";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
|
|
@ -77,8 +74,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const [selectedUserId, setSelectedUserId] = useQueryState("user", parseAsString.withOptions({ history: "push" }));
|
||||
const [openInEditMode, setOpenInEditMode] = useState(false);
|
||||
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<UserInfo | null>(null);
|
||||
|
|
@ -207,39 +202,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
setUserToDelete(null);
|
||||
};
|
||||
|
||||
const handleEditCancel = async () => {
|
||||
setSelectedUser(null);
|
||||
setEditModalVisible(false);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (editedUser: any) => {
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await userUpdateUserCall(accessToken, editedUser, null);
|
||||
queryClient.setQueriesData<UserListResponse>({ queryKey: ["userList"] }, (previousData) => {
|
||||
if (previousData === undefined) return previousData;
|
||||
const updatedUsers = previousData.users.map((user) => {
|
||||
if (user.user_id === response.data.user_id) {
|
||||
return updateExistingKeys(user, response.data);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
|
||||
return { ...previousData, users: updatedUsers };
|
||||
});
|
||||
|
||||
toast.success(`User ${editedUser.user_id} updated successfully`);
|
||||
} catch (error) {
|
||||
console.error("There was an error updating the user", error);
|
||||
}
|
||||
setSelectedUser(null);
|
||||
setEditModalVisible(false);
|
||||
// Close the modal
|
||||
};
|
||||
|
||||
const handleToggleSelectionMode = () => {
|
||||
setSelectionMode(!selectionMode);
|
||||
setRowSelection({});
|
||||
|
|
@ -438,14 +400,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
)}
|
||||
|
||||
{/* Existing Modals */}
|
||||
<EditUserModal
|
||||
visible={editModalVisible}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
onCancel={handleEditCancel}
|
||||
user={selectedUser}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete User?"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue