refactor(ui): consolidate Add/Edit credential modals into one CredentialModal (#32572)

* refactor(ui): consolidate Add/Edit credential modals into one CredentialModal

AddCredentialModal and EditCredentialModal were ~90% identical: the same
provider select, ProviderSpecificFields, and submit/filter logic, differing
only in title, button text, edit-mode prefill, and the disabled credential
name. Replace both with a single CredentialModal driven by a mode: 'add' |
'edit' prop, and point the two call sites in credentials.tsx at it.

Removes ~120 lines of duplication and drops the no-explicit-any and
no-restricted-imports baselines. The two per-file tests merge into one
CredentialModal.test.tsx covering both modes (add: editable empty name;
edit: prefilled, disabled name; provider fields render).

* refactor(ui): derive credential name disabled state from mode, not data

The disabled flag on the credential name field was tied to whether
existingCredential?.credential_name is truthy, an artifact of the old
EditCredentialModal. Drive it from the isEdit flag like the rest of the
component so mode='add' with a stray existingCredential can't disable the
field and mode='edit' with an empty name can't leave it editable. Behavior
is unchanged for real call sites; adds a regression test for the edit-with-
empty-name case.

* refactor(ui): prefill credential form declaratively instead of via useEffect

The edit-mode form was seeded with an imperative form.setFieldsValue inside
a useEffect that also set React state (setSelectedProvider), an antd anti-
pattern carried over from the old EditCredentialModal. Both call sites mount
the modal fresh with existingCredential already present (conditional && plus
destroyOnHidden), so there is no 'prop arrives after mount' case to handle.

Replace it with antd's declarative initialValues on the Form and a lazy
useState initializer for the provider. Removes the effect, its
react-hooks/set-state-in-effect suppression and exhaustive-deps warning, and
one any cast; behavior is unchanged (edit now shows the real provider on
first paint instead of flashing the default). Existing tests cover prefill
and the disabled name field.
This commit is contained in:
ryan-crabbe-berri 2026-07-18 11:24:03 -07:00 committed by GitHub
parent 703327a544
commit 6f4f4f69df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 191 additions and 423 deletions

View file

@ -1885,19 +1885,11 @@
"count": 1
}
},
"src/components/model_add/AddCredentialModal.tsx": {
"src/components/model_add/CredentialModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/model_add/EditCredentialModal.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/model_add/credentials.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -1,108 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Providers } from "../provider_info_helpers";
import AddCredentialModal from "./AddCredentialModal";
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
...actual,
getProviderCreateMetadata: vi.fn().mockResolvedValue([
{
provider: "OpenAI",
provider_display_name: Providers.OpenAI,
litellm_provider: "openai",
default_model_placeholder: "gpt-3.5-turbo",
credential_fields: [
{
key: "api_key",
label: "OpenAI API Key",
field_type: "password",
required: true,
},
{
key: "api_base",
label: "API Base",
field_type: "text",
placeholder: "https://api.openai.com/v1",
},
],
},
{
provider: "Anthropic",
provider_display_name: Providers.Anthropic,
litellm_provider: "anthropic",
default_model_placeholder: "claude-3-opus-20240229",
credential_fields: [
{
key: "api_key",
label: "Anthropic API Key",
field_type: "password",
required: true,
},
],
},
]),
};
});
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
const mockUploadProps = {
beforeUpload: vi.fn(),
onChange: vi.fn(),
};
describe("AddCredentialModal", () => {
it("should render", () => {
const queryClient = createQueryClient();
const onCancel = vi.fn();
const onAddCredential = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<AddCredentialModal
open={true}
onCancel={onCancel}
onAddCredential={onAddCredential}
uploadProps={mockUploadProps}
/>
</QueryClientProvider>,
);
expect(screen.getByText("Add New Credential")).toBeInTheDocument();
expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument();
expect(screen.getByLabelText("Provider:")).toBeInTheDocument();
});
it("should show the correct provider fields", async () => {
const queryClient = createQueryClient();
const onCancel = vi.fn();
const onAddCredential = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<AddCredentialModal
open={true}
onCancel={onCancel}
onAddCredential={onAddCredential}
uploadProps={mockUploadProps}
/>
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument();
expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,140 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Providers } from "../provider_info_helpers";
import { CredentialItem } from "../networking";
import CredentialModal from "./CredentialModal";
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
...actual,
getProviderCreateMetadata: vi.fn().mockResolvedValue([
{
provider: "OpenAI",
provider_display_name: Providers.OpenAI,
litellm_provider: "openai",
default_model_placeholder: "gpt-3.5-turbo",
credential_fields: [
{
key: "api_key",
label: "OpenAI API Key",
field_type: "password",
required: true,
},
{
key: "api_base",
label: "API Base",
field_type: "text",
placeholder: "https://api.openai.com/v1",
},
],
},
{
provider: "Anthropic",
provider_display_name: Providers.Anthropic,
litellm_provider: "anthropic",
default_model_placeholder: "claude-3-opus-20240229",
credential_fields: [
{
key: "api_key",
label: "Anthropic API Key",
field_type: "password",
required: true,
},
],
},
]),
};
});
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
const mockUploadProps = {
beforeUpload: vi.fn(),
onChange: vi.fn(),
};
const mockCredential: CredentialItem = {
credential_name: "test-credential",
credential_values: {
api_key: "test-api-key",
api_base: "https://api.test.com",
},
credential_info: {
custom_llm_provider: Providers.OpenAI,
},
};
const renderModal = (props: Partial<React.ComponentProps<typeof CredentialModal>> = {}) =>
render(
<QueryClientProvider client={createQueryClient()}>
<CredentialModal
open={true}
mode="add"
onCancel={vi.fn()}
onSubmit={vi.fn()}
uploadProps={mockUploadProps}
{...props}
/>
</QueryClientProvider>,
);
describe("CredentialModal", () => {
describe("add mode", () => {
it("renders the add title and an editable credential name", () => {
renderModal({ mode: "add" });
expect(screen.getByText("Add New Credential")).toBeInTheDocument();
expect(screen.getByText("Add Credential")).toBeInTheDocument();
const nameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement;
expect(nameInput.value).toBe("");
expect(nameInput.disabled).toBe(false);
});
it("shows provider-specific fields for the selected provider", async () => {
renderModal({ mode: "add" });
await waitFor(() => {
expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument();
expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument();
});
});
});
describe("edit mode", () => {
it("renders the edit title and update button", () => {
renderModal({ mode: "edit", existingCredential: mockCredential });
expect(screen.getByText("Edit Credential")).toBeInTheDocument();
expect(screen.getByText("Update Credential")).toBeInTheDocument();
});
it("prefills the credential name and disables it", async () => {
renderModal({ mode: "edit", existingCredential: mockCredential });
await waitFor(() => {
const nameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement;
expect(nameInput.value).toBe("test-credential");
expect(nameInput.disabled).toBe(true);
});
});
it("disables the name from the mode, not the credential's name value", () => {
renderModal({
mode: "edit",
existingCredential: { ...mockCredential, credential_name: "" },
});
expect((screen.getByLabelText("Credential Name:") as HTMLInputElement).disabled).toBe(true);
});
});
});

View file

@ -1,23 +1,47 @@
import { TextInput } from "@tremor/react";
import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd";
import type { UploadProps } from "antd/es/upload";
import React, { useState } from "react";
import { useState } from "react";
import ProviderSpecificFields from "../add_model/provider_specific_fields";
import { CredentialItem } from "../networking";
import { Providers, providerLogoMap } from "../provider_info_helpers";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { resetCredentialFormOnProviderChange } from "./credential_form_helpers";
const { Link } = Typography;
interface AddCredentialsModalProps {
interface CredentialModalProps {
open: boolean;
onCancel: () => void;
onAddCredential: (values: any) => void;
onSubmit: (values: any) => void;
uploadProps: UploadProps;
mode: "add" | "edit";
existingCredential?: CredentialItem | null;
}
const AddCredentialsModal: React.FC<AddCredentialsModalProps> = ({ open, onCancel, onAddCredential, uploadProps }) => {
export default function CredentialModal({
open,
onCancel,
onSubmit,
uploadProps,
mode,
existingCredential = null,
}: CredentialModalProps) {
const isEdit = mode === "edit";
const [form] = Form.useForm();
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.OpenAI);
const [selectedProvider, setSelectedProvider] = useState<Providers>(
(existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI,
);
const initialValues = existingCredential
? {
credential_name: existingCredential.credential_name,
custom_llm_provider: existingCredential.credential_info.custom_llm_provider,
...Object.fromEntries(
Object.entries(existingCredential.credential_values || {}).map(([key, value]) => [key, value ?? null]),
),
}
: undefined;
const handleSubmit = (values: any) => {
const filteredValues = Object.entries(values).reduce((acc, [key, value]) => {
@ -26,32 +50,33 @@ const AddCredentialsModal: React.FC<AddCredentialsModalProps> = ({ open, onCance
}
return acc;
}, {} as any);
onAddCredential(filteredValues);
onSubmit(filteredValues);
form.resetFields();
};
const closeAndReset = () => {
onCancel();
form.resetFields();
};
return (
<Modal
title="Add New Credential"
title={isEdit ? "Edit Credential" : "Add New Credential"}
open={open}
onCancel={() => {
onCancel();
form.resetFields();
}}
onCancel={closeAndReset}
footer={null}
width={600}
destroyOnHidden={isEdit}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
{/* Credential Name */}
<Form form={form} onFinish={handleSubmit} layout="vertical" initialValues={initialValues}>
<Form.Item
label="Credential Name:"
name="credential_name"
rules={[{ required: true, message: "Credential name is required" }]}
>
<TextInput placeholder="Enter a friendly name for these credentials" />
<TextInput placeholder="Enter a friendly name for these credentials" disabled={isEdit} />
</Form.Item>
{/* Provider Selection */}
<Form.Item
rules={[{ required: true, message: "Required" }]}
label="Provider:"
@ -92,28 +117,19 @@ const AddCredentialsModal: React.FC<AddCredentialsModalProps> = ({ open, onCance
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
{/* Modal Footer */}
<div className="flex justify-between items-center">
<Tooltip title="Get help on our github">
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
</Tooltip>
<div>
<Button
onClick={() => {
onCancel();
form.resetFields();
}}
style={{ marginRight: 10 }}
>
<Button onClick={closeAndReset} style={{ marginRight: 10 }}>
Cancel
</Button>
<Button htmlType="submit">{"Add Credential"}</Button>
<Button htmlType="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
</div>
</div>
</Form>
</Modal>
);
};
export default AddCredentialsModal;
}

View file

@ -1,123 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Providers } from "../provider_info_helpers";
import { CredentialItem } from "../networking";
import EditCredentialModal from "./EditCredentialModal";
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
...actual,
getProviderCreateMetadata: vi.fn().mockResolvedValue([
{
provider: "OpenAI",
provider_display_name: Providers.OpenAI,
litellm_provider: "openai",
default_model_placeholder: "gpt-3.5-turbo",
credential_fields: [
{
key: "api_key",
label: "OpenAI API Key",
field_type: "password",
required: true,
},
{
key: "api_base",
label: "API Base",
field_type: "text",
placeholder: "https://api.openai.com/v1",
},
],
},
{
provider: "Anthropic",
provider_display_name: Providers.Anthropic,
litellm_provider: "anthropic",
default_model_placeholder: "claude-3-opus-20240229",
credential_fields: [
{
key: "api_key",
label: "Anthropic API Key",
field_type: "password",
required: true,
},
],
},
]),
};
});
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
const mockUploadProps = {
beforeUpload: vi.fn(),
onChange: vi.fn(),
};
const mockCredential: CredentialItem = {
credential_name: "test-credential",
credential_values: {
api_key: "test-api-key",
api_base: "https://api.test.com",
},
credential_info: {
custom_llm_provider: Providers.OpenAI,
},
};
describe("EditCredentialModal", () => {
it("should render", () => {
const queryClient = createQueryClient();
const onCancel = vi.fn();
const onUpdateCredential = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<EditCredentialModal
open={true}
onCancel={onCancel}
onUpdateCredential={onUpdateCredential}
uploadProps={mockUploadProps}
existingCredential={mockCredential}
/>
</QueryClientProvider>,
);
expect(screen.getByText("Edit Credential")).toBeInTheDocument();
expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument();
expect(screen.getByLabelText("Provider:")).toBeInTheDocument();
});
it("should render initial values", async () => {
const queryClient = createQueryClient();
const onCancel = vi.fn();
const onUpdateCredential = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<EditCredentialModal
open={true}
onCancel={onCancel}
onUpdateCredential={onUpdateCredential}
uploadProps={mockUploadProps}
existingCredential={mockCredential}
/>
</QueryClientProvider>,
);
await waitFor(() => {
const credentialNameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement;
expect(credentialNameInput.value).toBe("test-credential");
expect(credentialNameInput.disabled).toBe(true);
});
});
});

View file

@ -1,150 +0,0 @@
import { TextInput } from "@tremor/react";
import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd";
import type { UploadProps } from "antd/es/upload";
import { useEffect, useState } from "react";
import ProviderSpecificFields from "../add_model/provider_specific_fields";
import { CredentialItem } from "../networking";
import { Providers, providerLogoMap } from "../provider_info_helpers";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { resetCredentialFormOnProviderChange } from "./credential_form_helpers";
const { Link } = Typography;
interface EditCredentialsModalProps {
open: boolean;
onCancel: () => void;
onUpdateCredential: (values: any) => void;
uploadProps: UploadProps;
existingCredential: CredentialItem | null;
}
export default function EditCredentialsModal({
open,
onCancel,
onUpdateCredential,
uploadProps,
existingCredential,
}: EditCredentialsModalProps) {
const [form] = Form.useForm();
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
const handleSubmit = (values: any) => {
const filteredValues = Object.entries(values).reduce((acc, [key, value]) => {
if (value !== "" && value !== undefined && value !== null) {
acc[key] = value;
}
return acc;
}, {} as any);
onUpdateCredential(filteredValues);
form.resetFields();
};
useEffect(() => {
if (existingCredential) {
// Spread all credential_values dynamically, converting undefined/null to null for form compatibility
const credentialValues = Object.entries(existingCredential.credential_values || {}).reduce(
(acc, [key, value]) => {
acc[key] = value ?? null;
return acc;
},
{} as Record<string, any>,
);
form.setFieldsValue({
credential_name: existingCredential.credential_name,
custom_llm_provider: existingCredential.credential_info.custom_llm_provider,
...credentialValues,
});
setSelectedProvider(existingCredential.credential_info.custom_llm_provider as Providers);
}
}, [existingCredential]);
return (
<Modal
title="Edit Credential"
open={open}
onCancel={() => {
onCancel();
form.resetFields();
}}
footer={null}
width={600}
destroyOnHidden={true}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
{/* Credential Name */}
<Form.Item
label="Credential Name:"
name="credential_name"
rules={[{ required: true, message: "Credential name is required" }]}
initialValue={existingCredential?.credential_name}
>
<TextInput
placeholder="Enter a friendly name for these credentials"
disabled={existingCredential?.credential_name ? true : false}
/>
</Form.Item>
{/* Provider Selection */}
<Form.Item
rules={[{ required: true, message: "Required" }]}
label="Provider:"
name="custom_llm_provider"
tooltip="Helper to auto-populate provider specific fields"
>
<AntdSelect
showSearch
onChange={(value) => {
resetCredentialFormOnProviderChange(form, value as Providers, setSelectedProvider);
}}
>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => (
<AntdSelect.Option key={providerEnum} value={providerEnum}>
<div className="flex items-center space-x-2">
<img
src={resolveLogoSrc(providerLogoMap[providerDisplayName])}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs";
fallbackDiv.textContent = providerDisplayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</AntdSelect.Option>
))}
</AntdSelect>
</Form.Item>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
{/* Modal Footer */}
<div className="flex justify-between items-center">
<Tooltip title="Get help on our github">
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
</Tooltip>
<div>
<Button
onClick={() => {
onCancel();
form.resetFields();
}}
style={{ marginRight: 10 }}
>
Cancel
</Button>
<Button htmlType="submit">{"Update Credential"}</Button>
</div>
</div>
</Form>
</Modal>
);
}

View file

@ -22,8 +22,7 @@ import { UploadProps } from "antd/es/upload";
import { useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import NotificationsManager from "../molecules/notifications_manager";
import AddCredentialsTab from "./AddCredentialModal";
import EditCredentialsModal from "./EditCredentialModal";
import CredentialModal from "./CredentialModal";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
@ -201,18 +200,20 @@ const CredentialsPanel: React.FC<CredentialsPanelProps> = ({ uploadProps }) => {
</Card>
{isAddModalOpen && (
<AddCredentialsTab
onAddCredential={handleAddCredential}
<CredentialModal
mode="add"
onSubmit={handleAddCredential}
open={isAddModalOpen}
onCancel={() => setIsAddModalOpen(false)}
uploadProps={uploadProps}
/>
)}
{isUpdateModalOpen && (
<EditCredentialsModal
<CredentialModal
mode="edit"
open={isUpdateModalOpen}
existingCredential={selectedCredential}
onUpdateCredential={handleUpdateCredential}
onSubmit={handleUpdateCredential}
uploadProps={uploadProps}
onCancel={() => setIsUpdateModalOpen(false)}
/>