mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #34053 from BerriAI/litellm_/migrate-simple-table-631c9b
refactor(ui): migrate credentials table onto shared DataTable
This commit is contained in:
commit
731efafbca
9 changed files with 702 additions and 414 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue