From c9dfd6411d3e4376c55c974e0f30e67087b77b2a Mon Sep 17 00:00:00 2001
From: Abdullah Alqahtani <61029571+anqorithm@users.noreply.github.com>
Date: Tue, 3 Nov 2026 02:35:18 +0100
Subject: [PATCH] feat(ui): add a quick user budget editor
Add an admin-only budget action to Internal Users
Preserve unchanged reset schedules and refresh user rows after saving
Keep the logo license inside its SVG so Next.js can build the dashboard
---
.../public/assets/logos/claude.svg | 3 +-
.../UserBudgetDialog.integration.test.tsx | 86 ++++++++++++++
.../users/_components/UserBudgetDialog.tsx | 107 ++++++++++++++++++
.../users/_components/userBudgetForm.test.ts | 17 +++
.../users/_components/userBudgetForm.ts | 16 +++
.../view_users.integration.test.tsx | 18 +++
.../users/_components/view_users.tsx | 11 ++
.../UsersTable.integration.test.tsx | 21 ++++
.../_components/view_users/UsersTable.tsx | 5 +-
.../view_users/UsersTableColumns.tsx | 19 +++-
.../src/app/(dashboard)/users/page.tsx | 3 +-
11 files changed, 302 insertions(+), 4 deletions(-)
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts
diff --git a/ui/litellm-dashboard/public/assets/logos/claude.svg b/ui/litellm-dashboard/public/assets/logos/claude.svg
index ede30652b25..17da2adfc37 100644
--- a/ui/litellm-dashboard/public/assets/logos/claude.svg
+++ b/ui/litellm-dashboard/public/assets/logos/claude.svg
@@ -1,3 +1,4 @@
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx
new file mode 100644
index 00000000000..25371abda51
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.integration.test.tsx
@@ -0,0 +1,86 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { UserInfo } from "@/components/networking";
+import { chooseSelectOption } from "../../../../../tests/test-utils";
+
+import UserBudgetDialog from "./UserBudgetDialog";
+
+const userInfo: Pick = {
+ user_id: "budget-user",
+ user_email: "budget@example.test",
+ max_budget: 12.345,
+ budget_duration: "7d",
+};
+
+const fetchMock = vi.fn();
+
+const renderDialog = () => {
+ const onClose = vi.fn();
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ return onClose;
+};
+
+describe("UserBudgetDialog", () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(Response.json({ user_id: userInfo.user_id }));
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("submits only the selected user's budget and preserves the existing reset schedule", async () => {
+ const user = userEvent.setup();
+ const onClose = renderDialog();
+ expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(12.345);
+ fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "42.765" } });
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/user/update"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ user_id: "budget-user", max_budget: 42.765 }),
+ }),
+ );
+ });
+
+ it("can remove the limit and change its reset period", async () => {
+ const user = userEvent.setup();
+ const onClose = renderDialog();
+ await user.click(screen.getByRole("checkbox", { name: "Unlimited budget" }));
+ await chooseSelectOption(user, screen.getByRole("combobox", { name: "Reset Budget" }), "monthly");
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/user/update"),
+ expect.objectContaining({
+ body: JSON.stringify({ user_id: "budget-user", max_budget: null, budget_duration: "30d" }),
+ }),
+ );
+ });
+
+ it("keeps the edited values available after an update fails so the admin can retry", async () => {
+ const user = userEvent.setup();
+ fetchMock.mockResolvedValueOnce(Response.json({ error: "Update failed" }, { status: 500 }));
+ const onClose = renderDialog();
+ fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "25" } });
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "Save budget" })).toBeEnabled());
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(25);
+ await user.click(screen.getByRole("button", { name: "Save budget" }));
+ await waitFor(() => expect(onClose).toHaveBeenCalledOnce());
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx
new file mode 100644
index 00000000000..dc430812cd6
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/UserBudgetDialog.tsx
@@ -0,0 +1,107 @@
+import { useId } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { z } from "zod";
+
+import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
+import { UserInfo, userUpdateUserCall } from "@/components/networking";
+import { FormField } from "@/components/shared/form/FormField";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { FieldGroup } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { useZodForm } from "@/lib/forms/useZodForm";
+import { toast } from "@/lib/toast";
+
+import { userBudgetSchema } from "./userBudgetForm";
+
+interface UserBudgetDialogProps {
+ user: Pick;
+ accessToken: string;
+ onClose: () => void;
+}
+
+export default function UserBudgetDialog({ user, accessToken, onClose }: UserBudgetDialogProps) {
+ const formId = useId();
+ const queryClient = useQueryClient();
+ const form = useZodForm(userBudgetSchema, {
+ defaultValues: {
+ unlimited: user.max_budget == null,
+ max_budget: user.max_budget?.toString() ?? "",
+ budget_duration: user.budget_duration ?? null,
+ },
+ });
+ const unlimited = form.watch("unlimited");
+ const { isSubmitting, dirtyFields } = form.formState;
+
+ const save = async (values: z.output) => {
+ try {
+ await userUpdateUserCall(
+ accessToken,
+ {
+ user_id: user.user_id,
+ max_budget: values.unlimited ? null : Number(values.max_budget),
+ ...(dirtyFields.budget_duration ? { budget_duration: values.budget_duration } : {}),
+ },
+ null,
+ );
+ await queryClient.invalidateQueries({ queryKey: ["userList"] });
+ toast.success("Budget updated");
+ onClose();
+ } catch (error) {
+ toast.fromError(error);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts
new file mode 100644
index 00000000000..1d950d402c1
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import { userBudgetSchema } from "./userBudgetForm";
+
+describe("userBudgetSchema", () => {
+ it.each(["0", "0.001", "42.765"])("accepts a finite non-negative budget of %s", (max_budget) => {
+ expect(userBudgetSchema.safeParse({ unlimited: false, max_budget, budget_duration: "7d" }).success).toBe(true);
+ });
+
+ it.each(["", " ", "-1", "NaN", "Infinity", "12abc"])("rejects an invalid finite budget of %s", (max_budget) => {
+ expect(userBudgetSchema.safeParse({ unlimited: false, max_budget, budget_duration: null }).success).toBe(false);
+ });
+
+ it("represents unlimited as null without requiring a disabled amount", () => {
+ expect(userBudgetSchema.safeParse({ unlimited: true, max_budget: "", budget_duration: null }).success).toBe(true);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts
new file mode 100644
index 00000000000..ae96f00f80c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userBudgetForm.ts
@@ -0,0 +1,16 @@
+import { z } from "zod";
+
+export const userBudgetSchema = z
+ .object({
+ unlimited: z.boolean(),
+ max_budget: z.string(),
+ budget_duration: z.string().nullable(),
+ })
+ .refine(
+ ({ unlimited, max_budget }) => {
+ const amount = Number(max_budget);
+ const validAmount = max_budget.trim() !== "" && Number.isFinite(amount) && amount >= 0;
+ return unlimited || validAmount;
+ },
+ { path: ["max_budget"], message: "Enter a non-negative budget" },
+ );
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx
index d728f6473f5..706a542aaed 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.integration.test.tsx
@@ -81,6 +81,7 @@ const defaultProps = {
accessToken: "test-token",
token: "test-token",
userRole: "Admin",
+ isViewOnly: false,
userID: "admin-user-id",
teams: [],
};
@@ -104,6 +105,23 @@ describe("ViewUserDashboard", () => {
});
});
+ it("offers the budget quick action to a write-capable proxy admin", async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+ await user.click(await screen.findByTestId("user-actions-user-1"));
+ await user.click(await screen.findByRole("menuitem", { name: "Adjust budget" }));
+ expect(screen.getByRole("dialog", { name: "Adjust budget" })).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Unlimited budget" })).toBeChecked();
+ });
+
+ it("hides budget updates from a read-only session even when its display role is Admin", async () => {
+ const user = userEvent.setup();
+ renderDashboard({ isViewOnly: true });
+ await user.click(await screen.findByTestId("user-actions-user-1"));
+ await screen.findByTestId("user-action-edit");
+ expect(screen.queryByRole("menuitem", { name: "Adjust budget" })).not.toBeInTheDocument();
+ });
+
it("should render the ViewUserDashboard component", async () => {
renderDashboard();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx
index 1dbab3b1803..7ec1054ed11 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx
@@ -2,6 +2,7 @@ import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import BulkEditUserModal from "./BulkEditUsers";
+import UserBudgetDialog from "./UserBudgetDialog";
import BulkCreateUsersButton from "@/components/bulk_create_users_button";
import { CreateUserButton } from "@/components/CreateUserButton";
import { Button } from "@/components/ui/button";
@@ -39,6 +40,7 @@ interface ViewUserDashboardProps {
accessToken: string | null;
token: string | null;
userRole: string | null;
+ isViewOnly?: boolean;
userID: string | null;
teams: any[] | null;
orgAdminOrgIds?: Array<{ organization_id: string; organization_alias: string }> | null;
@@ -54,6 +56,7 @@ const ViewUserDashboard: React.FC = ({
accessToken,
token,
userRole,
+ isViewOnly,
userID,
teams,
orgAdminOrgIds,
@@ -77,6 +80,9 @@ const ViewUserDashboard: React.FC = ({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [userToDelete, setUserToDelete] = useState(null);
+ const [budgetUser, setBudgetUser] = useState(null);
+ const canAdjustBudget = isProxyAdmin && isViewOnly === false;
+ const budgetDialogUser = canAdjustBudget ? budgetUser : null;
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState(null);
const [baseUrl, setBaseUrl] = useState(null);
@@ -313,6 +319,7 @@ const ViewUserDashboard: React.FC = ({
onUserClick={handleUserClick}
onDeleteUser={handleDelete}
onResetPassword={handleResetPassword}
+ onAdjustBudget={canAdjustBudget ? setBudgetUser : undefined}
/>
);
@@ -401,6 +408,10 @@ const ViewUserDashboard: React.FC = ({
usersTable
)}
+ {budgetDialogUser && accessToken && (
+ setBudgetUser(null)} />
+ )}
+
{/* Existing Modals */}
void;
onDeleteUser?: (user: UserInfo) => void;
onResetPassword?: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
onSortingChange?: Mock;
}
@@ -56,6 +57,7 @@ function Harness({
onUserClick = vi.fn(),
onDeleteUser = vi.fn(),
onResetPassword = vi.fn(),
+ onAdjustBudget,
onSortingChange,
}: HarnessOverrides) {
const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]);
@@ -94,6 +96,7 @@ function Harness({
onUserClick={onUserClick}
onDeleteUser={onDeleteUser}
onResetPassword={onResetPassword}
+ onAdjustBudget={onAdjustBudget}
/>
>
);
@@ -104,6 +107,24 @@ const openRowMenu = async (user: ReturnType, userId: str
};
describe("UsersTable", () => {
+ it("opens the budget editor for the selected row when the action is allowed", async () => {
+ const user = userEvent.setup();
+ const onAdjustBudget = vi.fn();
+ const selected = makeUser({ user_id: "budget-user", max_budget: 25 });
+ render();
+ await openRowMenu(user, "budget-user");
+ await user.click(await screen.findByRole("menuitem", { name: "Adjust budget" }));
+ expect(onAdjustBudget).toHaveBeenCalledWith(selected);
+ });
+
+ it("does not offer a budget update without permission", async () => {
+ const user = userEvent.setup();
+ render();
+ await openRowMenu(user, "user-1");
+ await screen.findByTestId("user-action-edit");
+ expect(screen.queryByRole("menuitem", { name: "Adjust budget" })).not.toBeInTheDocument();
+ });
+
it("renders every migrated column header", () => {
render();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx
index 3e266ef482d..7b4da36903b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx
@@ -48,6 +48,7 @@ interface UsersTableProps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
}
const FILTER_LABELS: Record = {
@@ -90,6 +91,7 @@ export function UsersTable({
onUserClick,
onDeleteUser,
onResetPassword,
+ onAdjustBudget,
}: UsersTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
@@ -101,9 +103,10 @@ export function UsersTable({
onUserClick,
onDeleteUser,
onResetPassword,
+ onAdjustBudget,
};
return getUsersTableColumns(columnDeps);
- }, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword]);
+ }, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword, onAdjustBudget]);
const roleOptions = useMemo(
() =>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx
index f2593f6dda8..9be819bd129 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx
@@ -35,9 +35,17 @@ interface UserRowActionsProps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
}
-function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
+function UserRowActions({
+ user,
+ canViewUserUsage,
+ onUserClick,
+ onDeleteUser,
+ onResetPassword,
+ onAdjustBudget,
+}: UserRowActionsProps) {
return (
Edit user
+ {onAdjustBudget && (
+ onAdjustBudget(user)}>
+
+ Adjust budget
+
+ )}
onResetPassword(user.user_id)} data-testid="user-action-reset-password">
Reset password
@@ -89,6 +103,7 @@ export interface UsersTableColumnsDeps {
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
+ onAdjustBudget?: (user: UserInfo) => void;
}
export const getUsersTableColumns = ({
@@ -98,6 +113,7 @@ export const getUsersTableColumns = ({
onUserClick,
onDeleteUser,
onResetPassword,
+ onAdjustBudget,
}: UsersTableColumnsDeps): ColumnDef[] => {
const baseColumns: ColumnDef[] = [
{
@@ -282,6 +298,7 @@ export const getUsersTableColumns = ({
onUserClick={onUserClick}
onDeleteUser={onDeleteUser}
onResetPassword={onResetPassword}
+ onAdjustBudget={onAdjustBudget}
/>
),
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx
index 75e5f27b07b..44b214a8093 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx
@@ -5,12 +5,13 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
export default function UsersPage() {
- const { accessToken, token, userRole, userId } = useAuthorized();
+ const { accessToken, token, userRole, userId, isViewOnly } = useAuthorized();
const { data: teams } = useTeams();
return (