From 6e8a45f6daa394db42d2d925e5de6609b4e44d1e Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Mon, 10 Aug 2026 17:41:14 -0700 Subject: [PATCH] fix(ui): show each destination's own scope in the Edit scope dialog Form.useForm() owns a store that outlives the dialog, and initialValues seeds it only on first mount. Reopening Edit scope for a second destination therefore left the previous one's scope in the fields while only the title changed, and Save sends the whole access object, so pressing it wrote that stale scope: a team-scoped destination silently became global and began receiving every tenant's traces. Key the modal on the destination so the component, and the form store with it, is rebuilt per destination. destroyOnHidden alone does not help (destroyOnClose was renamed in antd 5.25 and this is 5.29, but neither remounts the store the parent holds), and reseeding from an effect does not either, because the portal's children mount after the effect runs. --- .../EditLoggingCredentialModal.tsx | 4 +- .../src/components/settings.test.tsx | 44 +++++++++++++++++++ .../src/components/settings.tsx | 9 ++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx index 30f6a910af7..9e168567bc0 100644 --- a/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx @@ -33,8 +33,6 @@ const EditLoggingCredentialModal: React.FC = ({ onClose, onSaved, }) => { - // destroyOnClose remounts the Form each open, so initialValues re-seeds from the - // current destination -- no effect syncing prop into state. const [form] = Form.useForm(); const handleSave = async () => { @@ -65,7 +63,7 @@ const EditLoggingCredentialModal: React.FC = ({ onCancel={onClose} onOk={handleSave} okText="Save" - destroyOnClose + destroyOnHidden > form={form} layout="vertical" preserve={false} initialValues={{ access: access ?? {} }}> diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index e4a20bedd66..709211662df 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -258,6 +258,50 @@ describe("Settings", () => { expect(getByText("well-formed")).toBeInTheDocument(); }); + // Regression: the Edit scope dialog is driven by a Form store that Form.useForm() owns. + // initialValues only seeds that store on first mount, so reopening the dialog for a second + // destination left the first one's scope in the fields while only the title changed. Save + // sends the whole access object, so pressing it wrote the stale scope -- silently turning a + // team-scoped destination global and leaking every tenant's traces to it. The parent keys + // the modal per destination so the store is rebuilt each time. + it("should show each destination's own scope when Edit scope is reopened for another one", async () => { + const user = userEvent.setup(); + credentialsFixture = { + credentials: [ + { + credential_name: "global-dest", + credential_info: { credential_type: "logging", description: "generic", access: { global: true } }, + }, + { + credential_name: "team-dest", + credential_info: { credential_type: "logging", description: "generic", access: { teams: ["team-1"] } }, + }, + ], + }; + + const { findByText } = renderSettings(defaultProps); + await findByText("Active Logging Callbacks"); + + const openEditScope = async (name: string) => { + await user.click(await screen.findByTestId(`callback-actions-${name}-success`)); + await user.click(await screen.findByTestId("destination-action-edit-access")); + return await screen.findByText(`Edit scope — ${name}`); + }; + const globalSwitch = () => document.querySelector(".ant-modal .ant-switch"); + const closeDialog = async () => { + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(document.querySelector(".ant-modal-title")).not.toBeInTheDocument()); + }; + + await openEditScope("global-dest"); + expect(globalSwitch()).toHaveAttribute("aria-checked", "true"); + await closeDialog(); + + await openEditScope("team-dest"); + // Before the fix this read "true", carried over from global-dest. + expect(globalSwitch()).toHaveAttribute("aria-checked", "false"); + }); + it("should hold the callbacks table in loading state until the fetch settles", async () => { let resolveCallbacks: (value: { callbacks: never[]; diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index dacba0524bc..972825fec93 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -590,6 +590,15 @@ const Settings: React.FC = ({ accessToken, userRole, userID, /> {accessToken && (