{/* Export Type Selection */}
diff --git a/ui/litellm-dashboard/src/components/create_user_button.tsx b/ui/litellm-dashboard/src/components/create_user_button.tsx
index 935c34321e..da155b042a 100644
--- a/ui/litellm-dashboard/src/components/create_user_button.tsx
+++ b/ui/litellm-dashboard/src/components/create_user_button.tsx
@@ -228,7 +228,7 @@ const Createuser: React.FC
= ({
= ({
,
]}
width={1000}
- destroyOnClose
+ destroyOnHidden
>
diff --git a/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx b/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx
index 88753b7df3..95ceb65d10 100644
--- a/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx
@@ -53,8 +53,8 @@ export const handleEditModelSubmit = async (
model_info:
model_info_model_id !== undefined
? {
- id: model_info_model_id,
- }
+ id: model_info_model_id,
+ }
: undefined,
};
@@ -119,7 +119,7 @@ const EditModelModal: React.FC = ({ visible, onCancel, mode
return (
= ({ visible, possibleUIRoles,
}
return (
-
+
@@ -704,24 +702,39 @@ export default function KeyInfoView({
{Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0
? currentKeyData.metadata.prompts.map((prompt, index) => (
-
- {prompt}
-
- ))
+
+ {prompt}
+
+ ))
: "No prompts specified"}
+
+
Allowed Routes
+
+ {Array.isArray(currentKeyData.allowed_routes) && currentKeyData.allowed_routes.length > 0 ? (
+ currentKeyData.allowed_routes.map((route, index) => (
+
+ {route}
+
+ ))
+ ) : (
+ All routes allowed
+ )}
+
+
+
Allowed Pass Through Routes
{Array.isArray(currentKeyData.metadata?.allowed_passthrough_routes) &&
- currentKeyData.metadata.allowed_passthrough_routes.length > 0
+ currentKeyData.metadata.allowed_passthrough_routes.length > 0
? currentKeyData.metadata.allowed_passthrough_routes.map((route, index) => (
-
- {route}
-
- ))
+
+ {route}
+
+ ))
: "No pass through routes specified"}
diff --git a/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx b/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx
index e4dc896f55..6f3ce27567 100644
--- a/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx
+++ b/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx
@@ -1368,6 +1368,7 @@ const OldModelDashboard: React.FC
= ({
all_models_on_proxy={all_models_on_proxy}
getDisplayModelName={getDisplayModelName}
setSelectedModelId={setSelectedModelId}
+ teams={teams}
/>
diff --git a/ui/litellm-dashboard/src/components/user_edit_view.test.tsx b/ui/litellm-dashboard/src/components/user_edit_view.test.tsx
new file mode 100644
index 0000000000..4bac32321a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/user_edit_view.test.tsx
@@ -0,0 +1,504 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../../tests/test-utils";
+import { UserEditView } from "./user_edit_view";
+
+vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({
+ getModelDisplayName: vi.fn((model: string) => model),
+}));
+
+vi.mock("../utils/roles", () => ({
+ all_admin_roles: ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"],
+}));
+
+vi.mock("antd", async (importOriginal) => {
+ const actual = await importOriginal();
+ const React = await import("react");
+ const SelectComponent = ({
+ value,
+ onChange,
+ mode,
+ children,
+ placeholder,
+ disabled,
+ style,
+ allowClear,
+ ...props
+ }: any) => {
+ const isMultiple = mode === "multiple";
+ const selectValue = isMultiple ? (Array.isArray(value) ? value : []) : value || "";
+ return React.createElement(
+ "select",
+ {
+ multiple: isMultiple,
+ value: selectValue,
+ onChange: (e: React.ChangeEvent) => {
+ const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
+ onChange(isMultiple ? selectedValues : selectedValues[0] || undefined);
+ },
+ disabled,
+ placeholder,
+ style,
+ "aria-label": placeholder || "Select",
+ role: "combobox",
+ ...props,
+ },
+ children,
+ );
+ };
+ SelectComponent.Option = ({ value: optionValue, children: optionChildren }: any) =>
+ React.createElement("option", { value: optionValue }, optionChildren);
+ return {
+ ...actual,
+ Select: SelectComponent,
+ Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
+ Checkbox: ({ checked, onChange, children, ...props }: any) =>
+ React.createElement(
+ "label",
+ { style: { display: "flex", alignItems: "center", gap: "8px" } },
+ React.createElement("input", {
+ type: "checkbox",
+ checked: checked,
+ onChange: (e: React.ChangeEvent) => onChange({ target: { checked: e.target.checked } }),
+ ...props,
+ }),
+ children,
+ ),
+ };
+});
+
+vi.mock("@tremor/react", async (importOriginal) => {
+ const actual = await importOriginal();
+ const React = await import("react");
+ return {
+ ...actual,
+ SelectItem: ({ value, children, title }: any) => {
+ const childText = React.Children.toArray(children)
+ .map((child: any) => (typeof child === "string" ? child : child?.props?.children || ""))
+ .join(" ");
+ return React.createElement("option", { value, title }, childText || title || value);
+ },
+ };
+});
+
+describe("UserEditView", () => {
+ const MOCK_USER_DATA = {
+ user_id: "user-123",
+ user_info: {
+ user_email: "test@example.com",
+ user_alias: "Test User",
+ user_role: "proxy_admin",
+ models: ["gpt-4", "gpt-3.5-turbo"],
+ max_budget: 100.5,
+ budget_duration: "30d",
+ metadata: {
+ key1: "value1",
+ key2: "value2",
+ },
+ },
+ };
+
+ const MOCK_POSSIBLE_UI_ROLES = {
+ proxy_admin: {
+ ui_label: "Proxy Admin",
+ description: "Full access to proxy",
+ },
+ proxy_admin_viewer: {
+ ui_label: "Proxy Admin Viewer",
+ description: "Read-only access",
+ },
+ user: {
+ ui_label: "User",
+ description: "Standard user",
+ },
+ };
+
+ const defaultProps = {
+ userData: MOCK_USER_DATA,
+ onCancel: vi.fn(),
+ onSubmit: vi.fn(),
+ teams: null,
+ accessToken: "test-token",
+ userID: "current-user-1",
+ userRole: "Admin",
+ userModels: ["gpt-4", "gpt-3.5-turbo", "claude-3"],
+ possibleUIRoles: MOCK_POSSIBLE_UI_ROLES,
+ isBulkEdit: false,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+ });
+
+ it("should display user ID field when not in bulk edit mode", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("User ID")).toBeInTheDocument();
+ });
+
+ const userIdInput = screen.getByLabelText("User ID");
+ expect(userIdInput).toBeDisabled();
+ expect(userIdInput).toHaveValue("user-123");
+ });
+
+ it("should not display user ID field when in bulk edit mode", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ expect(screen.queryByLabelText("User ID")).not.toBeInTheDocument();
+ });
+
+ it("should display email field when not in bulk edit mode", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ });
+
+ const emailInput = screen.getByLabelText("Email");
+ expect(emailInput).toHaveValue("test@example.com");
+ });
+
+ it("should not display email field when in bulk edit mode", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ expect(screen.queryByLabelText("Email")).not.toBeInTheDocument();
+ });
+
+ it("should display user alias field with initial value", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("User Alias")).toBeInTheDocument();
+ });
+
+ const aliasInput = screen.getByLabelText("User Alias");
+ expect(aliasInput).toHaveValue("Test User");
+ });
+
+ it("should display personal models select with available models", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Personal Models")).toBeInTheDocument();
+ });
+
+ const modelsSelect = screen.getByRole("combobox", { name: /select models/i });
+ expect(modelsSelect).toBeInTheDocument();
+ });
+
+ it("should disable models select when user role is not admin", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ const modelsSelect = screen.getByRole("combobox", { name: /select models/i });
+ expect(modelsSelect).toBeDisabled();
+ });
+ });
+
+ it("should enable models select when user role is admin", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ const modelsSelect = screen.getByRole("combobox", { name: /select models/i });
+ expect(modelsSelect).not.toBeDisabled();
+ });
+ });
+
+ it("should display max budget input field", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Max Budget (USD)")).toBeInTheDocument();
+ });
+ });
+
+ it("should display unlimited budget checkbox", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Unlimited Budget")).toBeInTheDocument();
+ });
+ });
+
+ it("should set unlimited budget checkbox when max_budget is null", async () => {
+ const userDataWithNullBudget = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ max_budget: null,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ const checkbox = screen.getByLabelText("Unlimited Budget");
+ expect(checkbox).toBeChecked();
+ });
+ });
+
+ it("should disable budget input when unlimited budget is checked", async () => {
+ const userDataWithNullBudget = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ max_budget: null,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i });
+ expect(budgetInput).toBeDisabled();
+ });
+ });
+
+ it("should enable budget input when unlimited budget is unchecked", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i });
+ expect(budgetInput).not.toBeDisabled();
+ });
+ });
+
+ it("should clear budget value when unlimited budget is checked", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Unlimited Budget")).toBeInTheDocument();
+ });
+
+ const checkbox = screen.getByLabelText("Unlimited Budget");
+ await userEvent.click(checkbox);
+
+ await waitFor(() => {
+ const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i });
+ expect(budgetInput).toHaveValue(null);
+ });
+ });
+
+ it("should display metadata textarea with formatted JSON", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Metadata")).toBeInTheDocument();
+ });
+
+ const metadataTextarea = screen.getByLabelText("Metadata");
+ const expectedJson = JSON.stringify(MOCK_USER_DATA.user_info.metadata, null, 2);
+ expect(metadataTextarea).toHaveValue(expectedJson);
+ });
+
+ it("should display empty metadata textarea when metadata is undefined", async () => {
+ const userDataWithoutMetadata = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ metadata: undefined,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ const metadataTextarea = screen.getByLabelText("Metadata");
+ expect(metadataTextarea).toHaveValue("");
+ });
+ });
+
+ it("should call onCancel when cancel button is clicked", async () => {
+ const onCancelMock = vi.fn();
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ const cancelButton = screen.getByRole("button", { name: /cancel/i });
+ await userEvent.click(cancelButton);
+
+ expect(onCancelMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onSubmit with form values when form is submitted", async () => {
+ const onSubmitMock = vi.fn();
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByRole("button", { name: /save changes/i });
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(onSubmitMock).toHaveBeenCalled();
+ });
+
+ const callArgs = onSubmitMock.mock.calls[0][0];
+ expect(callArgs.user_id).toBe("user-123");
+ expect(callArgs.user_email).toBe("test@example.com");
+ expect(callArgs.user_alias).toBe("Test User");
+ expect(callArgs.user_role).toBe("proxy_admin");
+ expect(callArgs.models).toEqual(["gpt-4", "gpt-3.5-turbo"]);
+ expect(callArgs.max_budget).toBe(100.5);
+ expect(callArgs.budget_duration).toBe("30d");
+ expect(callArgs.metadata).toEqual(MOCK_USER_DATA.user_info.metadata);
+ });
+
+ it("should set max_budget to null when unlimited budget is checked on submit", async () => {
+ const onSubmitMock = vi.fn();
+ const userDataWithNullBudget = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ max_budget: null,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByRole("button", { name: /save changes/i });
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(onSubmitMock).toHaveBeenCalled();
+ });
+
+ const callArgs = onSubmitMock.mock.calls[0][0];
+ expect(callArgs.max_budget).toBeNull();
+ });
+
+ it("should require budget when unlimited budget is not checked", async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Max Budget (USD)")).toBeInTheDocument();
+ });
+
+ const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i });
+ await userEvent.clear(budgetInput);
+
+ const checkbox = screen.getByLabelText("Unlimited Budget");
+ expect(checkbox).not.toBeChecked();
+
+ const submitButton = screen.getByRole("button", { name: /save changes/i });
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("Please enter a budget or select Unlimited Budget")).toBeInTheDocument();
+ });
+ });
+
+ it("should allow submission when unlimited budget is checked even if budget is empty", async () => {
+ const onSubmitMock = vi.fn();
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Unlimited Budget")).toBeInTheDocument();
+ });
+
+ const checkbox = screen.getByLabelText("Unlimited Budget");
+ await userEvent.click(checkbox);
+
+ await waitFor(() => {
+ expect(checkbox).toBeChecked();
+ });
+
+ const submitButton = screen.getByRole("button", { name: /save changes/i });
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(onSubmitMock).toHaveBeenCalled();
+ });
+ });
+
+ it("should update form values when userData changes", async () => {
+ const { rerender } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("User Alias")).toHaveValue("Test User");
+ });
+
+ const updatedUserData = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ user_alias: "Updated Alias",
+ },
+ };
+
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("User Alias")).toHaveValue("Updated Alias");
+ });
+ });
+
+ it("should handle user data with empty models array", async () => {
+ const userDataWithEmptyModels = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ models: [],
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByRole("button", { name: /save changes/i });
+ await userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onSubmit).toHaveBeenCalled();
+ });
+
+ const callArgs = defaultProps.onSubmit.mock.calls[0][0];
+ expect(callArgs.models).toEqual([]);
+ });
+
+ it("should handle user data with undefined max_budget", async () => {
+ const userDataWithUndefinedBudget = {
+ ...MOCK_USER_DATA,
+ user_info: {
+ ...MOCK_USER_DATA.user_info,
+ max_budget: undefined,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ const checkbox = screen.getByLabelText("Unlimited Budget");
+ expect(checkbox).toBeChecked();
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/user_edit_view.tsx b/ui/litellm-dashboard/src/components/user_edit_view.tsx
index 5a15cba91f..e123b6aadd 100644
--- a/ui/litellm-dashboard/src/components/user_edit_view.tsx
+++ b/ui/litellm-dashboard/src/components/user_edit_view.tsx
@@ -1,12 +1,11 @@
-import React from "react";
-import { Form, Select, Tooltip } from "antd";
-import NumericalInput from "./shared/numerical_input";
-import { TextInput, Textarea, SelectItem } from "@tremor/react";
-import { Button } from "@tremor/react";
-import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
-import { all_admin_roles } from "../utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
+import { Button, SelectItem, TextInput, Textarea } from "@tremor/react";
+import { Checkbox, Form, Select, Tooltip } from "antd";
+import React, { useState } from "react";
+import { all_admin_roles } from "../utils/roles";
import BudgetDurationDropdown from "./common_components/budget_duration_dropdown";
+import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
+import NumericalInput from "./shared/numerical_input";
interface UserEditViewProps {
userData: any;
@@ -34,21 +33,34 @@ export function UserEditView({
isBulkEdit = false,
}: UserEditViewProps) {
const [form] = Form.useForm();
+ const [unlimitedBudget, setUnlimitedBudget] = useState(false);
// Set initial form values
React.useEffect(() => {
+ const maxBudget = userData.user_info?.max_budget;
+ const isUnlimited = maxBudget === null || maxBudget === undefined;
+ setUnlimitedBudget(isUnlimited);
+
form.setFieldsValue({
user_id: userData.user_id,
user_email: userData.user_info?.user_email,
user_alias: userData.user_info?.user_alias,
user_role: userData.user_info?.user_role,
models: userData.user_info?.models || [],
- max_budget: userData.user_info?.max_budget,
+ max_budget: isUnlimited ? "" : maxBudget,
budget_duration: userData.user_info?.budget_duration,
metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined,
});
}, [userData, form]);
+ const handleUnlimitedBudgetChange = (e: any) => {
+ const checked = e.target.checked;
+ setUnlimitedBudget(checked);
+ if (checked) {
+ form.setFieldsValue({ max_budget: "" });
+ }
+ };
+
const handleSubmit = (values: any) => {
// Convert metadata back to an object if it exists and is a string
if (values.metadata && typeof values.metadata === "string") {
@@ -60,6 +72,10 @@ export function UserEditView({
}
}
+ if (unlimitedBudget || values.max_budget === "" || values.max_budget === undefined) {
+ values.max_budget = null;
+ }
+
onSubmit(values);
};
@@ -138,8 +154,36 @@ export function UserEditView({
-
-
+
+ Max Budget (USD)
+
+ Unlimited Budget
+
+
+ }
+ name="max_budget"
+ rules={[
+ {
+ validator: (_, value) => {
+ if (!unlimitedBudget && (value === "" || value === null || value === undefined)) {
+ return Promise.reject(new Error("Please enter a budget or select Unlimited Budget"));
+ }
+ return Promise.resolve();
+ },
+ },
+ ]}
+ >
+