feat(ui): reset a team member's spend from the Members tab

Every member now carries a membership row, so a member who spent with no budget is over budget the moment a member budget is added later. The only fix was POST /team/{team_id}/member/{user_id}/reset_spend, which had no UI.

The Members tab gets a Reset spend action on rows that have current cycle spend. It confirms in a dialog, posts reset_to 0 through the typed client, and refreshes the team without remounting the page so the tab stays open. A team admin does not see it on their own row because the backend rejects that reset
This commit is contained in:
ryan-crabbe-berri 2026-09-18 12:27:39 -07:00
parent 61d4c5b9b5
commit c21e86a443
6 changed files with 234 additions and 20 deletions

View file

@ -0,0 +1,17 @@
import { useMutation } from "@tanstack/react-query";
import { fetchClient } from "@/lib/http/api";
export interface ResetTeamMemberSpendParams {
teamId: string;
userId: string;
}
export const resetTeamMemberSpend = async ({ teamId, userId }: ResetTeamMemberSpendParams): Promise<void> => {
await fetchClient.POST("/team/{team_id}/member/{user_id}/reset_spend", {
params: { path: { team_id: teamId, user_id: userId } },
body: { reset_to: 0 },
});
};
export const useResetTeamMemberSpend = () =>
useMutation<void, Error, ResetTeamMemberSpendParams>({ mutationFn: resetTeamMemberSpend });

View file

@ -30,6 +30,7 @@ export const TableIconActionButtonMap: Record<string, TableIconActionButtonBaseP
Delete: { icon: TrashIcon, className: "hover:text-destructive" },
Test: { icon: PlayIcon, className: "hover:text-info" },
Regenerate: { icon: RefreshIcon, className: "hover:text-success" },
Reset: { icon: RefreshIcon, className: "hover:text-info" },
Up: { icon: ChevronUpIcon, className: "hover:text-info" },
Down: { icon: ChevronDownIcon, className: "hover:text-info" },
Open: { icon: ExternalLinkIcon, className: "hover:text-success" },

View file

@ -36,6 +36,8 @@ export interface MemberTableProps {
roleTooltip?: string;
extraColumns?: MemberTableColumn[];
showDeleteForMember?: (member: Member) => boolean;
onResetSpend?: (member: Member) => void;
showResetSpendForMember?: (member: Member) => boolean;
emptyText?: string;
}
@ -73,6 +75,8 @@ interface MemberColumnDeps {
roleTooltip?: string;
extraColumns: MemberTableColumn[];
showDeleteForMember?: (member: Member) => boolean;
onResetSpend?: (member: Member) => void;
showResetSpendForMember?: (member: Member) => boolean;
}
const extraColumnDef = (column: MemberTableColumn): ColumnDef<Member> => {
@ -105,6 +109,8 @@ const buildColumns = ({
roleTooltip,
extraColumns,
showDeleteForMember,
onResetSpend,
showResetSpendForMember,
}: MemberColumnDeps): ColumnDef<Member>[] => [
{
id: "user_alias",
@ -173,6 +179,14 @@ const buildColumns = ({
dataTestId="edit-member"
onClick={() => onEdit(row.original)}
/>
{onResetSpend && (showResetSpendForMember?.(row.original) ?? true) && (
<TableIconActionButton
variant="Reset"
tooltipText="Reset spend"
dataTestId="reset-member-spend"
onClick={() => onResetSpend(row.original)}
/>
)}
{(!showDeleteForMember || showDeleteForMember(row.original)) && (
<TableIconActionButton
variant="Delete"
@ -196,6 +210,8 @@ export default function MemberTable({
roleTooltip,
extraColumns = [],
showDeleteForMember,
onResetSpend,
showResetSpendForMember,
emptyText,
}: MemberTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
@ -210,6 +226,8 @@ export default function MemberTable({
roleTooltip,
extraColumns,
showDeleteForMember,
onResetSpend,
showResetSpendForMember,
};
const columns = buildColumns(columnDeps);
const roleFilterItems = [

View file

@ -678,6 +678,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
};
const refreshTeamData = async () => {
if (!accessToken) return;
try {
setTeamData(await teamInfoCall(accessToken, teamId));
} catch {
toast.fromError("Failed to load team information");
}
};
useEffect(() => {
fetchTeamInfo();
}, [teamId, accessToken]);
@ -1351,6 +1360,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
teamData={teamData}
canEditTeam={canEditTeam}
handleMemberDelete={handleMemberDelete}
onMemberSpendReset={refreshTeamData}
setSelectedEditMember={setSelectedEditMember}
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
setIsAddMemberModalVisible={setIsAddMemberModalVisible}

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } 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";
@ -13,6 +13,9 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(),
}));
const { POST } = vi.hoisted(() => ({ POST: vi.fn() }));
vi.mock("@/lib/http/api", () => ({ fetchClient: { POST } }));
vi.mock("@/utils/roles", () => ({
isUserTeamAdminForSingleTeam: vi.fn(() => false),
isProxyAdminRole: vi.fn(() => false),
@ -26,6 +29,7 @@ const mockHandleMemberDelete = vi.fn();
const mockSetSelectedEditMember = vi.fn();
const mockSetIsEditMemberModalVisible = vi.fn();
const mockSetIsAddMemberModalVisible = vi.fn();
const mockOnMemberSpendReset = vi.fn();
const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString();
@ -121,6 +125,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -136,6 +141,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -154,6 +160,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -172,6 +179,7 @@ describe("TeamMembersComponent", () => {
const props = {
canEditTeam: false,
handleMemberDelete: mockHandleMemberDelete,
onMemberSpendReset: mockOnMemberSpendReset,
setSelectedEditMember: mockSetSelectedEditMember,
setIsEditMemberModalVisible: mockSetIsEditMemberModalVisible,
setIsAddMemberModalVisible: mockSetIsAddMemberModalVisible,
@ -195,6 +203,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -221,6 +230,7 @@ describe("TeamMembersComponent", () => {
})}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -247,6 +257,7 @@ describe("TeamMembersComponent", () => {
})}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -262,6 +273,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -280,6 +292,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -295,6 +308,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -311,6 +325,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -330,6 +345,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -364,6 +380,7 @@ describe("TeamMembersComponent", () => {
teamData={teamData}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -417,6 +434,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -447,6 +465,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -466,6 +485,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -482,6 +502,7 @@ describe("TeamMembersComponent", () => {
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@ -491,4 +512,95 @@ describe("TeamMembersComponent", () => {
expect(screen.queryByTestId("edit-member")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-member")).not.toBeInTheDocument();
});
describe("reset spend", () => {
const renderEditableTab = () =>
renderWithProviders(
<TeamMembersComponent
teamData={createMockTeamData()}
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
/>,
);
it("resets the member's current cycle spend to $0 after confirming, then refreshes the team", async () => {
const user = userEvent.setup();
POST.mockResolvedValue({ data: {} });
renderEditableTab();
const memberRow = screen.getByRole("row", { name: /user1@test\.com/ });
await user.click(within(memberRow).getByTestId("reset-member-spend"));
const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Spend" });
expect(dialog).toHaveTextContent("user1@test.com");
expect(dialog).toHaveTextContent("$100.5000");
expect(POST).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Reset" }));
await waitFor(() => expect(mockOnMemberSpendReset).toHaveBeenCalledTimes(1));
expect(POST).toHaveBeenCalledExactlyOnceWith("/team/{team_id}/member/{user_id}/reset_spend", {
params: { path: { team_id: "team-123", user_id: "user1@test.com" } },
body: { reset_to: 0 },
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("keeps the dialog open and does not refresh the team when the reset fails", async () => {
const user = userEvent.setup();
POST.mockRejectedValue(new Error("Cannot reset your own spend. Ask a proxy admin."));
renderEditableTab();
await user.click(screen.getByTestId("reset-member-spend"));
const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Spend" });
await user.click(within(dialog).getByRole("button", { name: "Reset" }));
await waitFor(() => expect(POST).toHaveBeenCalledTimes(1));
expect(mockOnMemberSpendReset).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Reset Team Member Spend" })).toBeInTheDocument();
});
it("does not call the API when the dialog is cancelled", async () => {
const user = userEvent.setup();
renderEditableTab();
await user.click(screen.getByTestId("reset-member-spend"));
const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Spend" });
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(POST).not.toHaveBeenCalled();
});
it("only offers the reset on members that have current cycle spend", () => {
renderEditableTab();
expect(
within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId("reset-member-spend"),
).toBeVisible();
expect(
within(screen.getByRole("row", { name: /user2@test\.com/ })).queryByTestId("reset-member-spend"),
).not.toBeInTheDocument();
});
it("hides the reset on the caller's own row for a team admin, since the backend rejects it", () => {
vi.mocked(useAuthorized).mockReturnValue({ userId: "user1@test.com", userRole: "Internal User" } as never);
vi.mocked(isProxyAdminRole).mockReturnValue(false);
renderEditableTab();
expect(screen.queryByTestId("reset-member-spend")).not.toBeInTheDocument();
});
it("shows the reset on the caller's own row for a proxy admin", () => {
vi.mocked(useAuthorized).mockReturnValue({ userId: "user1@test.com", userRole: "Admin" } as never);
vi.mocked(isProxyAdminRole).mockReturnValue(true);
renderEditableTab();
expect(screen.getByTestId("reset-member-spend")).toBeVisible();
});
});
});

View file

@ -1,13 +1,18 @@
import { useResetTeamMemberSpend } from "@/app/(dashboard)/hooks/teams/useResetTeamMemberSpend";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { SimpleTooltip } from "@/components/ui/tooltip";
import MemberTable from "@/components/common_components/MemberTable";
import { Member } from "@/components/networking";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { DateCell, MoneyCell } from "@/components/shared/table_cells";
import { toast } from "@/lib/toast";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CircleHelp } from "lucide-react";
import type { ComponentProps } from "react";
import { useState, type ComponentProps } from "react";
import { TeamData, TeamMembership } from "./TeamInfo";
export const seedMemberBudgetFields = (
@ -31,6 +36,7 @@ interface TeamMemberTabProps {
setSelectedEditMember: (member: Member) => void;
setIsEditMemberModalVisible: (visible: boolean) => void;
setIsAddMemberModalVisible: (visible: boolean) => void;
onMemberSpendReset: () => void;
}
export default function TeamMemberTab({
@ -40,7 +46,11 @@ export default function TeamMemberTab({
setSelectedEditMember,
setIsEditMemberModalVisible,
setIsAddMemberModalVisible,
onMemberSpendReset,
}: TeamMemberTabProps) {
const [memberToResetSpend, setMemberToResetSpend] = useState<Member | null>(null);
const { mutate: resetMemberSpend, isPending: isResettingSpend } = useResetTeamMemberSpend();
const formatNumber = (value: number | null): string => {
if (value === null || value === undefined) return "0";
@ -199,24 +209,70 @@ export default function TeamMemberTab({
},
];
const handleResetSpend = () => {
if (!memberToResetSpend?.user_id) return;
resetMemberSpend(
{ teamId: teamData.team_id, userId: memberToResetSpend.user_id },
{
onSuccess: () => {
toast.success("Team member spend reset to $0");
setMemberToResetSpend(null);
onMemberSpendReset();
},
onError: (error) => toast.fromError(parseErrorMessage(error)),
},
);
};
return (
<MemberTable
key={teamData.team_id}
members={teamData.team_info.members_with_roles}
canEdit={canEditTeam}
onEdit={(record) => {
const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
setSelectedEditMember(seedMemberBudgetFields(record, membership?.litellm_budget_table));
setIsEditMemberModalVisible(true);
}}
onDelete={handleMemberDelete}
onAddMember={() => setIsAddMemberModalVisible(true)}
roleColumnTitle="Team Role"
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
extraColumns={extraColumns}
showDeleteForMember={() =>
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
}
/>
<>
<MemberTable
key={teamData.team_id}
members={teamData.team_info.members_with_roles}
canEdit={canEditTeam}
onEdit={(record) => {
const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
setSelectedEditMember(seedMemberBudgetFields(record, membership?.litellm_budget_table));
setIsEditMemberModalVisible(true);
}}
onDelete={handleMemberDelete}
onAddMember={() => setIsAddMemberModalVisible(true)}
roleColumnTitle="Team Role"
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
extraColumns={extraColumns}
showDeleteForMember={() =>
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
}
onResetSpend={setMemberToResetSpend}
showResetSpendForMember={(record) =>
getUserCurrentCycleSpend(record.user_id) > 0 && (isProxyAdmin || record.user_id !== userId)
}
/>
<Dialog open={memberToResetSpend !== null} onOpenChange={(open) => !open && setMemberToResetSpend(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Team Member Spend</DialogTitle>
</DialogHeader>
<p>
Reset current cycle spend for{" "}
<strong>{memberToResetSpend?.user_email || memberToResetSpend?.user_id}</strong> in this team to{" "}
<strong>$0</strong>?
</p>
<p className="text-sm text-muted-foreground">
Current cycle spend:{" "}
<strong>${formatNumberWithCommas(getUserCurrentCycleSpend(memberToResetSpend?.user_id ?? null), 4)}</strong>
. This is the value checked against the member&apos;s budget. Total spend and logs are preserved.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setMemberToResetSpend(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleResetSpend} disabled={isResettingSpend}>
Reset
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}