From e8d827a191593ffea6455c9f4abcc521bbb78256 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:44:39 -0700 Subject: [PATCH 1/7] fix(ui): prefill the key edit Organization picker from org_id The Virtual Keys table hands KeyEditView rows from /key/list, which serializes the organization as org_id, while /key/info calls the same field organization_id. The edit form read only organization_id, so on a key opened from the list the Organization picker fell back to its "All Organizations" placeholder even though the key belongs to an org. Read both field names through one keyOrganizationId helper, used by the form value mapper, the edit view's selected organization state, and the two places in key_info_view that already hand-rolled the same fallback. --- .../templates/keyEditFieldNormalizers.ts | 3 ++ .../components/templates/keyEditFormValues.ts | 4 +-- .../templates/key_edit_view.test.tsx | 33 +++++++++++++++++++ .../components/templates/key_edit_view.tsx | 3 +- .../components/templates/key_info_view.tsx | 5 +-- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts index c8b8a689dae..dc16f5ad34d 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts @@ -9,6 +9,9 @@ const WORD_FORM_BUDGET_DURATIONS: Record = { export const canonicalBudgetDuration = (duration: string | null | undefined): string | null => duration ? WORD_FORM_BUDGET_DURATIONS[duration] ?? duration : null; +export const keyOrganizationId = (key: { organization_id?: string | null; org_id?: string | null }): string | null => + key.organization_id || key.org_id || null; + // Determine the key_type display value from allowed_routes export const keyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): string => { if (!allowedRoutes || allowedRoutes.length === 0) return "default"; diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts index ad90610b732..130d42b0a02 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts @@ -4,7 +4,7 @@ import { KeyResponse } from "../key_team_helpers/key_list"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; import { mapInternalToDisplayNames } from "../callback_info_helpers"; import { estimateChecks, estimateFields } from "./estimatedOutputTokens"; -import { canonicalBudgetDuration } from "./keyEditFieldNormalizers"; +import { canonicalBudgetDuration, keyOrganizationId } from "./keyEditFieldNormalizers"; export interface McpServersAndGroups { servers: string[]; @@ -99,7 +99,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues => agents: keyData.object_permission?.agents || [], accessGroups: keyData.object_permission?.agent_access_groups || [], }, - organization_id: keyData.organization_id, + organization_id: keyOrganizationId(keyData), team_id: keyData.team_id, logging_settings: extractLoggingSettings(keyData.metadata), metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)), diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 97b09e00808..dfa3e8b2bca 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1435,6 +1435,39 @@ describe("KeyEditView", () => { expect(await screen.findByText("Engineering")).toBeInTheDocument(); }); + it("should initialize organization from a key list row that only carries org_id", async () => { + const keyFromList = { + ...MOCK_KEY_DATA, + organization_id: null, + org_id: "org-1", + }; + + renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Engineering"); + }); + + await userEvent.click(screen.getByLabelText("Team ID")); + + expect(await screen.findByRole("option", { name: /Alpha/ })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /Beta/ })).not.toBeInTheDocument(); + }); + it("should initialize organization from keyData", async () => { const keyWithOrg = { ...MOCK_KEY_DATA, 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 df1af2ca8e9..e925eac5380 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -28,6 +28,7 @@ import { routerSettingsEditorValue, routerSettingsUpdate } from "../common_compo import { estimateTooltips, withNormalizedEstimates } from "./estimatedOutputTokens"; import { currentValuePlaceholder, + keyOrganizationId, keyTypeFromRoutes, modelSentinelOptions, parseAllowedRoutes, @@ -105,7 +106,7 @@ export function KeyEditView({ ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) : [], ); - const [selectedOrganizationId, setSelectedOrganizationId] = useState(keyData.organization_id || null); + const [selectedOrganizationId, setSelectedOrganizationId] = useState(keyOrganizationId(keyData)); const [autoRotationEnabled, setAutoRotationEnabled] = useState(keyData.auto_rotate || false); const [rotationInterval, setRotationInterval] = useState(keyData.rotation_interval || ""); const [neverExpire, setNeverExpire] = useState(!keyData.expires); 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 0dd0dd6d6af..dce2e076cec 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -40,6 +40,7 @@ import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal"; import { parseErrorMessage } from "../shared/errorUtils"; import { InheritedBudgetHint, inheritedBudgetGates } from "../shared/InheritedBudgetHint"; import { KeyEditView } from "./key_edit_view"; +import { keyOrganizationId } from "./keyEditFieldNormalizers"; interface KeyInfoViewProps { keyId: string; @@ -468,7 +469,7 @@ export default function KeyInfoView({ const lastConfiguredAt = currentKeyData.settings_updated_at || currentKeyData.created_at; const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null; - const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || ""; + const orgId = keyOrganizationId(currentKeyData) || parentTeam?.organization_id || ""; const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null; const hasOwnBudget = currentKeyData.max_budget !== null; @@ -835,7 +836,7 @@ export default function KeyInfoView({

Organization

-

{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}

+

{keyOrganizationId(currentKeyData) || "Not Set"}

From ce8179b2c0f130eb8ab6c025be56f1f1d7ee827b Mon Sep 17 00:00:00 2001 From: mateo-berri Date: Thu, 3 Sep 2026 03:04:33 -0700 Subject: [PATCH 2/7] fix(ui): keep the key edit team filter in sync with the organization field --- .../templates/key_edit_view.test.tsx | 34 +++++++++++++++++++ .../components/templates/key_edit_view.tsx | 4 +-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index dfa3e8b2bca..feb777f32a9 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1468,6 +1468,40 @@ describe("KeyEditView", () => { expect(screen.queryByRole("option", { name: /Beta/ })).not.toBeInTheDocument(); }); + it("should re-scope teams when the edited key switches to another organization", async () => { + const sharedProps = { + teams: [ + { team_id: "team-a", team_alias: "Alpha", organization_id: "org-1" }, + { team_id: "team-b", team_alias: "Beta", organization_id: "org-2" }, + ], + onCancel: () => {}, + onSubmit: async () => {}, + accessToken: "", + userID: "", + userRole: "Admin", + premiumUser: false, + }; + + const { rerender } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Engineering"); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Sales"); + }); + + await userEvent.click(screen.getByLabelText("Team ID")); + + expect(await screen.findByRole("option", { name: /Beta/ })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /Alpha/ })).not.toBeInTheDocument(); + }); + it("should initialize organization from keyData", async () => { const keyWithOrg = { ...MOCK_KEY_DATA, 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 e925eac5380..3242aee49bc 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -28,7 +28,6 @@ import { routerSettingsEditorValue, routerSettingsUpdate } from "../common_compo import { estimateTooltips, withNormalizedEstimates } from "./estimatedOutputTokens"; import { currentValuePlaceholder, - keyOrganizationId, keyTypeFromRoutes, modelSentinelOptions, parseAllowedRoutes, @@ -106,7 +105,6 @@ export function KeyEditView({ ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) : [], ); - const [selectedOrganizationId, setSelectedOrganizationId] = useState(keyOrganizationId(keyData)); const [autoRotationEnabled, setAutoRotationEnabled] = useState(keyData.auto_rotate || false); const [rotationInterval, setRotationInterval] = useState(keyData.rotation_interval || ""); const [neverExpire, setNeverExpire] = useState(!keyData.expires); @@ -135,6 +133,7 @@ export function KeyEditView({ return project?.project_alias ? `${project.project_alias} (${keyData.project_id})` : keyData.project_id; })(); + const selectedOrganizationId = form.watch("organization_id") ?? null; const allowedRoutesValue = form.watch("allowed_routes"); const selectedModels = (form.watch("models") as string[] | undefined) ?? []; const allowedRoutes = parseAllowedRoutes(allowedRoutesValue); @@ -306,7 +305,6 @@ export function KeyEditView({ const handleOrganizationChange = (setField: (value: string | undefined) => void, orgId: string | undefined) => { setField(orgId); - setSelectedOrganizationId(orgId || null); form.setValue("team_id", undefined); }; From 2223084ce64d19d307a1645fc6109725a69285d4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:15:47 -0700 Subject: [PATCH 3/7] fix(ui): drop the leftover organization setter calls in the key edit team handler The previous commit derived the organization from the form and removed the useState behind it, but handleTeamChange still called the deleted setter, so the dashboard build failed to type check and picking a team with an organization threw at runtime. The form.setValue calls next to them already carry the organization, so the setter calls only had to go. Adds a test that picks a team with an organization and asserts the form adopts and submits it. --- .../templates/key_edit_view.test.tsx | 37 +++++++++++++++++++ .../components/templates/key_edit_view.tsx | 2 - 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index feb777f32a9..8c3ae003e81 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1502,6 +1502,43 @@ describe("KeyEditView", () => { expect(screen.queryByRole("option", { name: /Alpha/ })).not.toBeInTheDocument(); }); + it("should adopt the organization of a team picked in the form", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await screen.findByRole("button", { name: /save changes/i }); + expect(screen.getByLabelText("Organization")).toHaveValue(""); + + await userEvent.click(screen.getByLabelText("Team ID")); + await userEvent.click(await screen.findByRole("option", { name: /Beta/ })); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Sales"); + }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + expect(onSubmitMock.mock.calls[0][0].organization_id).toBe("org-2"); + }); + it("should initialize organization from keyData", async () => { const keyWithOrg = { ...MOCK_KEY_DATA, 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 3242aee49bc..1a01375fca4 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -312,10 +312,8 @@ export function KeyEditView({ setField(teamId); const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; if (selectedTeam?.organization_id) { - setSelectedOrganizationId(selectedTeam.organization_id); form.setValue("organization_id", selectedTeam.organization_id); } else if (!teamId) { - setSelectedOrganizationId(null); form.setValue("organization_id", undefined); } }; From d712b2991477dd79b407f07029c7b21fd6cf549a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:59 -0700 Subject: [PATCH 4/7] fix(ui): drop the list row's stale org_id when a key save returns its organization --- .../KeyInfoView.handleKeyUpdate.test.tsx | 39 +++++++++++++++++++ .../components/templates/key_info_view.tsx | 4 +- 2 files changed, 42 insertions(+), 1 deletion(-) 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 c82a4823dfd..16fd3d38bd8 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -655,3 +655,42 @@ describe("KeyInfoView handleKeyUpdate soft_budget", () => { expect(keyUpdateCallMock).not.toHaveBeenCalled(); }); }); + +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 }); + mockUseAuthorized.mockReturnValue(adminAuth); + + render( + {}} + keyData={{ ...baseKeyData, org_id: "org-1" } as any} + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + + fireEvent.click(screen.getByText("Settings")); + expect(screen.getByText("Organization", { selector: "p" }).nextElementSibling).toHaveTextContent("org-1"); + + fireEvent.click(screen.getByText("Edit Settings")); + (globalThis as any).__TEST_FORM_VALUES = { token: "tok_123", organization_id: null }; + + fireEvent.click(screen.getByText("Mock Submit")); + + await waitFor(() => { + expect(screen.getByText("Organization", { selector: "p" }).nextElementSibling).toHaveTextContent("Not Set"); + }); + }); +}); 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 5fc57a16869..d83971b8810 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -398,7 +398,9 @@ export default function KeyInfoView({ const newKeyValues = await keyUpdateCall(accessToken, formValues); - setCurrentKeyData((prevData) => (prevData ? { ...prevData, ...newKeyValues } : undefined)); + setCurrentKeyData((prevData) => + prevData ? { ...prevData, ...newKeyValues, org_id: newKeyValues.organization_id } : undefined, + ); void queryClient.invalidateQueries({ queryKey: keyKeys.all }); if (onKeyDataUpdate) { 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 5/7] 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); From 2a5f0e7749c8e40b4d8c26f5a22ad280f5a593c1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:22:25 -0700 Subject: [PATCH 6/7] fix(ui): open team page keys with their own organization in the edit form --- .../team/TeamVirtualKeysTable.test.tsx | 17 +++++++++++++++++ .../components/team/TeamVirtualKeysTable.tsx | 14 +++----------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 5c3af3f6c3f..c890501bb2c 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -7,6 +7,7 @@ import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useTeamMemberBudgets } from "@/app/(dashboard)/hooks/teams/useTeamMemberBudgets"; import { KeyResponse } from "../key_team_helpers/key_list"; import { Organization } from "../networking"; +import KeyInfoView from "../templates/key_info_view"; vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: vi.fn(), @@ -442,6 +443,22 @@ describe("TeamVirtualKeysTable", () => { }); }); + it("should open a key without its own organization without the team's organization", async () => { + const keyWithoutOrganization = { + keys: [createMockKey({ token: "sk-no-org", key_alias: "no_org_key", organization_id: null })], + total_count: 1, + current_page: 1, + total_pages: 1, + }; + mockUseKeys.mockReturnValue(createKeysQueryResult(keyWithoutOrganization)); + renderWithProviders(); + + await userEvent.click(await screen.findByRole("button", { name: /sk-no-org/ })); + + await screen.findByText("Key Info View"); + expect(vi.mocked(KeyInfoView).mock.lastCall?.[0].keyData).toMatchObject({ organization_id: null }); + }); + describe("entity links out of the key rows", () => { const renderRow = async (key: KeyResponse, organization: Organization | null = null) => { mockUseKeys.mockReturnValue({ diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 8ea559990c0..2e7d2c001a1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -90,15 +90,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const { data: keys, isPending: isLoading, isFetching, refetch } = useKeys(pageIndex + 1, pageSize, keyListOptions); - const displayKeys = useMemo(() => { - const kList = keys?.keys || []; - const orgId = organization?.organization_id; - if (!orgId) return kList; - return kList.map((k: KeyResponse) => ({ - ...k, - organization_id: (k.organization_id ?? k.org_id) || orgId, - })); - }, [keys?.keys, organization?.organization_id]); + const displayKeys = useMemo(() => keys?.keys ?? [], [keys?.keys]); const teamMemberBudgetTeamIds = useMemo( () => (displayKeys.some((key) => key.max_budget == null && Boolean(key.user_id)) ? [teamId] : []), @@ -180,7 +172,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }, { id: "organization_id", - accessorKey: "organization_id", + accessorFn: (key) => (key.organization_id ?? key.org_id) || organization?.organization_id || null, header: "Organization ID", size: 140, enableSorting: false, @@ -442,7 +434,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }, }, ], - [expandedAccordions, teamMemberBudgets, teamId], + [expandedAccordions, teamMemberBudgets, teamId, organization?.organization_id], ); const handleSortingChange = useCallback((updaterOrValue: React.SetStateAction) => { From 45339ad26e2d09342fc997a588641da69d6d9663 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:02:14 -0700 Subject: [PATCH 7/7] fix(ui): keep every team in the key form's team list for users who cannot change the organization --- .../key_edit_view.integration.test.tsx | 38 +++++++++++++++++++ .../components/templates/key_edit_view.tsx | 10 +++-- 2 files changed, 44 insertions(+), 4 deletions(-) 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 11ac2b67e88..901b98407c5 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 @@ -1704,6 +1704,44 @@ describe("KeyEditView", () => { expect(screen.queryByRole("option", { name: /Beta/ })).not.toBeInTheDocument(); }); + it("should let a non-admin move a key to their team in another organization", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken="" + userID="" + userRole="Internal User" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Engineering"); + }); + + await userEvent.click(screen.getByRole("combobox", { name: "Team" })); + await userEvent.click(await screen.findByRole("option", { name: /Beta/ })); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Sales"); + }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + expect(onSubmitMock.mock.calls[0][0]).toMatchObject({ organization_id: "org-2", team_id: "team-b" }); + }); + it("should re-scope teams when the edited key switches to another organization", async () => { const sharedProps = { teams: [ 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 5b61805de05..3e844d38d68 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -146,6 +146,7 @@ function KeyEditSession({ const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); const hasProject = Boolean(keyData.project_id); + const canChangeOrganization = userRole === "Admin" && !hasProject; const detachProject = hasProject && form.watch("project_id") === null; const canDetachProject = canDetachKeyProject(team, organizations, userID, userRole); @@ -352,9 +353,10 @@ function KeyEditSession({ })), ]; - const visibleTeams = selectedOrganizationId - ? teams?.filter((t) => t.organization_id === selectedOrganizationId) - : teams; + const visibleTeams = + canChangeOrganization && selectedOrganizationId + ? teams?.filter((t) => t.organization_id === selectedOrganizationId) + : teams; const teamOptionsFor = (selectedTeamId: string | null): SearchSelectOption[] => { const selectedTeamOutsideFilter = teams?.find((t) => t.team_id === selectedTeamId && !visibleTeams?.includes(t)); @@ -795,7 +797,7 @@ function KeyEditSession({ value={value} organizations={organizations} loading={isOrganizationsLoading} - disabled={userRole !== "Admin" || hasProject} + disabled={!canChangeOrganization} onChange={(orgId) => handleOrganizationChange(onChange, orgId)} /> )}