mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
[Feature] UI - Virtual Keys: Add KeyInfoHeader component with metadata display
Add a reusable KeyInfoHeader component to replace the inline header in KeyInfoView. Extract LabeledField as a common component for labeled metadata display with copyable support, default_user_id handling, and empty value placeholders. - Migrate all icons from lucide-react/heroicons to @ant-design/icons - Use antd native copyable with descriptive tooltips (Copy Key Alias, Copy Key ID, etc.) - Show DefaultProxyAdminTag for default_user_id values - Add canModifyKey, regenerateDisabled, regenerateTooltip props for permission gating - Add tests for KeyInfoHeader (19 tests) and LabeledField (8 tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
28a225d8bd
commit
fd05c49787
6 changed files with 414 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(<LabeledField label="User Email" value="test@example.com" />);
|
||||
expect(screen.getByText("User Email")).toBeInTheDocument();
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the icon when provided", () => {
|
||||
render(
|
||||
<LabeledField label="Name" value="Alice" icon={<span data-testid="test-icon" />} />,
|
||||
);
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show '-' when value is empty", () => {
|
||||
render(<LabeledField label="User ID" value="" />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Default Proxy Admin' tag when value is default_user_id and defaultUserIdCheck is true", () => {
|
||||
render(
|
||||
<LabeledField label="User ID" value="default_user_id" copyable defaultUserIdCheck />,
|
||||
);
|
||||
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(<LabeledField label="User ID" value="default_user_id" />);
|
||||
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(<LabeledField label="User ID" value="" copyable />);
|
||||
// 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(
|
||||
<LabeledField label="User ID" value="default_user_id" copyable defaultUserIdCheck />,
|
||||
);
|
||||
expect(container.querySelector(".ant-typography-copy")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be copyable when copyable is true and value is present", () => {
|
||||
const { container } = render(
|
||||
<LabeledField label="User ID" value="user-123" copyable />,
|
||||
);
|
||||
expect(container.querySelector(".ant-typography-copy")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ? (
|
||||
<DefaultProxyAdminTag userId={value} />
|
||||
) : (
|
||||
<Text
|
||||
strong
|
||||
copyable={isCopyable ? { tooltips: [`Copy ${label}`, "Copied!"] } : false}
|
||||
ellipsis={truncate}
|
||||
style={truncate ? { maxWidth: 160, display: "block" } : undefined}
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">{icon}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Space>
|
||||
<div>{valueEl}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
expect(screen.getByText("My Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the key ID with prefix", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
expect(screen.getByText(/Key ID:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/sk-1234567890abcdef/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all metadata fields", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
expect(screen.getByRole("button", { name: /back to keys/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with custom text", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} backButtonText="Back to Dashboard" />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} onBack={onBack} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} canModifyKey={true} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} canModifyKey={false} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} onRegenerate={onRegenerate} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} onDelete={onDelete} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /delete key/i }));
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should disable Regenerate button when regenerateDisabled is true", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} regenerateDisabled={true} />);
|
||||
expect(screen.getByRole("button", { name: /regenerate key/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not disable Regenerate button by default", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
expect(screen.getByRole("button", { name: /regenerate key/i })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Create New Key button", () => {
|
||||
it("should show when onCreateNew is provided", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} onCreateNew={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /create new key/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide when onCreateNew is not provided", () => {
|
||||
render(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
expect(screen.queryByRole("button", { name: /create new key/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onCreateNew when clicked", async () => {
|
||||
const onCreateNew = vi.fn();
|
||||
render(<KeyInfoHeader data={MOCK_DATA} onCreateNew={onCreateNew} />);
|
||||
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(<KeyInfoHeader data={data} />);
|
||||
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(<KeyInfoHeader data={data} />);
|
||||
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(<KeyInfoHeader data={data} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
130
ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx
Normal file
130
ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx
Normal file
|
|
@ -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 (
|
||||
<div>
|
||||
{onCreateNew && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateNew}>
|
||||
Create New Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack}>
|
||||
{backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Flex justify="space-between" align="start" style={{ marginBottom: 20 }}>
|
||||
<div>
|
||||
<Title level={3} copyable={{ tooltips: ["Copy Key Alias", "Copied!"] }} style={{ margin: 0 }}>
|
||||
{data.keyName}
|
||||
</Title>
|
||||
<Text type="secondary" copyable={{ text: data.keyId, tooltips: ["Copy Key ID", "Copied!"] }}>
|
||||
Key ID: {data.keyId}
|
||||
</Text>
|
||||
</div>
|
||||
{canModifyKey && (
|
||||
<Space>
|
||||
<Tooltip title={regenerateTooltip || ""}>
|
||||
<span>
|
||||
<Button icon={<SyncOutlined />} onClick={onRegenerate} disabled={regenerateDisabled}>
|
||||
Regenerate Key
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={onDelete}>
|
||||
Delete Key
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex align="stretch" gap={40} style={{ marginBottom: 40 }}>
|
||||
<Space direction="vertical" size={16}>
|
||||
<LabeledField label="User Email" value={data.userEmail} icon={<MailOutlined />} />
|
||||
<LabeledField
|
||||
label="User ID"
|
||||
value={data.userId}
|
||||
icon={<UserOutlined />}
|
||||
truncate
|
||||
copyable
|
||||
defaultUserIdCheck
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Divider type="vertical" style={{ height: "auto" }} />
|
||||
|
||||
<Space direction="vertical" size={16}>
|
||||
<LabeledField label="Created At" value={data.createdAt} icon={<CalendarOutlined />} />
|
||||
<LabeledField
|
||||
label="Created By"
|
||||
value={data.createdBy}
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
truncate
|
||||
copyable
|
||||
defaultUserIdCheck
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Divider type="vertical" style={{ height: "auto" }} />
|
||||
|
||||
<Space direction="vertical" size={16}>
|
||||
<LabeledField label="Last Updated" value={data.lastUpdated} icon={<ClockCircleOutlined />} />
|
||||
<LabeledField label="Last Active" value={data.lastActive} icon={<ThunderboltOutlined />} />
|
||||
</Space>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Record<string, boolean>>({});
|
||||
|
||||
// Add local state to maintain key data and track regeneration
|
||||
const [currentKeyData, setCurrentKeyData] = useState<KeyResponse | undefined>(keyData);
|
||||
const [lastRegeneratedAt, setLastRegeneratedAt] = useState<Date | null>(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<KeyResponse>) => {
|
||||
// Update local state immediately with ALL the new data
|
||||
setCurrentKeyData((prevData) => {
|
||||
|
|
@ -346,79 +334,29 @@ export default function KeyInfoView({
|
|||
|
||||
return (
|
||||
<div className="w-full h-screen p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
|
||||
{backButtonText}
|
||||
</Button>
|
||||
<Title>{currentKeyData.key_alias || "Virtual Key"}</Title>
|
||||
|
||||
<div className="flex items-center cursor-pointer mb-2 space-y-6">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-400 uppercase tracking-wide mt-2">Key ID</Text>
|
||||
<Text className="text-gray-500 font-mono text-sm">{currentKeyData.token_id || currentKeyData.token}</Text>
|
||||
</div>
|
||||
<AntdButton
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copiedStates["key-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add timestamp and regeneration indicator */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Text className="text-sm text-gray-500">
|
||||
{currentKeyData.updated_at && currentKeyData.updated_at !== currentKeyData.created_at
|
||||
? `Updated: ${formatTimestamp(currentKeyData.updated_at)}`
|
||||
: `Created: ${formatTimestamp(currentKeyData.created_at)}`}
|
||||
</Text>
|
||||
|
||||
{isRecentlyRegenerated && (
|
||||
<Badge color="green" size="xs" className="animate-pulse">
|
||||
Recently Regenerated
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{lastRegeneratedAt && (
|
||||
<Badge color="blue" size="xs">
|
||||
Regenerated
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canModifyKey && (
|
||||
<div className="flex gap-2">
|
||||
<Tooltip
|
||||
title={!premiumUser ? "This is a LiteLLM Enterprise feature, and requires a valid key to use." : ""}
|
||||
>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
icon={RefreshIcon}
|
||||
variant="secondary"
|
||||
onClick={() => setIsRegenerateModalOpen(true)}
|
||||
className="flex items-center"
|
||||
disabled={!premiumUser}
|
||||
>
|
||||
Regenerate Key
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
variant="secondary"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="flex items-center text-red-500 border-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<KeyInfoHeader
|
||||
data={{
|
||||
keyName: currentKeyData.key_alias || "Virtual Key",
|
||||
keyId: currentKeyData.token_id || currentKeyData.token,
|
||||
userId: currentKeyData.user_id || "",
|
||||
userEmail: currentKeyData.user_email || "",
|
||||
createdBy: currentKeyData.user_email || currentKeyData.user_id || "",
|
||||
createdAt: currentKeyData.created_at ? formatTimestamp(currentKeyData.created_at) : "",
|
||||
lastUpdated: currentKeyData.updated_at ? formatTimestamp(currentKeyData.updated_at) : "",
|
||||
lastActive: currentKeyData.last_active ? formatTimestamp(currentKeyData.last_active) : "Never",
|
||||
}}
|
||||
onBack={onClose}
|
||||
onRegenerate={() => 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 */}
|
||||
<RegenerateKeyModal
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue