fix(ui): allow clearing a key's budget reset from the Edit Key form (#36140)

This commit is contained in:
ryan-crabbe-berri 2026-08-06 18:46:28 -07:00 committed by GitHub
parent a79d9bacbf
commit 429a5dc430
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 135 additions and 5 deletions

View file

@ -236,7 +236,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
editContent={
<BudgetDurationDropdown
value={editedValues.budget_duration || null}
onChange={(v) => update("budget_duration", v)}
onChange={(v) => update("budget_duration", v ?? null)}
style={{ maxWidth: 320 }}
/>
}

View file

@ -5,9 +5,10 @@ const { Option } = Select;
interface BudgetDurationDropdownProps {
value?: string | null;
onChange?: (value: string) => void;
onChange?: (value: string | undefined) => void;
className?: string;
style?: React.CSSProperties;
placeholder?: string;
}
const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
@ -15,6 +16,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
onChange,
className = "",
style = {},
placeholder = "n/a",
}) => {
return (
<Select
@ -22,7 +24,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
value={value || undefined}
onChange={onChange}
className={className}
placeholder="n/a"
placeholder={placeholder}
allowClear
>
<Option value="1h">hourly</Option>

View file

@ -1060,7 +1060,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
name="budget_duration"
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
<BudgetDurationDropdown
placeholder="Never resets"
onChange={(value) => form.setFieldValue("budget_duration", value)}
/>
</Form.Item>
<Form.Item
className="mt-4"

View file

@ -507,6 +507,68 @@ describe("KeyInfoView handleKeyUpdate budget_duration", () => {
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
expect(sentPayload.budget_duration).toBe("30d");
});
it("should forward a cleared budget_duration as an explicit null the JSON body keeps", async () => {
renderView(true);
fireEvent.click(screen.getByText("Settings"));
fireEvent.click(screen.getByText("Edit Settings"));
(globalThis as any).__TEST_FORM_VALUES = {
token: "tok_123",
budget_duration: null,
};
fireEvent.click(screen.getByText("Mock Submit"));
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
expect(sentPayload.budget_duration).toBeNull();
expect(JSON.stringify({ ...sentPayload })).toContain('"budget_duration":null');
});
it("should render the cleared budget as never resetting instead of snapping back to the old interval", async () => {
keyUpdateCallMock.mockResolvedValueOnce({
...baseKeyData,
budget_duration: null,
budget_reset_at: null,
});
mockUseAuthorized.mockReturnValue({
accessToken: "access_abc",
userId: "user_1",
userRole: "Admin",
premiumUser: true,
token: "token_123",
userEmail: "test@example.com",
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
render(
<KeyInfoView
keyId="tok_123"
onClose={() => {}}
keyData={{ ...baseKeyData, budget_duration: "30d", budget_reset_at: "2026-09-01T00:00:00Z" } as any}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
fireEvent.click(screen.getByText("Settings"));
expect(screen.getByText("Budget Reset").parentElement?.textContent).toContain("Every 30d");
fireEvent.click(screen.getByText("Edit Settings"));
(globalThis as any).__TEST_FORM_VALUES = {
token: "tok_123",
budget_duration: null,
};
fireEvent.click(screen.getByText("Mock Submit"));
await waitFor(() => {
expect(screen.getByText("Budget Reset").parentElement?.textContent).toBe("Budget ResetNever");
});
});
});
describe("KeyInfoView handleKeyUpdate empty strings", () => {

View file

@ -777,6 +777,65 @@ describe("KeyEditView", () => {
});
});
it("should send an explicit null budget_duration when a previously-set Reset Budget is cleared", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
const clearIcon = resetBudgetItem.querySelector(".ant-select-clear");
expect(clearIcon).not.toBeNull();
fireEvent.mouseDown(clearIcon as Element);
await waitFor(() => {
expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
const callArgs = onSubmitMock.mock.calls[0][0];
expect(callArgs.budget_duration).toBeNull();
expect(JSON.stringify({ ...callArgs })).toContain('"budget_duration":null');
});
it("should send an explicit null budget_duration when a legacy word-form Reset Budget is cleared", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const legacyKeyData = { ...MOCK_KEY_DATA, budget_duration: "monthly" };
renderWithProviders(
<KeyEditView
keyData={legacyKeyData}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element);
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
});
expect(onSubmitMock.mock.calls[0][0].budget_duration).toBeNull();
});
it("should omit budget_limits when existing windows are left untouched (issue #33246)", async () => {
// The backend treats any budget_limits in the payload as an admin-only
// budget change, so re-sending untouched windows 403s a non-admin owner.

View file

@ -294,6 +294,10 @@ export function KeyEditView({
values.duration = null;
}
if (keyData.budget_duration && !values.budget_duration) {
values.budget_duration = null;
}
// Reconcile multi-window budget limits from the editor state, dropping
// incomplete entries (no max_budget). The backend treats any budget_limits
// in a /key/update request as an admin-only budget change, so re-sending
@ -494,7 +498,7 @@ export function KeyEditView({
</Form.Item>
<Form.Item label="Reset Budget" name="budget_duration">
<BudgetDurationDropdown />
<BudgetDurationDropdown placeholder="Never resets" />
</Form.Item>
<Form.Item