From b8e2192f4d1973519d8eac2f2d04b72780144110 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:32:09 -0700 Subject: [PATCH] fix(ui): send the key's organization only when the edit form changes it An untouched save from the key edit form now leaves organization_id out of the /key/update body, the same way the form already drops unchanged gated fields. This stops a save from writing a team's fallback organization onto a key that has none, and keeps the audit log free of no-op organization updates. The organization tests now use typed inputs --- .../KeyInfoView.handleKeyUpdate.test.tsx | 69 ++++++++++++------- .../components/templates/key_info_view.tsx | 4 ++ 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 16fd3d38bd8..9e48c4e3aba 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "@/lib/toast"; +import type { KeyResponse } from "../key_team_helpers/key_list"; vi.mock("@/lib/toast", () => ({ toast: { error: vi.fn(), success: vi.fn() }, @@ -657,40 +658,58 @@ describe("KeyInfoView handleKeyUpdate soft_budget", () => { }); describe("KeyInfoView handleKeyUpdate organization", () => { - it("should show no organization after a save clears the one a key list row carried as org_id", async () => { - const adminAuth = { - accessToken: "access_abc", - userId: "user_1", - userRole: "Admin", - premiumUser: true, - token: "token_123", - userEmail: "test@example.com", - disabledPersonalKeyCreation: false, - showSSOBanner: false, - }; - keyUpdateCallMock.mockResolvedValueOnce({ ...baseKeyData, organization_id: null }); + const keyListRow = { ...baseKeyData, org_id: "org-1" } as KeyResponse; + + const adminAuth = { + accessToken: "access_abc", + userId: "user_1", + userRole: "Admin", + premiumUser: true, + token: "token_123", + userEmail: "test@example.com", + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }; + + const renderKeyListRowSettings = () => { mockUseAuthorized.mockReturnValue(adminAuth); - render( - {}} - keyData={{ ...baseKeyData, org_id: "org-1" } as any} - onKeyDataUpdate={() => {}} - teams={[]} - />, + {}} keyData={keyListRow} onKeyDataUpdate={() => {}} teams={[]} />, ); - fireEvent.click(screen.getByText("Settings")); - expect(screen.getByText("Organization", { selector: "p" }).nextElementSibling).toHaveTextContent("org-1"); + }; + const submitEditForm = (formValues: Record) => { fireEvent.click(screen.getByText("Edit Settings")); - (globalThis as any).__TEST_FORM_VALUES = { token: "tok_123", organization_id: null }; - + Object.assign(globalThis, { __TEST_FORM_VALUES: formValues }); fireEvent.click(screen.getByText("Mock Submit")); + }; + + const shownOrganization = () => screen.getByText("Organization", { selector: "p" }).nextElementSibling; + + it("should show no organization after a save clears the one a key list row carried as org_id", async () => { + keyUpdateCallMock.mockResolvedValueOnce({ ...baseKeyData, organization_id: null }); + renderKeyListRowSettings(); + expect(shownOrganization()).toHaveTextContent("org-1"); + + submitEditForm({ token: "tok_123", organization_id: null }); await waitFor(() => { - expect(screen.getByText("Organization", { selector: "p" }).nextElementSibling).toHaveTextContent("Not Set"); + expect(shownOrganization()).toHaveTextContent("Not Set"); }); + expect(keyUpdateCallMock.mock.calls[0][1]).toHaveProperty("organization_id", null); + }); + + it("should leave organization_id out of a save that keeps the key's organization", async () => { + keyUpdateCallMock.mockResolvedValueOnce({ ...baseKeyData, organization_id: "org-1" }); + renderKeyListRowSettings(); + + submitEditForm({ token: "tok_123", organization_id: "org-1" }); + + await waitFor(() => { + expect(keyUpdateCallMock).toHaveBeenCalledTimes(1); + }); + expect(keyUpdateCallMock.mock.calls[0][1]).not.toHaveProperty("organization_id"); + expect(shownOrganization()).toHaveTextContent("org-1"); }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index d83971b8810..523c14d378d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -240,6 +240,10 @@ export default function KeyInfoView({ delete formValues.disable_global_guardrails; } + if ((formValues.organization_id || null) === keyOrganizationId(currentKeyData)) { + delete formValues.organization_id; + } + // Handle max budget empty string formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);