From 5e91bc8e86b0481500648d9fd5d6778b614a6717 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Nov 2026 18:02:18 -0800 Subject: [PATCH] fix(ui): send a team admin's member key edits on the first save (#50007) The key edit form read react-hook-form's dirtyFields only inside the submit handler. formState is a proxy that only tracks what the component reads while rendering, so on the first save of an edit session that read returned the initial empty object. A team admin editing a member's key then sent only the key id to /key/update, got a 200 and a "Key updated successfully" toast, and nothing changed: a max_budget edit was silently dropped, and a rename went through as an empty update instead of the "team admins can only change budget fields" error. Read dirtyFields during render so the form subscribes to it and the submit handler sees the fields the user actually changed. --- .../key_edit_view.integration.test.tsx | 55 +++++++++++++++++++ .../components/templates/key_edit_view.tsx | 3 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index dce14adc2e5..d590d7beef9 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -15,6 +15,7 @@ import { vectorStoreListCall, } from "../networking"; import { KeyEditView } from "./key_edit_view"; +import { teamAdminMemberKeyPayload } from "./teamAdminMemberKeyPayload"; const can = vi.fn(); vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ @@ -3234,4 +3235,58 @@ describe("KeyEditView", () => { expect(await screen.findByRole("switch", { name: /disable global guardrails/i })).toBeInTheDocument(); }); }); + + describe("team admin editing a member's key", () => { + const renderAsTeamAdmin = ( + onSubmit: (values: Record, dirtyFields: readonly string[]) => Promise, + ) => + renderWithProviders( + {}} + onSubmit={onSubmit} + accessToken="test-token" + userID="team-admin-user" + userRole="Internal User" + />, + ); + + const firstSavePayload = async (onSubmit: ReturnType) => { + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const [values, dirtyFields] = onSubmit.mock.calls[0]; + return teamAdminMemberKeyPayload({ ...values, key: values.token }, dirtyFields); + }; + + it("should block a rename on the first save instead of sending an empty update", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAsTeamAdmin(onSubmit); + + fireEvent.change(await screen.findByRole("textbox", { name: "Key Alias" }), { target: { value: "renamed" } }); + + expect(await firstSavePayload(onSubmit)).toEqual({ kind: "blocked", fields: ["key_alias"] }); + }); + + it("should send a budget change on the first save", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAsTeamAdmin(onSubmit); + + fireEvent.change(await screen.findByRole("spinbutton", { name: "Max Budget (USD)" }), { + target: { value: "25" }, + }); + + expect(await firstSavePayload(onSubmit)).toEqual({ + kind: "ok", + payload: { key: MOCK_KEY_DATA.token, max_budget: "25" }, + }); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 81464d0d195..940108f9721 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -116,6 +116,7 @@ function KeyEditSession({ values: toKeyEditFormValues(keyData), resetOptions: { keepDirtyValues: true }, }); + const { dirtyFields } = form.formState; const [promptsList, setPromptsList] = useState([]); const [tagsList, setTagsList] = useState>({}); const team = teams?.find((team) => team.team_id === keyData.team_id); @@ -321,7 +322,7 @@ function KeyEditSession({ ...values, ...(detachProject && enableProjectsUI && canDetachProject ? { project_id: null } : {}), }), - [...Object.keys(form.formState.dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])], + [...Object.keys(dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])], ); } finally { setIsKeySaving(false);