feat(ui): add team member budget windows (#49091)

This commit is contained in:
ishaan-j-berri 2026-10-31 11:40:23 -07:00
parent efc2304b7e
commit 26ca426393
17 changed files with 523 additions and 12 deletions

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../tests/test-utils";
@ -47,6 +47,35 @@ afterEach(() => {
});
describe("Teams creation integration", () => {
it("should preserve default member budget windows when additional settings are collapsed", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<Teams accessToken="sk-session-test" userID="test-admin" userRole="Admin" premiumUser />);
await user.click(screen.getAllByRole("button", { name: /create team/i })[0]);
await screen.findByLabelText(/team name/i);
await user.click(screen.getByText("Additional Settings"));
await user.click(screen.getByRole("button", { name: /Add Budget Window/i }));
await act(async () => {
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "20" } });
});
await user.click(screen.getByRole("button", { name: /Add Budget Window/i }));
await user.click(screen.getByRole("combobox", { name: "Window 2 duration" }));
await user.click(await screen.findByRole("option", { name: "Monthly" }));
await act(async () => {
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 2 budget (USD)" }), { target: { value: "200" } });
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Budget Team" } });
});
await user.click(screen.getByText("Additional Settings"));
await user.click(screen.getAllByRole("button", { name: /create team/i }).at(-1)!);
await waitFor(() =>
expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes("/team/new"))).toBe(true),
);
const request = vi.mocked(fetch).mock.calls.find(([input]) => String(input).includes("/team/new"));
expect(JSON.parse(String(request?.[1]?.body)).team_member_budget_limits).toEqual([
{ budget_duration: "24h", max_budget: 20 },
{ budget_duration: "30d", max_budget: 200 },
]);
});
it("sends a configured default member per-model budget", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<Teams accessToken="sk-session-test" userID="test-admin" userRole="Admin" premiumUser />);

View file

@ -1,3 +1,5 @@
import { MemberBudgetWindows } from "./team/MemberBudgetWindows";
import { memberBudgetWindowsSchema } from "./team/memberBudgetWindowSchema";
import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import useCan from "@/app/(dashboard)/hooks/useCan";
@ -86,6 +88,7 @@ const teamCreateFieldsSchema = z.object({
metadata: metadataPairsSchema.optional(),
team_id: z.string().optional(),
team_member_budget: z.number().optional(),
team_member_budget_limits: memberBudgetWindowsSchema.optional(),
team_member_key_duration: z.string().optional(),
team_member_rpm_limit: numericInputSchema,
team_member_tpm_limit: numericInputSchema,
@ -124,6 +127,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
metadata: [],
team_id: undefined,
team_member_budget: undefined,
team_member_budget_limits: [],
team_member_key_duration: undefined,
team_member_rpm_limit: undefined,
team_member_tpm_limit: undefined,
@ -553,10 +557,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
}
}
const { require_trace_id: requireTraceId, ...teamCreateValues } = formValues;
const {
require_trace_id: requireTraceId,
team_member_budget_limits: memberBudgetWindows,
...teamCreateValues
} = formValues;
const teamMemberModelBudgets = modelMaxBudgetUpdate(teamMemberModelMaxBudget, {});
await teamCreateCall(accessToken, {
...teamCreateValues,
...(memberBudgetWindows?.length ? { team_member_budget_limits: memberBudgetWindows } : {}),
...(requireTraceId === undefined ? {} : { require_trace_id: requireTraceId }),
...(teamMemberModelBudgets !== undefined ? { team_member_model_max_budget: teamMemberModelBudgets } : {}),
models: normalizeTeamModelSelection(formValues.models),
@ -895,6 +904,13 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, isViewOnly
>
{({ ref, value, ...field }) => <UIInput {...field} ref={ref} value={value ?? ""} />}
</FormField>
<FormField
control={form.control}
name="team_member_budget_limits"
label="Default member budget windows"
>
{({ value, onChange }) => <MemberBudgetWindows value={value ?? []} onChange={onChange} />}
</FormField>
<FormField
control={form.control}
name="team_member_budget"

View file

@ -85,10 +85,18 @@ describe("UserSearchModal submit payload", () => {
vi.mocked(userFilterUICall).mockResolvedValue([{ user_id: "u-1", user_email: "picked@example.com" }] as never);
});
const setup = () => {
const setup = (defaultBudgetWindows?: Array<{ budget_duration: string; max_budget: number }>) => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={onSubmit} accessToken="sk-test" />);
render(
<UserSearchModal
isVisible
onCancel={vi.fn()}
onSubmit={onSubmit}
accessToken="sk-test"
defaultBudgetWindows={defaultBudgetWindows}
/>,
);
return { user, onSubmit };
};
@ -130,6 +138,25 @@ describe("UserSearchModal submit payload", () => {
});
});
it("should submit custom windows and reset to team defaults for the next member", async () => {
const windows = [
{ budget_duration: "24h", max_budget: 20 },
{ budget_duration: "30d", max_budget: 200 },
];
const { user, onSubmit } = setup(windows);
await user.click(screen.getByRole("radio", { name: "Custom budget windows" }));
await act(async () => {
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "5" } });
});
await searchByEmail(user, "pick");
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].budget_limits).toEqual([{ ...windows[0], max_budget: 5 }, windows[1]]);
expect(screen.getByRole("radio", { name: "Use team defaults" })).toBeChecked();
expect(screen.getByText("$20 daily, $200 monthly")).toBeInTheDocument();
expect(save()).toBeDisabled();
});
it("carries a role changed off its default into the payload", async () => {
const { onSubmit } = setup();
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });

View file

@ -11,6 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
import { MemberBudgetWindows } from "../team/MemberBudgetWindows";
import { memberBudgetWindowsSchema } from "../team/memberBudgetWindowSchema";
interface User {
user_id: string;
@ -31,12 +34,14 @@ interface Role {
}
interface FormValues {
budget_limits?: BudgetWindowEntry[];
user_email: string | null | undefined;
user_id: string | null | undefined;
role: string;
}
interface UserSearchModalProps {
defaultBudgetWindows?: BudgetWindowEntry[];
isVisible: boolean;
onCancel: () => void;
onSubmit: (values: FormValues) => void | Promise<void>;
@ -63,8 +68,14 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
],
defaultRole = "user",
teamId,
defaultBudgetWindows,
}) => {
const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole };
const emptyValues: FormValues = {
user_email: undefined,
user_id: undefined,
role: defaultRole,
...(defaultBudgetWindows !== undefined ? { budget_limits: [] } : {}),
};
const form = useForm<FormValues>({ defaultValues: emptyValues });
const selectedUserId = form.watch("user_id");
const selectedUserEmail = form.watch("user_email");
@ -124,9 +135,17 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
};
const handleSubmit = async (values: FormValues): Promise<void> => {
if (values.budget_limits !== undefined) {
const validation = memberBudgetWindowsSchema.safeParse(values.budget_limits);
if (!validation.success) {
form.setError("budget_limits", { type: "validate", message: validation.error.issues[0]?.message });
return;
}
}
setIsSubmitting(true);
try {
await onSubmit(values);
form.reset(emptyValues);
} finally {
setIsSubmitting(false);
}
@ -235,6 +254,17 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
</Select>
)}
</FormField>
{defaultBudgetWindows !== undefined && (
<FormField control={form.control} name="budget_limits" label="Budget windows">
{({ value, onChange }) => (
<MemberBudgetWindows
value={value ?? []}
onChange={onChange}
defaultWindows={defaultBudgetWindows}
/>
)}
</FormField>
)}
</FieldGroup>
<div className="mt-4 text-right">

View file

@ -46,7 +46,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
value={window.budget_duration}
onValueChange={(v: string | null) => v && updateWindow(idx, "budget_duration", v)}
>
<SelectTrigger className="w-[130px]">
<SelectTrigger className="w-[130px]" aria-label={`Window ${idx + 1} duration`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -62,6 +62,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
aria-label={`Window ${idx + 1} budget (USD)`}
type="number"
step={0.01}
min={0}
@ -80,6 +81,8 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
/>
</InputGroup>
<Button
type="button"
aria-label={`Remove budget window ${idx + 1}`}
variant="ghost"
size="sm"
className="px-1 text-destructive hover:text-destructive/80"
@ -93,6 +96,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
);
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={(e) => {

View file

@ -3,6 +3,49 @@ import { clearTokenCookies } from "@/utils/cookieUtils";
import * as Networking from "./networking";
import { uiHref } from "@/utils/uiHref";
describe("member budget window requests", () => {
const windows = [
{ budget_duration: "24h", max_budget: 20 },
{ budget_duration: "30d", max_budget: 200 },
];
afterEach(() => vi.unstubAllGlobals());
it("should send member windows beside the member when adding a member", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({}));
vi.stubGlobal("fetch", fetchMock);
await Networking.teamMemberAddCall("test-session", "team", {
user_id: "member",
role: "user",
budget_limits: windows,
});
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
team_id: "team",
member: { user_id: "member", role: "user" },
budget_limits: windows,
});
});
it.each([windows, []])("should send configured or cleared member windows on update", async (budget_limits) => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({}));
vi.stubGlobal("fetch", fetchMock);
await Networking.teamMemberUpdateCall("test-session", "team", { user_id: "member", role: "user", budget_limits });
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
team_id: "team",
user_id: "member",
role: "user",
budget_limits,
});
});
it("should omit windows when an older caller does not supply them", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({}));
vi.stubGlobal("fetch", fetchMock);
await Networking.teamMemberUpdateCall("test-session", "team", { user_id: "member", role: "user" });
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ team_id: "team", user_id: "member", role: "user" });
});
});
vi.mock("@/utils/cookieUtils", () => ({
clearTokenCookies: vi.fn(),
getCookie: vi.fn(),

View file

@ -1,3 +1,4 @@
import type { BudgetWindowEntry } from "./key_team_helpers/BudgetWindowsEditor";
// Shared date formatter for daily activity endpoints
export const formatDate = (date: Date) => {
const year = date.getFullYear();
@ -2920,6 +2921,7 @@ export const modelPatchUpdateCall = async (
};
export interface Member {
budget_limits?: BudgetWindowEntry[];
role: string;
user_id: string | null;
user_email?: string | null;
@ -2936,6 +2938,7 @@ export interface Member {
export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => {
try {
const { budget_limits, ...member } = formValues;
const url = proxyBaseUrl ? `${proxyBaseUrl}/team/member_add` : `/team/member_add`;
const response = await fetch(url, {
@ -2946,7 +2949,8 @@ export const teamMemberAddCall = async (accessToken: string, teamId: string, for
},
body: JSON.stringify({
team_id: teamId,
member: formValues,
member,
...(budget_limits !== undefined ? { budget_limits } : {}),
}),
});
@ -3046,6 +3050,9 @@ export const teamMemberUpdateCall = async (
role: formValues.role,
user_id: formValues.user_id,
};
if (formValues.budget_limits !== undefined) {
requestBody.budget_limits = formValues.budget_limits;
}
const orNull = (value: unknown) => (value === undefined || value === null || value === "" ? null : value);
if (formValues.user_email !== undefined) {

View file

@ -26,6 +26,8 @@ import {
type MemberFormValues,
} from "./memberFormValues";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MemberBudgetWindows } from "./MemberBudgetWindows";
import { isMemberBudgetWindows, memberBudgetWindowsSchema } from "./memberBudgetWindowSchema";
dayjs.extend(utc);
@ -65,6 +67,7 @@ const buildMemberSchema = (config: ModalConfig): z.ZodType<MemberFormValues, Mem
user_id: z.string().nullish(),
role: z.string({ error: ROLE_REQUIRED_MESSAGE }).min(1, ROLE_REQUIRED_MESSAGE),
...Object.fromEntries((config.additionalFields ?? []).map((field) => [field.name, memberFieldSchema])),
budget_limits: config.showBudgetWindows ? memberBudgetWindowsSchema.optional() : z.undefined().optional(),
};
return z.object(shape).superRefine((values, ctx) => {
@ -164,7 +167,7 @@ const MemberModal = <T extends BaseMember>({
return (
<MultiSelect
options={field.options ?? []}
value={Array.isArray(value) ? value : []}
value={Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []}
onValueChange={onChange}
placeholder={field.placeholder || "Select options"}
/>
@ -277,6 +280,17 @@ const MemberModal = <T extends BaseMember>({
</FormField>
{config.additionalFields?.map((field) => renderField(field, field.name))}
{config.showBudgetWindows && (
<FormField control={form.control} name="budget_limits" label="Budget windows">
{({ value, onChange }) => (
<MemberBudgetWindows
value={isMemberBudgetWindows(value) ? value : []}
onChange={onChange}
defaultWindows={config.defaultBudgetWindows ?? []}
/>
)}
</FormField>
)}
</FieldGroup>
<div className="mt-6 text-right">

View file

@ -0,0 +1,88 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import MemberModal from "./EditMembership";
const windows = [
{ budget_duration: "24h", max_budget: 20 },
{ budget_duration: "30d", max_budget: 200 },
];
function renderMember(budget_limits = windows, defaultBudgetWindows = windows) {
const onSubmit = vi.fn();
renderWithProviders(
<MemberModal
visible
mode="edit"
initialData={{ user_id: "member", role: "user", budget_limits, max_budget_in_team: 50 }}
onCancel={vi.fn()}
onSubmit={onSubmit}
config={{
title: "Edit Member",
roleOptions: [{ label: "User", value: "user" }],
showUserId: true,
showBudgetWindows: true,
defaultBudgetWindows,
additionalFields: [{ name: "max_budget_in_team", label: "Single budget", type: "numerical" }],
}}
/>,
);
return onSubmit;
}
describe("member budget windows form", () => {
it("should render both configured windows", () => {
renderMember();
expect(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" })).toHaveValue(20);
expect(screen.getByRole("spinbutton", { name: "Window 2 budget (USD)" })).toHaveValue(200);
expect(screen.getByRole("radio", { name: "Custom budget windows" })).toBeChecked();
});
it("should inherit team defaults, then save a custom zero-dollar cap without changing the single budget", async () => {
const onSubmit = renderMember([]);
expect(screen.getByText("$20 daily, $200 monthly")).toBeInTheDocument();
expect(screen.queryByRole("spinbutton", { name: "Window 1 budget (USD)" })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("radio", { name: "Custom budget windows" }));
});
await act(async () => {
fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "0" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
});
const expected = {
user_id: "member",
role: "user",
max_budget_in_team: 50,
budget_limits: [{ ...windows[0], max_budget: 0 }, windows[1]],
};
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expected));
});
it("should send an empty override to restore inheritance", async () => {
const onSubmit = renderMember();
await act(async () => {
fireEvent.click(screen.getByRole("radio", { name: "Use team defaults" }));
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
});
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ budget_limits: [] })));
});
it("should explain an incomplete custom window and prevent saving it", async () => {
const onSubmit = renderMember([], []);
await act(async () => {
fireEvent.click(screen.getByRole("radio", { name: "Custom budget windows" }));
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
});
expect(await screen.findByText("Enter a budget")).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("should explain duplicate durations and prevent saving them", async () => {
const onSubmit = renderMember([windows[0], windows[0]]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
});
expect(await screen.findByText("Use each budget duration only once")).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,64 @@
import { useId } from "react";
import { BudgetWindowsEditor, type BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface MemberBudgetWindowsProps {
value: BudgetWindowEntry[];
onChange: (windows: BudgetWindowEntry[]) => void;
defaultWindows?: BudgetWindowEntry[];
}
export function MemberBudgetWindows({ value, onChange, defaultWindows }: MemberBudgetWindowsProps) {
const id = useId();
const inherits = defaultWindows !== undefined && value.length === 0;
const selectMode = (selection: unknown) => {
if (selection === "default") {
onChange([]);
return;
}
if (defaultWindows?.length) {
onChange(defaultWindows.map((window) => ({ ...window })));
return;
}
onChange([{ budget_duration: "24h", max_budget: null }]);
};
return (
<div className="space-y-3">
{defaultWindows !== undefined && (
<RadioGroup
aria-label="Member budget windows"
value={inherits ? "default" : "custom"}
onValueChange={selectMode}
>
<div className="flex items-center gap-2">
<RadioGroupItem id={`${id}-default`} value="default" />
<Label htmlFor={`${id}-default`}>Use team defaults</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id={`${id}-custom`} value="custom" />
<Label htmlFor={`${id}-custom`}>Custom budget windows</Label>
</div>
</RadioGroup>
)}
{inherits ? (
<p className="text-sm text-muted-foreground">
{defaultWindows.length
? defaultWindows
.map((window) => `$${window.max_budget} ${getBudgetDurationLabel(window.budget_duration)}`)
.join(", ")
: "No default budget windows set"}
</p>
) : (
<BudgetWindowsEditor value={value} onChange={onChange} />
)}
<p className="text-sm text-muted-foreground">
{defaultWindows === undefined
? "Each window limits one member's spend in this team. All windows apply together."
: "Custom windows replace the team defaults for this member. Existing single-budget limits also apply."}
</p>
</div>
);
}

View file

@ -415,6 +415,28 @@ describe("TeamInfoView - member budget reset prompt", () => {
return await screen.findByLabelText("Default Budget (USD)");
};
it("should submit cleared default member windows after their section is collapsed", async () => {
const user = userEvent.setup({ delay: null });
const data = createMockTeamData({
team_member_budget_limits: [
{ budget_duration: "24h", max_budget: 20 },
{ budget_duration: "30d", max_budget: 200 },
],
});
vi.mocked(networking.teamInfoCall).mockResolvedValue(data);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: savedTeam, team_id: "123" });
renderWithProviders(<TeamInfoView {...props} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await user.click(screen.getByText("Team Member Settings"));
await user.click(screen.getByRole("button", { name: "Remove budget window 2" }));
await user.click(screen.getByRole("button", { name: "Remove budget window 1" }));
await user.click(screen.getByText("Team Member Settings"));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget_limits).toEqual([]);
});
const submitNewDefault = async (user: ReturnType<typeof userEvent.setup>, input: HTMLElement, value: string) => {
fireEvent.change(input, { target: { value } });
await user.click(screen.getByRole("button", { name: /save changes/i }));

View file

@ -1,3 +1,7 @@
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
import { MemberBudgetWindows } from "./MemberBudgetWindows";
import { memberBudgetWindowsSchema, type MemberBudgetWindow } from "./memberBudgetWindowSchema";
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";
import useCan from "@/app/(dashboard)/hooks/useCan";
@ -277,6 +281,7 @@ export const mcpUnresolvableSaveError = (reason: string): string =>
export type TeamMemberBudgetSource = components["schemas"]["TeamMemberResetBudgetResponse"]["budget_source"];
export interface TeamMembership {
budget_limits?: BudgetWindowEntry[] | null;
user_id: string;
team_id: string;
budget_id: string | null;
@ -302,6 +307,7 @@ export interface TeamMembership {
export interface TeamData {
team_id: string;
team_info: {
team_member_budget_limits?: MemberBudgetWindow[] | null;
team_alias: string;
team_id: string;
organization_id: string | null;
@ -376,6 +382,7 @@ const teamUpdateFieldsSchema = z.object({
soft_budget_alerting_emails: z.union([z.string(), z.array(z.string())]).optional(),
default_team_member_models: z.array(z.string()).optional(),
team_member_budget: numericInputSchema,
team_member_budget_limits: memberBudgetWindowsSchema.optional(),
team_member_budget_duration: z.string().nullish(),
team_member_key_duration: z.string().optional(),
team_member_tpm_limit: numericInputSchema,
@ -472,6 +479,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
soft_budget_alerting_emails: "",
default_team_member_models: [],
team_member_budget: undefined,
team_member_budget_limits: [],
team_member_budget_duration: undefined,
team_member_key_duration: undefined,
team_member_tpm_limit: undefined,
@ -524,6 +532,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
: "",
default_team_member_models: info.default_team_member_models || [],
team_member_budget: info.team_member_budget_table?.max_budget,
team_member_budget_limits: info.team_member_budget_limits ?? [],
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
team_member_key_duration: info.metadata?.team_member_key_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
@ -828,6 +837,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
user_email: values.user_email,
user_id: values.user_id,
role: values.role,
budget_limits: values.budget_limits ?? [],
};
await teamMemberAddCall(accessToken, teamId, member);
@ -867,6 +877,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
user_email: values.user_email,
user_id: values.user_id,
role: values.role,
budget_limits: values.budget_limits ?? [],
max_budget_in_team: values.max_budget_in_team,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
@ -1101,6 +1112,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
};
updateData.team_member_budget_duration = values.team_member_budget_duration;
if (
values.team_member_budget_limits !== undefined &&
(form.getFieldState("team_member_budget_limits").isDirty || info.team_member_budget_limits != null)
) {
updateData.team_member_budget_limits = values.team_member_budget_limits;
}
const newTeamMemberBudget =
values.team_member_budget !== undefined ? numberOrNull(values.team_member_budget) : undefined;
@ -1689,6 +1706,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
)}
</FormField>
<FormField
control={form.control}
name="team_member_budget_limits"
label="Default member budget windows"
>
{({ value, onChange }) => <MemberBudgetWindows value={value ?? []} onChange={onChange} />}
</FormField>
<FormField
control={form.control}
name="team_member_budget"
@ -2413,6 +2437,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</SimpleTooltip>
</p>
<div>
Default member budget windows:{" "}
{(info.team_member_budget_limits ?? [])
.map((window) => `$${window.max_budget} ${getBudgetDurationLabel(window.budget_duration)}`)
.join(", ") || "Not set"}
</div>
<div>Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}</div>
<div>Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}</div>
{modelMaxBudgetToEntries(
@ -2571,6 +2601,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
mode="edit"
config={{
title: "Edit Member",
showBudgetWindows: true,
defaultBudgetWindows: info.team_member_budget_limits ?? [],
showEmail: true,
showUserId: true,
roleOptions: [
@ -2700,6 +2732,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
onSubmit={handleMemberCreate}
accessToken={accessToken}
teamId={teamId}
defaultBudgetWindows={info.team_member_budget_limits ?? []}
/>
{/* Delete Member Confirmation Modal */}

View file

@ -158,6 +158,37 @@ describe("TeamMembersComponent", () => {
expect(screen.getByRole("columnheader", { name: /actions/i })).toBeInTheDocument();
});
it("should show member overrides separately from inherited team windows", () => {
const original = createMockTeamData();
const teamData: TeamData = {
...original,
team_info: {
...original.team_info,
team_member_budget_limits: [{ budget_duration: "30d", max_budget: 200 }],
},
team_memberships: original.team_memberships.map((membership) => ({
...membership,
budget_limits: [{ budget_duration: "24h", max_budget: 5 }],
})),
};
renderWithProviders(
<TeamMembersComponent
teamData={teamData}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
/>,
);
expect(screen.getByText("$5.00 daily")).toBeInTheDocument();
expect(screen.getByText("Member override")).toBeInTheDocument();
expect(screen.getByText("$200.00 monthly")).toBeInTheDocument();
expect(screen.getByText("Team default")).toBeInTheDocument();
});
it("should render team members data", () => {
renderWithProviders(
<TeamMembersComponent

View file

@ -18,6 +18,8 @@ import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CircleHelp } from "lucide-react";
import { useMemo, useState, type ComponentProps } from "react";
import { TeamData, TeamMemberBudgetSource, TeamMembership } from "./TeamInfo";
import { effectiveMemberBudgetWindows } from "./memberBudgetWindowSchema";
import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
const BUDGET_SOURCE_LABELS: Record<Exclude<TeamMemberBudgetSource, "none">, string> = {
team_default: "Team default",
@ -168,6 +170,25 @@ export default function TeamMemberTab({
};
const extraColumns: NonNullable<ComponentProps<typeof MemberTable>["extraColumns"]> = [
{
title: "Budget windows",
key: "budget_windows",
render: (record: Member) => {
const override = findMembership(record.user_id)?.budget_limits;
const windows = effectiveMemberBudgetWindows(teamData.team_info.team_member_budget_limits, override);
if (!windows.length) return <span className="text-muted-foreground">Not set</span>;
return (
<div className="space-y-1">
{windows.map((window) => (
<div key={window.budget_duration}>
${formatNumberWithCommas(window.max_budget ?? 0, 2)} {getBudgetDurationLabel(window.budget_duration)}
</div>
))}
<div className="text-xs text-muted-foreground">{override?.length ? "Member override" : "Team default"}</div>
</div>
);
},
},
{
title: (
<span className="flex items-center gap-1">
@ -334,7 +355,7 @@ export default function TeamMemberTab({
const membership = findMembership(record.user_id);
setSelectedEditMember(
seedMemberBudgetFields(
record,
{ ...record, budget_limits: membership?.budget_limits ?? [] },
membership?.litellm_budget_table,
teamData.team_info.team_member_budget_table?.model_max_budget,
),

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { effectiveMemberBudgetWindows, memberBudgetWindowsSchema } from "./memberBudgetWindowSchema";
const windows = [
{ budget_duration: "24h", max_budget: 20 },
{ budget_duration: "30d", max_budget: 200 },
];
describe("member budget windows", () => {
it("should accept simultaneous daily and monthly limits and preserve zero-dollar caps", () => {
expect(memberBudgetWindowsSchema.parse(windows)).toEqual(windows);
expect(memberBudgetWindowsSchema.parse([{ ...windows[0], max_budget: 0 }])).toEqual([
{ ...windows[0], max_budget: 0 },
]);
});
it.each([null, -1, Infinity, NaN])("should reject an invalid budget of %s", (max_budget) => {
expect(memberBudgetWindowsSchema.safeParse([{ ...windows[0], max_budget }]).success).toBe(false);
});
it("should reject duplicate and unsupported durations", () => {
expect(memberBudgetWindowsSchema.safeParse([windows[0], windows[0]]).success).toBe(false);
expect(memberBudgetWindowsSchema.safeParse([{ budget_duration: "tomorrow", max_budget: 20 }]).success).toBe(false);
});
it("should inherit defaults when no override is configured or an override is cleared", () => {
expect(effectiveMemberBudgetWindows(windows, undefined)).toEqual(windows);
expect(effectiveMemberBudgetWindows(windows, [])).toEqual(windows);
expect(effectiveMemberBudgetWindows(undefined, undefined)).toEqual([]);
});
it("should replace all default windows with the member override", () => {
const override = [{ budget_duration: "24h", max_budget: 5 }];
expect(effectiveMemberBudgetWindows(windows, override)).toEqual(override);
});
});

View file

@ -0,0 +1,35 @@
import { z } from "zod";
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
const budgetWindowEntriesSchema = z.array(
z.object({
budget_duration: z.enum(["1h", "24h", "7d", "30d"]),
max_budget: z.number().nullable(),
}),
);
export const memberBudgetWindowsSchema = budgetWindowEntriesSchema.superRefine((windows, context) => {
windows.forEach((window, index) => {
if (window.max_budget === null) {
context.addIssue({ code: "custom", message: "Enter a budget" });
} else if (window.max_budget < 0) {
context.addIssue({ code: "custom", message: "Enter a non-negative budget" });
}
if (windows.slice(0, index).some((other) => other.budget_duration === window.budget_duration)) {
context.addIssue({
code: "custom",
message: "Use each budget duration only once",
});
}
});
});
export const isMemberBudgetWindows = (value: unknown): value is BudgetWindowEntry[] =>
budgetWindowEntriesSchema.safeParse(value).success;
export type MemberBudgetWindow = z.infer<typeof memberBudgetWindowsSchema>[number];
export const effectiveMemberBudgetWindows = (
defaults: BudgetWindowEntry[] | null | undefined,
override: BudgetWindowEntry[] | null | undefined,
): BudgetWindowEntry[] => (override?.length ? override : defaults ?? []);

View file

@ -3,8 +3,10 @@ import {
modelMaxBudgetToEntries,
type ModelMaxBudget,
} from "../key_team_helpers/ModelMaxBudgetEditor";
import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
import { isMemberBudgetWindows } from "./memberBudgetWindowSchema";
export type MemberFieldValue = string | number | null | undefined | string[] | ModelMaxBudget;
export type MemberFieldValue = string | number | null | undefined | string[] | ModelMaxBudget | BudgetWindowEntry[];
export type MemberFormValues = Record<string, MemberFieldValue>;
@ -30,6 +32,8 @@ export interface MemberAdditionalField {
}
export interface MemberFieldsConfig {
showBudgetWindows?: boolean;
defaultBudgetWindows?: BudgetWindowEntry[];
roleOptions: Array<{ label: string; value: string }>;
defaultRole?: string;
showEmail?: boolean;
@ -62,6 +66,7 @@ export const memberFieldNames = (config: MemberFieldsConfig): string[] => [
...(config.showEmail ? ["user_email"] : []),
...(config.showUserId ? ["user_id"] : []),
"role",
...(config.showBudgetWindows ? ["budget_limits"] : []),
...(config.additionalFields ?? []).map((field) => field.name),
];
@ -76,6 +81,7 @@ export const buildMemberFormValues = (
if (mode === "edit" && initialData) {
const seeded: MemberFormValues = {
...initialData,
budget_limits: isMemberBudgetWindows(initialData.budget_limits) ? initialData.budget_limits : [],
role: (initialData.role as string) || config.defaultRole,
max_budget_in_team: initialData.max_budget_in_team ?? null,
tpm_limit: initialData.tpm_limit ?? null,
@ -92,7 +98,7 @@ export const buildMemberFormValues = (
return pickFieldNames(config, seeded);
}
return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value });
return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value, budget_limits: [] });
};
const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue => {
@ -112,7 +118,12 @@ const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue
export const emptyMemberFormValues = (config: MemberFieldsConfig): MemberFormValues => {
const typeByName = new Map((config.additionalFields ?? []).map((field) => [field.name, field.type]));
return Object.fromEntries(memberFieldNames(config).map((name) => [name, emptyValueForType(typeByName.get(name))]));
return Object.fromEntries(
memberFieldNames(config).map((name) => [
name,
name === "budget_limits" ? [] : emptyValueForType(typeByName.get(name)),
]),
);
};
export const buildMemberFormData = (values: MemberFormValues): MemberFormValues =>