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 @@ + -Claude +Claude 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 ( + !open && !isSubmitting && onClose()}> + + + Adjust budget + {user.user_email || user.user_id} + +
+
+ + + {({ id, value, onChange }) => } + + + {({ ref, value, onChange, ...control }) => ( + onChange(event.target.value)} + disabled={unlimited || isSubmitting} + /> + )} + + + {({ id, value, onChange }) => } + + +
+
+ + + + +
+
+ ); +} 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 (