Merge pull request #34053 from BerriAI/litellm_/migrate-simple-table-631c9b

refactor(ui): migrate credentials table onto shared DataTable
This commit is contained in:
yuneng-jiang 2026-07-20 18:18:28 -07:00 committed by GitHub
commit 731efafbca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 702 additions and 414 deletions

View file

@ -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

View file

@ -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";

View file

@ -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<typeof import("@/components/networking")>();
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<string, unknown>) => void;
}) {
if (!open) {
return null;
}
return (
<button
data-testid={`credential-modal-${mode}-submit`}
onClick={() => onSubmit({ credential_name: "new-cred", custom_llm_provider: "openai" })}
>
submit {mode}
</button>
);
},
}));
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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
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();
});
});
});

View file

@ -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<string, unknown>, credentialValues: Record<string, unknown>) => ({
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<string, unknown>): Record<string, unknown> =>
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<CredentialItem | null>(null);
const [credentialToDelete, setCredentialToDelete] = useState<CredentialItem | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCredentialDeleting, setIsCredentialDeleting] = useState(false);
const handleUpdateCredential = async (values: Record<string, unknown>) => {
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<string, unknown>) => {
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 (
<div className="mx-auto flex w-full flex-auto flex-col gap-4 overflow-y-auto p-2">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Configured credentials for different AI providers. Add and manage your API credentials.
</p>
{canModifyCredentials && (
<Button onClick={() => setIsAddModalOpen(true)}>
<Plus className="size-4" />
Add Credential
</Button>
)}
</div>
<CredentialsTable
credentials={credentialList}
canModifyCredentials={canModifyCredentials}
onEdit={openEditModal}
onDelete={openDeleteModal}
isLoading={isLoading}
/>
{isAddModalOpen && (
<CredentialModal
mode="add"
onSubmit={handleAddCredential}
open={isAddModalOpen}
onCancel={() => setIsAddModalOpen(false)}
uploadProps={uploadProps}
/>
)}
{isUpdateModalOpen && (
<CredentialModal
mode="edit"
open={isUpdateModalOpen}
existingCredential={selectedCredential}
onSubmit={handleUpdateCredential}
uploadProps={uploadProps}
onCancel={() => setIsUpdateModalOpen(false)}
/>
)}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
onCancel={closeDeleteModal}
onOk={handleDeleteCredential}
title="Delete Credential?"
message="Are you sure you want to delete this credential? This action cannot be undone and may break existing integrations."
resourceInformationTitle="Credential Information"
resourceInformation={[
{ label: "Credential Name", value: credentialToDelete?.credential_name },
{ label: "Provider", value: credentialToDelete?.credential_info?.custom_llm_provider || "-" },
]}
confirmLoading={isCredentialDeleting}
requiredConfirmation={credentialToDelete?.credential_name}
/>
</div>
);
}

View file

@ -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<string, { displayName: string; logo: string }> = {
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(<CredentialsTable {...defaultProps} />);
for (const header of ["Credential Name", "Provider"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
});
it("should display each credential name", () => {
render(<CredentialsTable {...defaultProps} />);
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(<CredentialsTable {...defaultProps} />);
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(<CredentialsTable {...defaultProps} credentials={credentials} />);
const row = screen.getAllByRole("row").slice(1)[0];
expect(within(row).getByText("-")).toBeInTheDocument();
});
it("should sort by credential name ascending by default", () => {
render(<CredentialsTable {...defaultProps} />);
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(<CredentialsTable {...defaultProps} credentials={[]} />);
expect(screen.getByText("No credentials configured")).toBeInTheDocument();
});
it("should edit a credential through the actions menu", async () => {
const user = userEvent.setup();
render(<CredentialsTable {...defaultProps} />);
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(<CredentialsTable {...defaultProps} />);
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(<CredentialsTable {...defaultProps} />);
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(<CredentialsTable {...defaultProps} canModifyCredentials={false} />);
// 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();
});
});

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<KeyRound className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No credentials configured</div>
<div className="text-sm text-muted-foreground">Add a credential to connect an AI provider.</div>
</div>
);
}
const CredentialsTable: React.FC<CredentialsTableProps> = ({
credentials,
canModifyCredentials,
onEdit,
onDelete,
isLoading = false,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(
() => getCredentialsTableColumns({ canModifyCredentials, onEdit, onDelete }),
[canModifyCredentials, onEdit, onDelete],
);
return (
<DataTable
data={credentials}
columns={columns}
getRowId={(credential, index) => credential.credential_name || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading credentials…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default CredentialsTable;

View file

@ -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 <span className="text-sm text-muted-foreground">-</span>;
}
const { displayName, logo } = getProviderLogoAndName(provider);
return (
<div className="flex items-center gap-2">
{logo ? (
<img
src={logo}
alt=""
className="size-4 shrink-0"
onError={(event) => {
(event.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : null}
<span className="truncate text-sm">{displayName || provider}</span>
</div>
);
}
interface CredentialRowActionsProps {
credential: CredentialItem;
onEdit: (credential: CredentialItem) => void;
onDelete: (credential: CredentialItem) => void;
}
function CredentialRowActions({ credential, onEdit, onDelete }: CredentialRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open credential actions"
data-testid={`credential-actions-${credential.credential_name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="credential-action-edit" onClick={() => onEdit(credential)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
data-testid="credential-action-copy"
onClick={() => void copyToClipboard(credential.credential_name, "Credential name copied")}
>
<Copy />
Copy credential name
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid="credential-action-delete"
onClick={() => onDelete(credential)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface CredentialsTableColumnsDeps {
canModifyCredentials: boolean;
onEdit: (credential: CredentialItem) => void;
onDelete: (credential: CredentialItem) => void;
}
export const getCredentialsTableColumns = ({
canModifyCredentials,
onEdit,
onDelete,
}: CredentialsTableColumnsDeps): ColumnDef<CredentialItem>[] => {
const dataColumns: ColumnDef<CredentialItem>[] = [
{
id: "credential_name",
accessorKey: "credential_name",
meta: { title: "Credential Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Credential Name" />,
size: 260,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell title={row.original.credential_name} className="max-w-72" titleClassName="font-medium" />
),
},
{
id: "provider",
accessorKey: "credential_info.custom_llm_provider",
meta: { title: "Provider" },
header: "Provider",
size: 200,
enableSorting: false,
cell: ({ row }) => <CredentialProviderCell provider={row.original.credential_info?.custom_llm_provider} />,
},
];
if (!canModifyCredentials) {
return dataColumns;
}
return [
...dataColumns,
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<CredentialRowActions credential={row.original} onEdit={onEdit} onDelete={onDelete} />
</div>
),
},
];
};

View file

@ -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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
// The Actions cell should be empty (no edit/delete buttons rendered).
// We rely on the row being visible but containing no `<button>`s in
// the actions column — easier-to-read assertion: the entire panel
// contains zero buttons in admin-viewer mode.
expect(container.querySelectorAll("button").length).toBe(0);
});
});
});

View file

@ -1,240 +0,0 @@
import {
credentialCreateCall,
credentialDeleteCall,
CredentialItem,
credentialUpdateCall,
} from "@/components/networking"; // Assume this is your networking function
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import {
Badge,
Button,
Card,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
} from "@tremor/react";
import { Form } from "antd";
import { UploadProps } from "antd/es/upload";
import { useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import NotificationsManager from "../molecules/notifications_manager";
import CredentialModal from "./CredentialModal";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { stripMaskedSecrets } from "@/utils/maskedSecretUtils";
interface CredentialsPanelProps {
uploadProps: UploadProps;
}
const CredentialsPanel: React.FC<CredentialsPanelProps> = ({ uploadProps }) => {
const { accessToken, userRole } = useAuthorized();
// Admin Viewer follows the read-parity rule: see credentials, do not modify.
const canModifyCredentials = isProxyAdminRole(userRole ?? "");
const { data: credentialsResponse, refetch: refetchCredentials } = useCredentials();
const credentialList = credentialsResponse?.credentials || [];
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
const [selectedCredential, setSelectedCredential] = useState<CredentialItem | null>(null);
const [credentialToDelete, setCredentialToDelete] = useState<CredentialItem | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCredentialDeleting, setIsCredentialDeleting] = useState(false);
const [form] = Form.useForm();
const restrictedFields = ["credential_name", "custom_llm_provider"];
const handleUpdateCredential = async (values: any) => {
if (!accessToken) {
return;
}
const filter_credential_values = stripMaskedSecrets(
Object.entries(values)
.filter(([key]) => !restrictedFields.includes(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
);
// Transform form values into credential structure
const newCredential = {
credential_name: values.credential_name,
credential_values: filter_credential_values,
credential_info: {
custom_llm_provider: values.custom_llm_provider,
},
};
await credentialUpdateCall(accessToken, values.credential_name, newCredential);
NotificationsManager.success("Credential updated successfully");
setIsUpdateModalOpen(false);
await refetchCredentials();
};
const handleAddCredential = async (values: any) => {
if (!accessToken) {
return;
}
const filter_credential_values = Object.entries(values)
.filter(([key]) => !restrictedFields.includes(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
// Transform form values into credential structure
const newCredential = {
credential_name: values.credential_name,
credential_values: filter_credential_values,
credential_info: {
custom_llm_provider: values.custom_llm_provider,
},
};
// Add to list and close modal
await credentialCreateCall(accessToken, newCredential);
NotificationsManager.success("Credential added successfully");
setIsAddModalOpen(false);
await refetchCredentials();
};
const renderProviderBadge = (provider: string) => {
const providerColors: Record<string, string> = {
openai: "blue",
azure: "indigo",
anthropic: "purple",
default: "gray",
};
const color = providerColors[provider.toLowerCase()] || providerColors["default"];
return (
<Badge color={color as any} size="xs">
{provider}
</Badge>
);
};
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 openDeleteModal = (credential: CredentialItem) => {
setCredentialToDelete(credential);
setIsDeleteModalOpen(true);
};
const closeDeleteModal = () => {
setCredentialToDelete(null);
setIsDeleteModalOpen(false);
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto p-2">
{canModifyCredentials && <Button onClick={() => setIsAddModalOpen(true)}>Add Credential</Button>}
<div className="flex justify-between items-center mt-4 mb-4">
<Text>Configured credentials for different AI providers. Add and manage your API credentials.</Text>
</div>
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Credential Name</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{!credentialList || credentialList.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-4 text-gray-500">
No credentials configured
</TableCell>
</TableRow>
) : (
credentialList.map((credential: CredentialItem, index: number) => (
<TableRow key={index}>
<TableCell>{credential.credential_name}</TableCell>
<TableCell>
{renderProviderBadge((credential.credential_info?.custom_llm_provider as string) || "-")}
</TableCell>
<TableCell>
{canModifyCredentials ? (
<>
<Button
icon={PencilAltIcon}
variant="light"
size="sm"
onClick={() => {
setSelectedCredential(credential);
setIsUpdateModalOpen(true);
}}
/>
<Button
icon={TrashIcon}
variant="light"
size="sm"
onClick={() => openDeleteModal(credential)}
className="ml-2"
/>
</>
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{isAddModalOpen && (
<CredentialModal
mode="add"
onSubmit={handleAddCredential}
open={isAddModalOpen}
onCancel={() => setIsAddModalOpen(false)}
uploadProps={uploadProps}
/>
)}
{isUpdateModalOpen && (
<CredentialModal
mode="edit"
open={isUpdateModalOpen}
existingCredential={selectedCredential}
onSubmit={handleUpdateCredential}
uploadProps={uploadProps}
onCancel={() => setIsUpdateModalOpen(false)}
/>
)}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
onCancel={closeDeleteModal}
onOk={handleDeleteCredential}
title="Delete Credential?"
message="Are you sure you want to delete this credential? This action cannot be undone and may break existing integrations."
resourceInformationTitle="Credential Information"
resourceInformation={[
{ label: "Credential Name", value: credentialToDelete?.credential_name },
{ label: "Provider", value: credentialToDelete?.credential_info?.custom_llm_provider || "-" },
]}
confirmLoading={isCredentialDeleting}
requiredConfirmation={credentialToDelete?.credential_name}
/>
</div>
);
};
export default CredentialsPanel;