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
This commit is contained in:
Abdullah Alqahtani 2026-11-03 02:35:18 +01:00
parent 4187e1ae89
commit c9dfd6411d
11 changed files with 302 additions and 4 deletions

View file

@ -1,3 +1,4 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
<!--
Source: https://github.com/lobehub/lobe-icons/blob/master/packages/static-svg/icons/claude-color.svg
Retrieved: 2026-10-22
@ -24,4 +25,4 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
<title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -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<UserInfo, "user_id" | "user_email" | "max_budget" | "budget_duration"> = {
user_id: "budget-user",
user_email: "budget@example.test",
max_budget: 12.345,
budget_duration: "7d",
};
const fetchMock = vi.fn<typeof fetch>();
const renderDialog = () => {
const onClose = vi.fn();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<UserBudgetDialog user={userInfo} accessToken="test-token" onClose={onClose} />
</QueryClientProvider>,
);
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());
});
});

View file

@ -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<UserInfo, "user_id" | "user_email" | "max_budget" | "budget_duration">;
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<typeof userBudgetSchema>) => {
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 (
<Dialog open onOpenChange={(open) => !open && !isSubmitting && onClose()}>
<DialogContent showCloseButton={!isSubmitting}>
<DialogHeader>
<DialogTitle>Adjust budget</DialogTitle>
<DialogDescription>{user.user_email || user.user_id}</DialogDescription>
</DialogHeader>
<form id={formId} onSubmit={form.handleSubmit(save)} noValidate>
<fieldset disabled={isSubmitting}>
<FieldGroup>
<FormField control={form.control} name="unlimited" label="Unlimited budget" orientation="horizontal">
{({ id, value, onChange }) => <Checkbox id={id} checked={value} onCheckedChange={onChange} />}
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
step="any"
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={unlimited || isSubmitting}
/>
)}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
</FormField>
</FieldGroup>
</fieldset>
</form>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save budget"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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);
});
});

View file

@ -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" },
);

View file

@ -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();

View file

@ -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<ViewUserDashboardProps> = ({
accessToken,
token,
userRole,
isViewOnly,
userID,
teams,
orgAdminOrgIds,
@ -77,6 +80,9 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [userToDelete, setUserToDelete] = useState<UserInfo | null>(null);
const [budgetUser, setBudgetUser] = useState<UserInfo | null>(null);
const canAdjustBudget = isProxyAdmin && isViewOnly === false;
const budgetDialogUser = canAdjustBudget ? budgetUser : null;
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
@ -313,6 +319,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
onUserClick={handleUserClick}
onDeleteUser={handleDelete}
onResetPassword={handleResetPassword}
onAdjustBudget={canAdjustBudget ? setBudgetUser : undefined}
/>
);
@ -401,6 +408,10 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
usersTable
)}
{budgetDialogUser && accessToken && (
<UserBudgetDialog user={budgetDialogUser} accessToken={accessToken} onClose={() => setBudgetUser(null)} />
)}
{/* Existing Modals */}
<DeleteResourceModal
isOpen={isDeleteModalOpen}

View file

@ -40,6 +40,7 @@ interface HarnessOverrides {
onUserClick?: (userId: string, openInEditMode?: boolean) => 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<SortingState>([{ 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<typeof userEvent.setup>, 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(<Harness data={[selected]} onAdjustBudget={onAdjustBudget} />);
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(<Harness />);
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(<Harness />);

View file

@ -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<string, string> = {
@ -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(
() =>

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
@ -52,6 +60,12 @@ function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onR
<Pencil />
Edit user
</DropdownMenuItem>
{onAdjustBudget && (
<DropdownMenuItem onClick={() => onAdjustBudget(user)}>
<Pencil />
Adjust budget
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onResetPassword(user.user_id)} data-testid="user-action-reset-password">
<KeyRound />
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<UserInfo>[] => {
const baseColumns: ColumnDef<UserInfo>[] = [
{
@ -282,6 +298,7 @@ export const getUsersTableColumns = ({
onUserClick={onUserClick}
onDeleteUser={onDeleteUser}
onResetPassword={onResetPassword}
onAdjustBudget={onAdjustBudget}
/>
</div>
),

View file

@ -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 (
<ViewUserDashboard
userID={userId}
userRole={userRole}
isViewOnly={isViewOnly}
token={token}
teams={teams ?? null}
accessToken={accessToken}