From c06c16b0bf5f73122109dd2f9aa80113156192c3 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 20 Jul 2026 17:09:55 -0700 Subject: [PATCH 1/2] refactor(ui): migrate credentials table onto shared DataTable Move the Credentials panel off its hand-rolled tremor table onto the shared DataTable and cell library, matching the SimpleTable design and the sibling Vector Stores / Guardrails tables. Split the panel into a modal-owning parent (CredentialsPanel), a thin client-mode DataTable consumer (CredentialsTable), and a CredentialsTableColumns factory. Credential Name and Provider render as shared cells (IdentityCell + provider logo via getProviderLogoAndName); the per-row edit/delete icons become a right -aligned overflow menu (Edit, Copy credential name, Delete). Admin-viewer read parity is preserved: viewers still see the list but get no actions column. Detail/edit and delete modals stay in the parent, so the public prop stays uploadProps only. Drops the now-unused tremor import (pruning its eslint suppression) and the dead antd Form handle. --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../ModelsAndEndpointsView.tsx | 2 +- .../model_add/CredentialsPanel.test.tsx | 122 +++++++++ .../components/model_add/CredentialsPanel.tsx | 168 ++++++++++++ .../model_add/CredentialsTable.test.tsx | 119 +++++++++ .../components/model_add/CredentialsTable.tsx | 64 +++++ .../model_add/CredentialsTableColumns.tsx | 139 ++++++++++ .../components/model_add/credentials.test.tsx | 168 ------------ .../src/components/model_add/credentials.tsx | 240 ------------------ 9 files changed, 613 insertions(+), 414 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx delete mode 100644 ui/litellm-dashboard/src/components/model_add/credentials.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/model_add/credentials.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index c775af81ba8..837b22ee761 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1877,11 +1877,6 @@ "count": 1 } }, - "src/components/model_add/credentials.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_add/reuse_credentials.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index aac5405ce6b..672bcc2aa95 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -9,7 +9,7 @@ import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/compon import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab"; import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit"; import { Team } from "@/components/key_team_helpers/key_list"; -import CredentialsPanel from "@/components/model_add/credentials"; +import CredentialsPanel from "@/components/model_add/CredentialsPanel"; import { getCallbacksCall } from "@/components/networking"; import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers"; import { getDisplayModelName } from "@/components/view_model/model_name_display"; diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx new file mode 100644 index 00000000000..7124cd9ab09 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { UploadProps } from "antd/es/upload"; +import { describe, expect, it, vi } from "vitest"; + +import { CredentialItem } from "@/components/networking"; + +import CredentialsPanel from "./CredentialsPanel"; + +const DEFAULT_UPLOAD_PROPS = {} as UploadProps; + +const mockUseAuthorized = vi.fn(); +const mockUseCredentials = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ + useCredentials: () => mockUseCredentials(), +})); + +const credentials: CredentialItem[] = [ + { + credential_name: "openai-key", + credential_values: {}, + credential_info: { custom_llm_provider: "openai" }, + }, +]; + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const renderPanel = () => + render( + + + , + ); + +describe("CredentialsPanel", () => { + it("renders the Add Credential button for an admin", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + expect(screen.getByRole("button", { name: /add credential/i })).toBeInTheDocument(); + }); + + it("displays the credential rows", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + expect(screen.getByText("openai-key")).toBeInTheDocument(); + }); + + it("shows the empty state when there are no credentials", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + expect(screen.getByText("No credentials configured")).toBeInTheDocument(); + }); + + it("shows the loading skeleton instead of the empty state while credentials load", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: undefined, isLoading: true, refetch: vi.fn() }); + + renderPanel(); + + // isLoading must reach the table: the empty state must not render mid-load. + expect(screen.queryByText("No credentials configured")).not.toBeInTheDocument(); + }); + + it("opens the add modal when the add button is clicked", async () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /add credential/i })); + }); + + await waitFor(() => { + expect(screen.getByText("Add New Credential")).toBeInTheDocument(); + }); + }); + + describe("Admin Viewer write-action gating", () => { + // Admin Viewer can VIEW credentials but must not add / edit / delete them. + it("hides the Add Credential button but still lists credentials", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin Viewer" }); + mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + expect(screen.getByText("openai-key")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /add credential/i })).not.toBeInTheDocument(); + }); + + it("does not render the per-row actions menu for Admin Viewer", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin Viewer" }); + mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + expect(screen.queryByTestId("credential-actions-openai-key")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx new file mode 100644 index 00000000000..f9754210851 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { UploadProps } from "antd/es/upload"; +import { Plus } from "lucide-react"; +import { useState } from "react"; + +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { + credentialCreateCall, + credentialDeleteCall, + CredentialItem, + credentialUpdateCall, +} from "@/components/networking"; +import { Button } from "@/components/ui/button"; +import { stripMaskedSecrets } from "@/utils/maskedSecretUtils"; +import { isProxyAdminRole } from "@/utils/roles"; + +import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import NotificationsManager from "../molecules/notifications_manager"; +import CredentialModal from "./CredentialModal"; +import CredentialsTable from "./CredentialsTable"; + +interface CredentialsPanelProps { + uploadProps: UploadProps; +} + +const restrictedFields = ["credential_name", "custom_llm_provider"]; + +const buildCredential = (values: Record, credentialValues: Record) => ({ + credential_name: values.credential_name as string, + credential_values: credentialValues, + credential_info: { + custom_llm_provider: values.custom_llm_provider as string, + }, +}); + +const withoutRestrictedFields = (values: Record): Record => + Object.fromEntries(Object.entries(values).filter(([key]) => !restrictedFields.includes(key))); + +export default function CredentialsPanel({ uploadProps }: CredentialsPanelProps) { + const { accessToken, userRole } = useAuthorized(); + // Admin Viewer follows the read-parity rule: see credentials, do not modify. + const canModifyCredentials = isProxyAdminRole(userRole ?? ""); + const { data: credentialsResponse, isLoading, refetch: refetchCredentials } = useCredentials(); + const credentialList = credentialsResponse?.credentials || []; + + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + const [selectedCredential, setSelectedCredential] = useState(null); + const [credentialToDelete, setCredentialToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isCredentialDeleting, setIsCredentialDeleting] = useState(false); + + const handleUpdateCredential = async (values: Record) => { + if (!accessToken) { + return; + } + const newCredential = buildCredential(values, stripMaskedSecrets(withoutRestrictedFields(values))); + await credentialUpdateCall(accessToken, values.credential_name as string, newCredential); + NotificationsManager.success("Credential updated successfully"); + setIsUpdateModalOpen(false); + await refetchCredentials(); + }; + + const handleAddCredential = async (values: Record) => { + if (!accessToken) { + return; + } + const newCredential = buildCredential(values, withoutRestrictedFields(values)); + await credentialCreateCall(accessToken, newCredential); + NotificationsManager.success("Credential added successfully"); + setIsAddModalOpen(false); + await refetchCredentials(); + }; + + const handleDeleteCredential = async () => { + if (!accessToken || !credentialToDelete) { + return; + } + setIsCredentialDeleting(true); + try { + await credentialDeleteCall(accessToken, credentialToDelete.credential_name); + NotificationsManager.success("Credential deleted successfully"); + await refetchCredentials(); + } catch (error) { + NotificationsManager.error("Failed to delete credential"); + } finally { + setCredentialToDelete(null); + setIsDeleteModalOpen(false); + setIsCredentialDeleting(false); + } + }; + + const openEditModal = (credential: CredentialItem) => { + setSelectedCredential(credential); + setIsUpdateModalOpen(true); + }; + + const openDeleteModal = (credential: CredentialItem) => { + setCredentialToDelete(credential); + setIsDeleteModalOpen(true); + }; + + const closeDeleteModal = () => { + setCredentialToDelete(null); + setIsDeleteModalOpen(false); + }; + + return ( +
+
+

+ Configured credentials for different AI providers. Add and manage your API credentials. +

+ {canModifyCredentials && ( + + )} +
+ + + + {isAddModalOpen && ( + setIsAddModalOpen(false)} + uploadProps={uploadProps} + /> + )} + {isUpdateModalOpen && ( + setIsUpdateModalOpen(false)} + /> + )} + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx new file mode 100644 index 00000000000..f7f6b26fcdc --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CredentialItem } from "@/components/networking"; + +import CredentialsTable from "./CredentialsTable"; + +vi.mock("@/components/provider_info_helpers", () => ({ + getProviderLogoAndName: (provider: string) => { + const providerMap: Record = { + openai: { displayName: "OpenAI", logo: "/openai-logo.png" }, + azure: { displayName: "Azure", logo: "/azure-logo.png" }, + }; + return providerMap[provider] || { displayName: provider, logo: "" }; + }, +})); + +const mockCredentials: CredentialItem[] = [ + { + credential_name: "b-openai-key", + credential_values: {}, + credential_info: { custom_llm_provider: "openai" }, + }, + { + credential_name: "a-azure-key", + credential_values: {}, + credential_info: { custom_llm_provider: "azure" }, + }, +]; + +const mockOnEdit = vi.fn(); +const mockOnDelete = vi.fn(); + +const defaultProps = { + credentials: mockCredentials, + canModifyCredentials: true, + onEdit: mockOnEdit, + onDelete: mockOnDelete, +}; + +describe("CredentialsTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the data column headers", () => { + render(); + for (const header of ["Credential Name", "Provider"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } + }); + + it("should display each credential name", () => { + render(); + expect(screen.getByText("b-openai-key")).toBeInTheDocument(); + expect(screen.getByText("a-azure-key")).toBeInTheDocument(); + }); + + it("should render provider display names from the logo helper", () => { + render(); + expect(screen.getByText("OpenAI")).toBeInTheDocument(); + expect(screen.getByText("Azure")).toBeInTheDocument(); + }); + + it("should render a dash when a credential has no provider", () => { + const credentials: CredentialItem[] = [ + { credential_name: "no-provider", credential_values: {}, credential_info: {} }, + ]; + render(); + const row = screen.getAllByRole("row").slice(1)[0]; + expect(within(row).getByText("-")).toBeInTheDocument(); + }); + + it("should sort by credential name ascending by default", () => { + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("a-azure-key")).toBeInTheDocument(); + expect(within(rows[1]).getByText("b-openai-key")).toBeInTheDocument(); + }); + + it("should display the empty state when there are no credentials", () => { + render(); + expect(screen.getByText("No credentials configured")).toBeInTheDocument(); + }); + + it("should edit a credential through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("credential-actions-b-openai-key")); + await user.click(await screen.findByTestId("credential-action-edit")); + expect(mockOnEdit).toHaveBeenCalledWith(mockCredentials[0]); + }); + + it("should delete a credential through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("credential-actions-b-openai-key")); + await user.click(await screen.findByTestId("credential-action-delete")); + expect(mockOnDelete).toHaveBeenCalledWith(mockCredentials[0]); + }); + + it("should copy the credential name through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("credential-actions-b-openai-key")); + await user.click(await screen.findByTestId("credential-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("b-openai-key"); + }); + + it("should not render the actions menu when the user cannot modify credentials", () => { + render(); + // Read parity: names still render... + expect(screen.getByText("b-openai-key")).toBeInTheDocument(); + // ...but there is no per-row actions trigger. + expect(screen.queryByTestId("credential-actions-b-openai-key")).not.toBeInTheDocument(); + expect(screen.queryByTestId("credential-actions-a-azure-key")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx new file mode 100644 index 00000000000..33d63e87a5b --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { KeyRound } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { CredentialItem } from "@/components/networking"; +import { DataTable } from "@/components/shared/DataTable"; + +import { getCredentialsTableColumns } from "./CredentialsTableColumns"; + +interface CredentialsTableProps { + credentials: CredentialItem[]; + canModifyCredentials: boolean; + onEdit: (credential: CredentialItem) => void; + onDelete: (credential: CredentialItem) => void; + isLoading?: boolean; +} + +const DEFAULT_SORTING: SortingState = [{ id: "credential_name", desc: false }]; + +function EmptyState() { + return ( +
+
+ +
+
No credentials configured
+
Add a credential to connect an AI provider.
+
+ ); +} + +const CredentialsTable: React.FC = ({ + credentials, + canModifyCredentials, + onEdit, + onDelete, + isLoading = false, +}) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo( + () => getCredentialsTableColumns({ canModifyCredentials, onEdit, onDelete }), + [canModifyCredentials, onEdit, onDelete], + ); + + return ( + credential.credential_name || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading credentials…" + noDataMessage={} + size="compact" + /> + ); +}; + +export default CredentialsTable; diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx new file mode 100644 index 00000000000..048ad16177d --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTableColumns.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; + +import { CredentialItem } from "@/components/networking"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { IdentityCell } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +function CredentialProviderCell({ provider }: { provider: string | undefined }) { + if (!provider) { + return -; + } + const { displayName, logo } = getProviderLogoAndName(provider); + return ( +
+ {logo ? ( + { + (event.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : null} + {displayName || provider} +
+ ); +} + +interface CredentialRowActionsProps { + credential: CredentialItem; + onEdit: (credential: CredentialItem) => void; + onDelete: (credential: CredentialItem) => void; +} + +function CredentialRowActions({ credential, onEdit, onDelete }: CredentialRowActionsProps) { + return ( + + + + + + onEdit(credential)}> + + Edit + + void copyToClipboard(credential.credential_name, "Credential name copied")} + > + + Copy credential name + + + onDelete(credential)} + > + + Delete + + + + ); +} + +interface CredentialsTableColumnsDeps { + canModifyCredentials: boolean; + onEdit: (credential: CredentialItem) => void; + onDelete: (credential: CredentialItem) => void; +} + +export const getCredentialsTableColumns = ({ + canModifyCredentials, + onEdit, + onDelete, +}: CredentialsTableColumnsDeps): ColumnDef[] => { + const dataColumns: ColumnDef[] = [ + { + id: "credential_name", + accessorKey: "credential_name", + meta: { title: "Credential Name" }, + header: ({ column }) => , + size: 260, + enableSorting: true, + cell: ({ row }) => ( + + ), + }, + { + id: "provider", + accessorKey: "credential_info.custom_llm_provider", + meta: { title: "Provider" }, + header: "Provider", + size: 200, + enableSorting: false, + cell: ({ row }) => , + }, + ]; + + if (!canModifyCredentials) { + return dataColumns; + } + + return [ + ...dataColumns, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, + ]; +}; diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.test.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.test.tsx deleted file mode 100644 index d1fe403297f..00000000000 --- a/ui/litellm-dashboard/src/components/model_add/credentials.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { CredentialItem } from "@/components/networking"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { UploadProps } from "antd/es/upload"; -import { describe, expect, it, vi } from "vitest"; -import CredentialsPanel from "./credentials"; - -const DEFAULT_UPLOAD_PROPS = {} as UploadProps; - -const mockUseAuthorized = vi.fn(); -const mockUseCredentials = vi.fn(); - -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => mockUseAuthorized(), -})); - -vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ - useCredentials: () => mockUseCredentials(), -})); - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }); - -describe("CredentialsPanel", () => { - it("should render", () => { - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); - mockUseCredentials.mockReturnValue({ - data: { credentials: [] }, - refetch: vi.fn(), - }); - - render( - - - , - ); - - expect(screen.getByRole("button", { name: /add credential/i })).toBeInTheDocument(); - }); - - it("should display provided credentials", () => { - const credentials: CredentialItem[] = [ - { - credential_name: "openai-key", - credential_values: {}, - credential_info: { custom_llm_provider: "openai" }, - }, - ]; - - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); - mockUseCredentials.mockReturnValue({ - data: { credentials }, - refetch: vi.fn(), - }); - - render( - - - , - ); - - expect(screen.getByText("openai-key")).toBeInTheDocument(); - }); - - it("should display empty state when no credentials are provided", () => { - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); - mockUseCredentials.mockReturnValue({ - data: { credentials: [] }, - refetch: vi.fn(), - }); - - render( - - - , - ); - - expect(screen.getByText("No credentials configured")).toBeInTheDocument(); - }); - - it("should open add modal when add button is clicked", async () => { - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); - mockUseCredentials.mockReturnValue({ - data: { credentials: [] }, - refetch: vi.fn(), - }); - - render( - - - , - ); - - const addButton = screen.getByRole("button", { name: /add credential/i }); - - act(() => { - fireEvent.click(addButton); - }); - - await waitFor(() => { - expect(screen.getByText("Add New Credential")).toBeInTheDocument(); - }); - }); - - describe("Admin Viewer write-action gating", () => { - // Admin Viewer can VIEW credentials but must not be able to add / edit / - // delete them. The page shows the credential list read-only. - const credentials: CredentialItem[] = [ - { - credential_name: "openai-key", - credential_values: {}, - credential_info: { custom_llm_provider: "openai" }, - }, - ]; - - it("hides the Add Credential button for Admin Viewer", () => { - mockUseAuthorized.mockReturnValue({ - accessToken: "test-token", - userRole: "Admin Viewer", - }); - mockUseCredentials.mockReturnValue({ - data: { credentials }, - refetch: vi.fn(), - }); - - render( - - - , - ); - - // Credential row still renders (read parity). - expect(screen.getByText("openai-key")).toBeInTheDocument(); - // But no Add Credential button (write blocked). - expect(screen.queryByRole("button", { name: /add credential/i })).not.toBeInTheDocument(); - }); - - it("hides Edit / Delete buttons on existing credentials for Admin Viewer", () => { - mockUseAuthorized.mockReturnValue({ - accessToken: "test-token", - userRole: "Admin Viewer", - }); - mockUseCredentials.mockReturnValue({ - data: { credentials }, - refetch: vi.fn(), - }); - - const { container } = render( - - - , - ); - - // The Actions cell should be empty (no edit/delete buttons rendered). - // We rely on the row being visible but containing no `} -
- Configured credentials for different AI providers. Add and manage your API credentials. -
- - - - - - Credential Name - Provider - Actions - - - - {!credentialList || credentialList.length === 0 ? ( - - - No credentials configured - - - ) : ( - credentialList.map((credential: CredentialItem, index: number) => ( - - {credential.credential_name} - - {renderProviderBadge((credential.credential_info?.custom_llm_provider as string) || "-")} - - - {canModifyCredentials ? ( - <> -
-
- - {isAddModalOpen && ( - setIsAddModalOpen(false)} - uploadProps={uploadProps} - /> - )} - {isUpdateModalOpen && ( - setIsUpdateModalOpen(false)} - /> - )} - - - - ); -}; - -export default CredentialsPanel; From 368bfe19bfa88155fd9b1788b2ee3d61884fc0fe Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 20 Jul 2026 17:37:24 -0700 Subject: [PATCH 2/2] fix(ui): surface add/update credential failures with an error toast The add and update handlers had no try/catch (carried over from the legacy panel), so a failed credentialCreateCall / credentialUpdateCall became an unhandled rejection: no error notification and the modal left open with no feedback. Bring them in line with the co-located delete handler by catching and calling NotificationsManager.error, keeping the modal open on failure so the user can retry. Add panel tests for the success (modal closes, refetch, success toast) and failure (error toast, modal stays open) paths. --- .../model_add/CredentialsPanel.test.tsx | 95 +++++++++++++++++-- .../components/model_add/CredentialsPanel.tsx | 28 ++++-- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx index 7124cd9ab09..af38645b00d 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -1,9 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { UploadProps } from "antd/es/upload"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { CredentialItem } from "@/components/networking"; +import { CredentialItem, credentialCreateCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import CredentialsPanel from "./CredentialsPanel"; @@ -20,6 +22,46 @@ vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ useCredentials: () => mockUseCredentials(), })); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + credentialCreateCall: vi.fn(), + credentialUpdateCall: vi.fn(), + credentialDeleteCall: vi.fn(), + }; +}); + +// Stub the modal so the panel's submit handlers can be driven directly: the +// button fires onSubmit with form-shaped values, and it only renders when open. +vi.mock("./CredentialModal", () => ({ + default: function CredentialModalMock({ + mode, + open, + onSubmit, + }: { + mode: "add" | "edit"; + open: boolean; + onSubmit: (values: Record) => void; + }) { + if (!open) { + return null; + } + return ( + + ); + }, +})); + const credentials: CredentialItem[] = [ { credential_name: "openai-key", @@ -46,6 +88,10 @@ const renderPanel = () => ); describe("CredentialsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders the Add Credential button for an admin", () => { mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); @@ -84,18 +130,53 @@ describe("CredentialsPanel", () => { }); it("opens the add modal when the add button is clicked", async () => { + const user = userEvent.setup(); mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); renderPanel(); - act(() => { - fireEvent.click(screen.getByRole("button", { name: /add credential/i })); - }); + expect(screen.queryByTestId("credential-modal-add-submit")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /add credential/i })); + expect(screen.getByTestId("credential-modal-add-submit")).toBeInTheDocument(); + }); + + it("closes the add modal and refetches after a successful add", async () => { + const user = userEvent.setup(); + const refetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch }); + vi.mocked(credentialCreateCall).mockResolvedValueOnce(undefined as never); + + renderPanel(); + + await user.click(screen.getByRole("button", { name: /add credential/i })); + await user.click(screen.getByTestId("credential-modal-add-submit")); await waitFor(() => { - expect(screen.getByText("Add New Credential")).toBeInTheDocument(); + expect(NotificationsManager.success).toHaveBeenCalledWith("Credential added successfully"); }); + expect(refetch).toHaveBeenCalled(); + expect(screen.queryByTestId("credential-modal-add-submit")).not.toBeInTheDocument(); + }); + + it("surfaces an error and keeps the add modal open when the create call fails", async () => { + const user = userEvent.setup(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); + vi.mocked(credentialCreateCall).mockRejectedValueOnce(new Error("network down")); + + renderPanel(); + + await user.click(screen.getByRole("button", { name: /add credential/i })); + await user.click(screen.getByTestId("credential-modal-add-submit")); + + await waitFor(() => { + expect(NotificationsManager.error).toHaveBeenCalledWith("Failed to add credential"); + }); + // The modal stays open so the user can retry, and no success toast fired. + expect(screen.getByTestId("credential-modal-add-submit")).toBeInTheDocument(); + expect(NotificationsManager.success).not.toHaveBeenCalled(); }); describe("Admin Viewer write-action gating", () => { diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx index f9754210851..99ab9525966 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -56,22 +56,30 @@ export default function CredentialsPanel({ uploadProps }: CredentialsPanelProps) if (!accessToken) { return; } - const newCredential = buildCredential(values, stripMaskedSecrets(withoutRestrictedFields(values))); - await credentialUpdateCall(accessToken, values.credential_name as string, newCredential); - NotificationsManager.success("Credential updated successfully"); - setIsUpdateModalOpen(false); - await refetchCredentials(); + try { + const newCredential = buildCredential(values, stripMaskedSecrets(withoutRestrictedFields(values))); + await credentialUpdateCall(accessToken, values.credential_name as string, newCredential); + NotificationsManager.success("Credential updated successfully"); + setIsUpdateModalOpen(false); + await refetchCredentials(); + } catch (error) { + NotificationsManager.error("Failed to update credential"); + } }; const handleAddCredential = async (values: Record) => { if (!accessToken) { return; } - const newCredential = buildCredential(values, withoutRestrictedFields(values)); - await credentialCreateCall(accessToken, newCredential); - NotificationsManager.success("Credential added successfully"); - setIsAddModalOpen(false); - await refetchCredentials(); + try { + const newCredential = buildCredential(values, withoutRestrictedFields(values)); + await credentialCreateCall(accessToken, newCredential); + NotificationsManager.success("Credential added successfully"); + setIsAddModalOpen(false); + await refetchCredentials(); + } catch (error) { + NotificationsManager.error("Failed to add credential"); + } }; const handleDeleteCredential = async () => {