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.
This commit is contained in:
yuneng-jiang 2026-11-04 18:02:18 -08:00 • committed by GitHub
parent 906fa7ed28
commit 5e91bc8e86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 57 additions and 1 deletions

View file

@ -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<string, unknown>, dirtyFields: readonly string[]) => Promise<void>,
) =>
renderWithProviders(
<KeyEditView
keyData={{ ...MOCK_KEY_DATA, team_id: "team-1", user_id: "member-user" }}
teams={[
{
team_id: "team-1",
team_alias: "Team One",
models: [],
members_with_roles: [{ user_id: "team-admin-user", role: "admin" }],
},
]}
onCancel={() => {}}
onSubmit={onSubmit}
accessToken="test-token"
userID="team-admin-user"
userRole="Internal User"
/>,
);
const firstSavePayload = async (onSubmit: ReturnType<typeof vi.fn>) => {
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" },
});
});
});
});

View file

@ -116,6 +116,7 @@ function KeyEditSession({
values: toKeyEditFormValues(keyData),
resetOptions: { keepDirtyValues: true },
});
const { dirtyFields } = form.formState;
const [promptsList, setPromptsList] = useState<string[]>([]);
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
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);