Skip to content

refactor: internal users confirm settings modal#24306

Closed
ryan-crabbe wants to merge 6 commits intolitellm_ryan_march_20from
litellm_confirm_settings_modal
Closed

refactor: internal users confirm settings modal#24306
ryan-crabbe wants to merge 6 commits intolitellm_ryan_march_20from
litellm_confirm_settings_modal

Conversation

@ryan-crabbe
Copy link
Copy Markdown
Contributor

Type

🧹 Refactoring

Changes

When removing teams, clearing budgets, or changing roles in Default User Settings, a "Review Changes" modal now appears summarizing what will change before persisting. Non-destructive changes (additions only) save directly.

Screenshot 2026-03-21 at 11 20 16 AM

…s changes

When removing teams, clearing budgets, or changing roles in Default User
Settings, a "Review Changes" modal now appears summarizing what will change
before persisting. Non-destructive changes (additions only) save directly.
Replace any-typed settings state with DefaultUserSettingsValues and
SettingsResponse interfaces matching the backend's Python types.
Simplifies normalizeTeams and computeSettingsDiff significantly.
…ings

Adds a type === "number" case in renderEditableField so max_budget
renders as an InputNumber instead of a free-text TextInput. Prevents
string input and fixes null displaying as "null" in the field.
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 21, 2026 7:41pm

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR adds a "Review Changes" confirmation modal to the Default User Settings page that intercepts destructive edits (team removals, budget clears, role changes) before they are persisted, while non-destructive changes (additions) continue to save directly. The refactor also tightens the component's TypeScript types, introduces a pure computeSettingsDiff utility, and substantially expands the test suite.

Key changes:

  • New ConfirmSettingsChangeModal component with color-coded change entries (red/green/amber) and correct save-in-progress lockdown (keyboard, maskClosable disabled during save).
  • executeSave now returns a boolean, so the modal only clears on success — addressing the prior regression where errors silently closed the modal and discarded pending changes.
  • Stale-closure bug fixed: setSettings now uses the functional updater form.
  • computeSettingsDiff exported as a pure function and covered with unit tests that verify every branch (team add/remove, model add/remove, scalar set/clear/change).
  • One new P1 finding: the oldTeams loop in computeSettingsDiff is missing the !t.team_id guard that the newTeams loop already has. Combined with the module-level normalizeTeams dropping its invalid-object fallback, a malformed team entry from the API could produce a spurious "removed" diff entry, unexpectedly triggering the confirmation modal.
  • One P2 finding: the test that verifies cancel-modal behaviour selects the modal's Cancel button by DOM position (cancelButtons[cancelButtons.length - 1]), which is fragile to layout changes.

Confidence Score: 3/5

  • Mostly safe but a P1 logic gap in computeSettingsDiff (asymmetric team_id guard) should be fixed before merging to avoid spurious confirmation modals.
  • The architectural approach is sound and the previously flagged regressions (swallowed errors, stale closure, Esc/backdrop dismissal) are all addressed. The one remaining P1 issue — the oldTeams loop lacking a !t.team_id guard that its sibling newTeams loop has — could produce false-positive "removed" diff entries from malformed API data, unexpectedly gating saves behind the confirmation modal. The test suite is comprehensive but has one fragile selector.
  • DefaultUserSettings.tsx — specifically the computeSettingsDiff function's oldTeams loop (line 68) and the module-level normalizeTeams guard drop (line 43).

Important Files Changed

Filename Overview
ui/litellm-dashboard/src/components/DefaultUserSettings.tsx Core refactor introducing the confirmation flow. The executeSave / handleConfirmSave / handleSaveSettings split is well-structured and the stale-closure fix (setSettings((prev) => ...)) is correct. Two concerns: the asymmetric team_id guard between old/new team loops in computeSettingsDiff can produce spurious "removed" entries triggering unwanted modal appearances; and the new module-level normalizeTeams dropped the invalid-object fallback from the original component-scoped version.
ui/litellm-dashboard/src/components/DefaultUserSettings.test.tsx Test coverage is significantly expanded and correctly addresses prior gaps: modal flow (show, confirm, cancel), API failure keeping modal open, and API-response reflection. The computeSettingsDiff unit tests are well-written as pure-function tests. Minor fragility in the cancel-button selector (uses array position instead of scoped query).
ui/litellm-dashboard/src/components/common_components/ConfirmSettingsChangeModal.tsx New modal component with color-coded change entries. Correctly blocks all dismissal paths during save via keyboard and maskClosable props. Clean and self-contained.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User clicks Save Changes] --> B[processedValues = editedValues with empty→null]
    B --> C[computeSettingsDiff original vs processed]
    C --> D{hasDestructiveChanges?}
    D -- No --> E[executeSave directly]
    D -- Yes --> F[setPendingChanges / setPendingProcessedValues\nsetShowConfirmModal = true]
    F --> G[ConfirmSettingsChangeModal shown\nwith color-coded change list]
    G --> H{User action}
    H -- Cancel --> I[setShowConfirmModal=false\nclear pending state\nremain in edit mode]
    H -- Confirm --> J[handleConfirmSave → executeSave]
    E --> K{API call}
    J --> K
    K -- Success → true --> L[setSettings functional update\nsetIsEditing=false\nif modal: clear pending state]
    K -- Failure → false --> M[NotificationManager error toast\nmodal stays open / edit mode preserved]
Loading

Last reviewed commit: "fix(ui): stale closu..."

Comment on lines +213 to +219
const handleConfirmSave = async () => {
if (!pendingProcessedValues) return;
await executeSave(pendingProcessedValues);
setShowConfirmModal(false);
setPendingChanges([]);
setPendingProcessedValues(null);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Modal closes on save failure, losing pending changes

executeSave catches errors internally and never re-throws them. This means the await executeSave(...) call in handleConfirmSave always resolves without throwing, so the modal is unconditionally closed (lines 216–218) even when the API call failed. The user is left in editing mode with their pending changes gone, forcing them to re-edit from scratch.

const handleConfirmSave = async () => {
  if (!pendingProcessedValues) return;
  await executeSave(pendingProcessedValues);
  // ↑ executeSave swallows errors — this always runs even on failure
  setShowConfirmModal(false);   // modal closes on error
  setPendingChanges([]);         // pending changes lost on error
  setPendingProcessedValues(null);
};

A simple fix is to have executeSave return a success boolean (or re-throw), and only clear modal state on success:

// In executeSave, return true/false based on success
const executeSave = async (processedValues: DefaultUserSettingsValues): Promise<boolean> => {
  if (!accessToken) return false;
  setSaving(true);
  try {
    const updatedSettings = await updateInternalUserSettings(accessToken, processedValues);
    setSettings({ ...settings, values: updatedSettings.settings });
    setIsEditing(false);
    return true;
  } catch (error) {
    console.error("Error updating SSO settings:", error);
    NotificationManager.fromBackend("Failed to update settings: " + error);
    return false;
  } finally {
    setSaving(false);
  }
};

const handleConfirmSave = async () => {
  if (!pendingProcessedValues) return;
  const success = await executeSave(pendingProcessedValues);
  if (success) {
    setShowConfirmModal(false);
    setPendingChanges([]);
    setPendingProcessedValues(null);
  }
};

Comment on lines +51 to +114
/**
* Compare original settings values against edited values and return a list of
* human-readable changes. A change is "destructive" if something was removed,
* cleared, or reduced.
*/
export function computeSettingsDiff(
original: DefaultUserSettingsValues,
edited: DefaultUserSettingsValues,
): { changes: SettingsChange[]; hasDestructiveChanges: boolean } {
const changes: SettingsChange[] = [];

// --- Teams ---
const oldTeams = normalizeTeams(original.teams || []);
const newTeams = normalizeTeams(edited.teams || []);
const oldTeamMap = new Map(oldTeams.map((t) => [t.team_id, t]));
const newTeamIds = new Set(newTeams.map((t) => t.team_id));

for (const t of oldTeams) {
if (!newTeamIds.has(t.team_id)) {
changes.push({ field: "Teams", type: "removed", details: `Team "${t.team_id}" removed` });
}
}
for (const t of newTeams) {
if (!t.team_id) continue;
const old = oldTeamMap.get(t.team_id);
if (!old) {
changes.push({ field: "Teams", type: "added", details: `Team "${t.team_id}" added` });
continue;
}
if (old.max_budget_in_team !== t.max_budget_in_team) {
const fmt = (v?: number) => (v !== undefined ? `$${v}` : "No limit");
changes.push({ field: "Teams", type: "changed", details: `Team "${t.team_id}" budget: ${fmt(old.max_budget_in_team)} → ${fmt(t.max_budget_in_team)}` });
}
if (old.user_role !== t.user_role) {
changes.push({ field: "Teams", type: "changed", details: `Team "${t.team_id}" role: ${old.user_role} → ${t.user_role}` });
}
}

// --- Models ---
const oldModels = new Set(original.models || []);
const newModels = new Set(edited.models || []);
const removedModels = [...oldModels].filter((m) => !newModels.has(m));
const addedModels = [...newModels].filter((m) => !oldModels.has(m));
if (removedModels.length) changes.push({ field: "Models", type: "removed", details: `${removedModels.join(", ")} removed` });
if (addedModels.length) changes.push({ field: "Models", type: "added", details: `${addedModels.join(", ")} added` });

// --- Scalars ---
const scalarKeys = ["user_role", "max_budget", "budget_duration"] as const;
for (const key of scalarKeys) {
const oldVal = original[key] ?? null;
const newVal = edited[key] ?? null;
if (oldVal === newVal) continue;

const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
if (oldVal != null && newVal == null) {
changes.push({ field: displayName, type: "removed", details: `Cleared (was "${oldVal}")` });
} else if (oldVal == null && newVal != null) {
changes.push({ field: displayName, type: "added", details: `Set to "${newVal}"` });
} else {
changes.push({ field: displayName, type: "changed", details: `"${oldVal}" → "${newVal}"` });
}
}

return { changes, hasDestructiveChanges: changes.some((c) => c.type === "removed" || c.type === "changed") };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Budget increases classified as destructive (contradicts doc comment)

The computeSettingsDiff doc comment says: "A change is 'destructive' if something was removed, cleared, or reduced." However, the implementation classifies all scalar changes — including budget increases — as "changed", which is then treated as destructive (line 114). For example, changing max_budget from 100 to 200 triggers the confirmation modal, even though it is a non-harmful increase.

The same applies to team budget increases via old.max_budget_in_team !== t.max_budget_in_team (line 80), which fires "changed" regardless of direction.

Consider differentiating between increases (non-destructive) and decreases (destructive) for numeric fields, or update the doc comment to reflect the conservative intent so the discrepancy is explicit.

Comment on lines 120 to 152
@@ -148,6 +150,295 @@ describe("DefaultUserSettings", () => {
expect(mockUpdateInternalUserSettings).toHaveBeenCalled();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Test coverage weakened — API response reflection no longer verified

The original test verified that a successful save causes the new API-returned value to be reflected in the component state by using a mock response with max_budget: 2000. The update removes this, replacing it with a response that echoes the unchanged values. As a result, there is no longer a test that confirms the component correctly applies the API response (e.g., setSettings({ ...settings, values: updatedSettings.settings })) after a direct (non-modal) save. A regression in that state-update path would go undetected.

Consider keeping at least one test where the mock API response differs from the initial state to verify the settings are updated in the UI after saving.

Rule Used: What: Flag any modifications to existing tests and... (source)

…e test

executeSave now returns a boolean; handleConfirmSave only clears modal
state on success. Adds tests for the error path and for verifying the
UI reflects API response values after a successful save.
);

const updatedSettings = await updateInternalUserSettings(accessToken, processedValues);
setSettings({ ...settings, values: updatedSettings.settings });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale closure when spreading settings after async save

executeSave captures settings from its closure at the time the function is defined. After await updateInternalUserSettings(...) resolves, the captured settings may be stale if any concurrent React state update modified it during the network round-trip (e.g., the confirmation modal being opened a second time or a parallel re-fetch). Using the stale snapshot when spreading means field_schema and other keys could silently revert to old values.

Use the functional form of setSettings to guarantee the latest state is used:

Suggested change
setSettings({ ...settings, values: updatedSettings.settings });
setSettings((prev) => ({ ...prev, values: updatedSettings.settings }));

Comment on lines +393 to +405
} else if (type === "number") {
return (
<InputNumber
style={{ width: "100%" }}
value={editedValues[key] as number | undefined}
onChange={(value) => handleTextInputChange(key, value)}
placeholder={property.description || ""}
className="mt-2"
min={0}
step={0.01}
precision={2}
/>
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TypeScript type mismatch introduced by stricter handleTextInputChange signature

This PR changed handleTextInputChange from (key: string, value: any) to (key: keyof DefaultUserSettingsValues, value: ...). However, renderEditableField still receives key: string (from Object.entries(field_schema.properties) on line 546), so every handleTextInputChange(key, value) call inside it is now a type error — including this new number branch and all the pre-existing branches (lines ~327, ~345, ~352, ~362, ~376, ~409, ~425).

The cleanest fix is to cast key at the call-site using a type assertion inside renderEditableField:

const renderEditableField = (key: string, property: any, value: any) => {
  const typedKey = key as keyof DefaultUserSettingsValues;
  // … then use typedKey everywhere instead of key when calling handleTextInputChange

Alternatively, change renderEditableField's signature to (key: keyof DefaultUserSettingsValues, ...) and update the call-site in renderSettings accordingly.

Comment on lines +54 to +63
<Modal
title="Review Changes"
open={isOpen}
onOk={onConfirm}
onCancel={onCancel}
confirmLoading={confirmLoading}
okText={confirmLoading ? "Saving..." : "Confirm Changes"}
cancelText="Cancel"
okButtonProps={{ disabled: confirmLoading }}
cancelButtonProps={{ disabled: confirmLoading }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Modal dismissible via Esc or backdrop while saving

cancelButtonProps={{ disabled: confirmLoading }} only disables the footer cancel button. Ant Design's Modal still allows dismissal via the Esc key and backdrop click by default — both keyboard and maskClosable default to true.

If the user dismisses this way while confirmLoading is true:

  1. onCancel fires, clearing pendingProcessedValues and pendingChanges.
  2. The in-flight executeSave continues, and on success calls setSettings(...) plus setIsEditing(false).
  3. The user, believing they cancelled, is silently taken out of edit mode when the save completes, with no visible feedback.

To block all dismissal paths while saving is in progress, set maskClosable and keyboard on the Modal to false when confirmLoading is true.

…uring save

- Use functional state updater in executeSave to avoid stale settings closure
- Widen handleTextInputChange key param to string (schema keys are dynamic)
- Block Esc/backdrop dismiss on confirmation modal during confirmLoading
Comment on lines +68 to +72
for (const t of oldTeams) {
if (!newTeamIds.has(t.team_id)) {
changes.push({ field: "Teams", type: "removed", details: `Team "${t.team_id}" removed` });
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Asymmetric team_id guard between old/new teams loops

The newTeams loop on line 74 guards against empty team_id entries with if (!t.team_id) continue, but the oldTeams loop (lines 68–71) has no equivalent guard:

for (const t of oldTeams) {
  if (!newTeamIds.has(t.team_id)) {  // no guard for empty t.team_id
    changes.push({ field: "Teams", type: "removed", details: `Team "${t.team_id}" removed` });
  }
}

If the API ever returns a team object with an empty/missing team_id (or if normalizeTeams produces one for a malformed entry — the new module-level normalizeTeams dropped the old typeof team === "object" && team.team_id guard), oldTeams would contain a { team_id: undefined } entry. Since newTeamIds wouldn't have undefined, this would push a spurious "Team "undefined" removed" entry into changes, set hasDestructiveChanges: true, and trigger the confirmation modal even when no real team was removed.

A simple fix mirrors the pattern already used for newTeams:

Suggested change
for (const t of oldTeams) {
if (!newTeamIds.has(t.team_id)) {
changes.push({ field: "Teams", type: "removed", details: `Team "${t.team_id}" removed` });
}
}
for (const t of oldTeams) {
if (!t.team_id) continue;
if (!newTeamIds.has(t.team_id)) {
changes.push({ field: "Teams", type: "removed", details: `Team "${t.team_id}" removed` });
}
}

Comment on lines +302 to +305
const cancelButtons = screen.getAllByText("Cancel");
await act(async () => {
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile cancel button selector relying on DOM order

The test retrieves all buttons labelled "Cancel" and picks the last one, relying on rendering order to distinguish between the edit-header Cancel and the modal footer Cancel:

const cancelButtons = screen.getAllByText("Cancel");
await act(async () => {
  fireEvent.click(cancelButtons[cancelButtons.length - 1]);
});

If the component or modal ever renders an additional "Cancel" element (e.g., a tooltip, a third panel, or the order changes with Ant Design updates), this selector will silently click the wrong button and the test will give false confidence. Consider adding a data-testid to the modal footer Cancel button — or at minimum using a within(screen.getByRole("dialog")) scope:

const dialog = screen.getByRole("dialog");
const modalCancelButton = within(dialog).getByText("Cancel");
await act(async () => {
  fireEvent.click(modalCancelButton);
});

Comment on lines +43 to +49
function normalizeTeams(teams: (string | TeamEntry)[]): TeamEntry[] {
return teams.map((team) =>
typeof team === "string"
? { team_id: team, user_role: "user" as const }
: { team_id: team.team_id, max_budget_in_team: team.max_budget_in_team, user_role: team.user_role || "user" },
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module-level normalizeTeams drops the invalid-object fallback

The old component-scoped normalizeTeams had an explicit guard and fallback for malformed team objects:

// OLD
} else if (typeof team === "object" && team.team_id) {
  return { team_id: team.team_id, ... };
}
return { team_id: "", user_role: "user" as const };  // fallback for objects without team_id

The new module-level version unconditionally spreads the object:

// NEW
: { team_id: team.team_id, max_budget_in_team: team.max_budget_in_team, user_role: team.user_role || "user" }

If the API returns a team object where team_id is absent or null (malformed data), team.team_id resolves to undefined. This flows into both oldTeams (where the missing !t.team_id guard described above is the direct consequence) and editedValues, potentially producing { team_id: undefined } entries that render as "Team ID: " in the editor. Consider restoring the guard:

Suggested change
function normalizeTeams(teams: (string | TeamEntry)[]): TeamEntry[] {
return teams.map((team) =>
typeof team === "string"
? { team_id: team, user_role: "user" as const }
: { team_id: team.team_id, max_budget_in_team: team.max_budget_in_team, user_role: team.user_role || "user" },
);
}
function normalizeTeams(teams: (string | TeamEntry)[]): TeamEntry[] {
return teams
.map((team) =>
typeof team === "string"
? { team_id: team, user_role: "user" as const }
: { team_id: team.team_id, max_budget_in_team: team.max_budget_in_team, user_role: team.user_role || "user" },
)
.filter((t) => !!t.team_id);
}

@ishaan-berri ishaan-berri deleted the branch litellm_ryan_march_20 March 26, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants