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..af38645b00d --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -0,0 +1,203 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { UploadProps } from "antd/es/upload"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CredentialItem, credentialCreateCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +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(), +})); + +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", + credential_values: {}, + credential_info: { custom_llm_provider: "openai" }, + }, +]; + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const renderPanel = () => + render( + + + , + ); + +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() }); + + 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 () => { + const user = userEvent.setup(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() }); + + renderPanel(); + + 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(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", () => { + // 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..99ab9525966 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.tsx @@ -0,0 +1,176 @@ +"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; + } + 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; + } + 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 () => { + 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;