diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 9dc468c6a9f..07946c5b8c7 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -453,8 +453,8 @@ it("should open KeyInfoView when clicking on a key ID button", async () => { // Wait for KeyInfoView to appear - check for unique elements that only exist in KeyInfoView await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); - // KeyInfoView shows "Created:" or "Updated:" which is unique to it - expect(screen.getByText(/Created:|Updated:/)).toBeInTheDocument(); + // KeyInfoHeader shows "Created At" metadata label + expect(screen.getByText("Created At")).toBeInTheDocument(); }); // Verify that table-specific elements are no longer visible diff --git a/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx b/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx new file mode 100644 index 00000000000..6558fe8bad9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import LabeledField from "./LabeledField"; + +describe("LabeledField", () => { + it("should render the label and value", () => { + render(); + expect(screen.getByText("User Email")).toBeInTheDocument(); + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + it("should render the icon when provided", () => { + render( + } />, + ); + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + it("should show '-' when value is empty", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should show 'Default Proxy Admin' tag when value is default_user_id and defaultUserIdCheck is true", () => { + render( + , + ); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + expect(screen.queryByText("default_user_id")).not.toBeInTheDocument(); + }); + + it("should show raw value when value is default_user_id but defaultUserIdCheck is false", () => { + render(); + expect(screen.getByText("default_user_id")).toBeInTheDocument(); + expect(screen.queryByText("Default Proxy Admin")).not.toBeInTheDocument(); + }); + + it("should not be copyable when value is empty", () => { + const { container } = render(); + // antd adds a .ant-typography-copy element when copyable; should not be present + expect(container.querySelector(".ant-typography-copy")).not.toBeInTheDocument(); + }); + + it("should not be copyable when value is default_user_id and defaultUserIdCheck is true", () => { + const { container } = render( + , + ); + expect(container.querySelector(".ant-typography-copy")).not.toBeInTheDocument(); + }); + + it("should be copyable when copyable is true and value is present", () => { + const { container } = render( + , + ); + expect(container.querySelector(".ant-typography-copy")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx b/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx new file mode 100644 index 00000000000..75a8236a2fa --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Typography, Space } from "antd"; +import DefaultProxyAdminTag from "./DefaultProxyAdminTag"; + +const { Text } = Typography; + +interface LabeledFieldProps { + label: string; + value: string; + icon?: React.ReactNode; + truncate?: boolean; + copyable?: boolean; + defaultUserIdCheck?: boolean; +} + +export default function LabeledField({ + label, + value, + icon, + truncate = false, + copyable = false, + defaultUserIdCheck = false, +}: LabeledFieldProps) { + const isEmpty = !value; + const isDefaultUser = defaultUserIdCheck && value === "default_user_id"; + const displayValue = isEmpty ? "-" : value; + const isCopyable = copyable && !isEmpty && !isDefaultUser; + + const valueEl = isDefaultUser ? ( + + ) : ( + + {displayValue} + + ); + return ( +
+ + {icon} + + {label} + + +
{valueEl}
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx new file mode 100644 index 00000000000..0d746b1f872 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx @@ -0,0 +1,145 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { KeyInfoHeader, KeyInfoData } from "./KeyInfoHeader"; + +const MOCK_DATA: KeyInfoData = { + keyName: "My Test Key", + keyId: "sk-1234567890abcdef", + userId: "user-abc-123", + userEmail: "test@example.com", + createdBy: "admin@example.com", + createdAt: "Oct 29, 2025 at 1:26 AM", + lastUpdated: "Oct 29, 2025 at 1:47 AM", + lastActive: "Oct 29, 2025 at 2:00 AM", +}; + +describe("KeyInfoHeader", () => { + it("should render", () => { + render(); + expect(screen.getByText("My Test Key")).toBeInTheDocument(); + }); + + it("should render the key ID with prefix", () => { + render(); + expect(screen.getByText(/Key ID:/)).toBeInTheDocument(); + expect(screen.getByText(/sk-1234567890abcdef/)).toBeInTheDocument(); + }); + + it("should render all metadata fields", () => { + render(); + expect(screen.getByText("User Email")).toBeInTheDocument(); + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getByText("User ID")).toBeInTheDocument(); + expect(screen.getByText("user-abc-123")).toBeInTheDocument(); + expect(screen.getByText("Created At")).toBeInTheDocument(); + expect(screen.getByText("Created By")).toBeInTheDocument(); + expect(screen.getByText("Last Updated")).toBeInTheDocument(); + expect(screen.getByText("Last Active")).toBeInTheDocument(); + }); + + describe("back button", () => { + it("should render with default text", () => { + render(); + expect(screen.getByRole("button", { name: /back to keys/i })).toBeInTheDocument(); + }); + + it("should render with custom text", () => { + render(); + expect(screen.getByRole("button", { name: /back to dashboard/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /back to keys/i })).not.toBeInTheDocument(); + }); + + it("should call onBack when clicked", async () => { + const onBack = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /back to keys/i })); + expect(onBack).toHaveBeenCalledTimes(1); + }); + }); + + describe("action buttons", () => { + it("should show Regenerate and Delete buttons by default", () => { + render(); + expect(screen.getByRole("button", { name: /regenerate key/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /delete key/i })).toBeInTheDocument(); + }); + + it("should show Regenerate and Delete buttons when canModifyKey is true", () => { + render(); + expect(screen.getByRole("button", { name: /regenerate key/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /delete key/i })).toBeInTheDocument(); + }); + + it("should hide Regenerate and Delete buttons when canModifyKey is false", () => { + render(); + expect(screen.queryByRole("button", { name: /regenerate key/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /delete key/i })).not.toBeInTheDocument(); + }); + + it("should call onRegenerate when Regenerate Key is clicked", async () => { + const onRegenerate = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /regenerate key/i })); + expect(onRegenerate).toHaveBeenCalledTimes(1); + }); + + it("should call onDelete when Delete Key is clicked", async () => { + const onDelete = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /delete key/i })); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("should disable Regenerate button when regenerateDisabled is true", () => { + render(); + expect(screen.getByRole("button", { name: /regenerate key/i })).toBeDisabled(); + }); + + it("should not disable Regenerate button by default", () => { + render(); + expect(screen.getByRole("button", { name: /regenerate key/i })).not.toBeDisabled(); + }); + }); + + describe("Create New Key button", () => { + it("should show when onCreateNew is provided", () => { + render(); + expect(screen.getByRole("button", { name: /create new key/i })).toBeInTheDocument(); + }); + + it("should hide when onCreateNew is not provided", () => { + render(); + expect(screen.queryByRole("button", { name: /create new key/i })).not.toBeInTheDocument(); + }); + + it("should call onCreateNew when clicked", async () => { + const onCreateNew = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /create new key/i })); + expect(onCreateNew).toHaveBeenCalledTimes(1); + }); + }); + + describe("default_user_id handling", () => { + it("should show Default Proxy Admin tag for User ID when value is default_user_id", () => { + const data = { ...MOCK_DATA, userId: "default_user_id" }; + render(); + expect(screen.getAllByText("Default Proxy Admin").length).toBeGreaterThanOrEqual(1); + }); + + it("should show Default Proxy Admin tag for Created By when value is default_user_id", () => { + const data = { ...MOCK_DATA, createdBy: "default_user_id" }; + render(); + expect(screen.getAllByText("Default Proxy Admin").length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("empty value handling", () => { + it("should show '-' for User Email when value is empty", () => { + const data = { ...MOCK_DATA, userEmail: "" }; + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx new file mode 100644 index 00000000000..1befd657843 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { Button, Typography, Tooltip, Space, Divider, Flex } from "antd"; +import { + ArrowLeftOutlined, + SyncOutlined, + DeleteOutlined, + PlusOutlined, + UserOutlined, + MailOutlined, + CalendarOutlined, + ClockCircleOutlined, + ThunderboltOutlined, + SafetyCertificateOutlined, +} from "@ant-design/icons"; +import LabeledField from "../common_components/LabeledField"; + +const { Title, Text } = Typography; + +export interface KeyInfoData { + keyName: string; + keyId: string; + userId: string; + userEmail: string; + createdBy: string; + createdAt: string; + lastUpdated: string; + lastActive: string; +} + +interface KeyInfoHeaderProps { + data: KeyInfoData; + onBack?: () => void; + onCreateNew?: () => void; + onRegenerate?: () => void; + onDelete?: () => void; + canModifyKey?: boolean; + backButtonText?: string; + regenerateDisabled?: boolean; + regenerateTooltip?: string; +} + +export function KeyInfoHeader({ + data, + onBack, + onCreateNew, + onRegenerate, + onDelete, + canModifyKey = true, + backButtonText = "Back to Keys", + regenerateDisabled = false, + regenerateTooltip, +}: KeyInfoHeaderProps) { + return ( +
+ {onCreateNew && ( +
+ +
+ )} + +
+ +
+ + +
+ + {data.keyName} + + + Key ID: {data.keyId} + +
+ {canModifyKey && ( + + + + + + + + + )} +
+ + + + } /> + } + truncate + copyable + defaultUserIdCheck + /> + + + + + + } /> + } + truncate + copyable + defaultUserIdCheck + /> + + + + + + } /> + } /> + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 94ca90b9630..378f5b3872b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -1,13 +1,13 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; -import { ArrowLeftIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; -import { Button as AntdButton, Form, Tag, Tooltip } from "antd"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { Form, Tag } from "antd"; +import { KeyInfoHeader } from "./KeyInfoHeader"; import { useEffect, useState } from "react"; -import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles"; +import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "../../utils/roles"; import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers"; import AutoRotationView from "../common_components/AutoRotationView"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; @@ -54,8 +54,6 @@ export default function KeyInfoView({ const [deleteLoading, setDeleteLoading] = useState(false); const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); - const [copiedStates, setCopiedStates] = useState>({}); - // Add local state to maintain key data and track regeneration const [currentKeyData, setCurrentKeyData] = useState(keyData); const [lastRegeneratedAt, setLastRegeneratedAt] = useState(null); @@ -284,16 +282,6 @@ export default function KeyInfoView({ } }; - const copyToClipboard = async (text: string, key: string) => { - const success = await utilCopyToClipboard(text); - if (success) { - setCopiedStates((prev) => ({ ...prev, [key]: true })); - setTimeout(() => { - setCopiedStates((prev) => ({ ...prev, [key]: false })); - }, 2000); - } - }; - const handleRegenerateKeyUpdate = (updatedKeyData: Partial) => { // Update local state immediately with ALL the new data setCurrentKeyData((prevData) => { @@ -346,79 +334,29 @@ export default function KeyInfoView({ return (
-
-
- - {currentKeyData.key_alias || "Virtual Key"} - -
-
- Key ID - {currentKeyData.token_id || currentKeyData.token} -
- : } - onClick={() => copyToClipboard(currentKeyData.token_id || currentKeyData.token, "key-id")} - className={`ml-2 transition-all duration-200${copiedStates["key-id"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" - }`} - /> -
- - {/* Add timestamp and regeneration indicator */} -
- - {currentKeyData.updated_at && currentKeyData.updated_at !== currentKeyData.created_at - ? `Updated: ${formatTimestamp(currentKeyData.updated_at)}` - : `Created: ${formatTimestamp(currentKeyData.created_at)}`} - - - {isRecentlyRegenerated && ( - - Recently Regenerated - - )} - - {lastRegeneratedAt && ( - - Regenerated - - )} -
-
- {canModifyKey && ( -
- - - - - - -
- )} -
+ setIsRegenerateModalOpen(true)} + onDelete={() => setIsDeleteModalOpen(true)} + canModifyKey={canModifyKey} + backButtonText={backButtonText} + regenerateDisabled={!premiumUser} + regenerateTooltip={ + !premiumUser + ? "This is a LiteLLM Enterprise feature, and requires a valid key to use." + : undefined + } + /> {/* Add RegenerateKeyModal */}