Merge pull request #36913 from BerriAI/litellm_shadcn_permissions_0814

refactor(ui): migrate key info and permissions views off antd and tremor
This commit is contained in:
yuneng-jiang 2026-08-14 10:15:28 -07:00 committed by GitHub
commit 3d35cff9f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 416 additions and 384 deletions

View file

@ -2749,11 +2749,6 @@
"count": 1
}
},
"src/components/permissions/AgentPermissions.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/permissions/MCPServerPermissions.tsx": {
"no-nested-ternary": {
"count": 3
@ -2762,11 +2757,6 @@
"count": 2
}
},
"src/components/permissions/VectorStorePermissions.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/policies/PolicySelector.tsx": {
"no-nested-ternary": {
"count": 1
@ -3045,9 +3035,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -3062,11 +3049,6 @@
"count": 1
}
},
"src/components/templates/KeyInfoHeader.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/templates/key_edit_view.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3096,9 +3078,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { Text, Badge } from "@tremor/react";
import { UserGroupIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getAgentsList } from "../networking";
interface Agent {
@ -58,10 +58,8 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
<div className="space-y-3">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-purple-600" />
<Text className="font-semibold text-gray-900">Agents</Text>
<Badge color="purple" size="xs">
{totalCount}
</Badge>
<p className="text-sm font-semibold text-gray-900">Agents</p>
<Badge variant="secondary">{totalCount}</Badge>
</div>
{totalCount > 0 ? (
@ -71,14 +69,17 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
<div className="flex items-center gap-3 py-2 px-3 rounded-lg border border-gray-200 bg-white">
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "agent" ? (
<Tooltip title={`Full ID: ${item.value}`} placement="top">
<div className="inline-flex items-center gap-2 min-w-0">
<span className="inline-block w-1.5 h-1.5 bg-purple-500 rounded-full shrink-0"></span>
<span className="text-sm font-medium text-gray-900 truncate">
{getAgentDisplayName(item.value)}
</span>
</div>
</Tooltip>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger render={<div className="inline-flex items-center gap-2 min-w-0" />}>
<span className="inline-block w-1.5 h-1.5 bg-purple-500 rounded-full shrink-0"></span>
<span className="text-sm font-medium text-gray-900 truncate">
{getAgentDisplayName(item.value)}
</span>
</TooltipTrigger>
<TooltipContent>{`Full ID: ${item.value}`}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div className="inline-flex items-center gap-2 min-w-0">
<span className="inline-block w-1.5 h-1.5 bg-green-500 rounded-full shrink-0"></span>
@ -96,7 +97,7 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<UserGroupIcon className="h-4 w-4 text-gray-400" />
<Text className="text-gray-500 text-sm">No agents or access groups configured</Text>
<p className="text-gray-500 text-sm">No agents or access groups configured</p>
</div>
)}
</div>

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { Text, Badge } from "@tremor/react";
import { DatabaseIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { vectorStoreListCall } from "../networking";
interface VectorStoreDetails {
@ -52,10 +52,8 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
<div className="space-y-3">
<div className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-blue-600" />
<Text className="font-semibold text-gray-900">Vector Stores</Text>
<Badge color="blue" size="xs">
{vectorStores.length}
</Badge>
<p className="text-sm font-semibold text-gray-900">Vector Stores</p>
<Badge variant="secondary">{vectorStores.length}</Badge>
</div>
{vectorStores.length > 0 ? (
@ -63,7 +61,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
{vectorStores.map((store, index) => (
<div
key={index}
className="inline-flex items-center px-3 py-1.5 rounded-lg bg-blue-50 border border-blue-200 text-blue-800 text-sm font-medium"
className="inline-flex min-w-0 items-center px-3 py-1.5 rounded-lg bg-blue-50 border border-blue-200 text-blue-800 text-sm font-medium break-words"
>
{getVectorStoreDisplayName(store)}
</div>
@ -72,7 +70,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<DatabaseIcon className="h-4 w-4 text-gray-400" />
<Text className="text-gray-500 text-sm">No vector stores configured</Text>
<p className="text-gray-500 text-sm">No vector stores configured</p>
</div>
)}
</div>

View file

@ -1,5 +1,5 @@
import * as networking from "@/components/networking";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { renderWithProviders } from "../../../tests/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import MemberPermissions from "./member_permissions";
@ -9,6 +9,9 @@ vi.mock("@/components/networking", () => ({
teamPermissionsUpdateCall: vi.fn(),
}));
const checkboxFor = (endpoint: string) =>
within(screen.getByText(endpoint).closest("tr") as HTMLElement).getByRole("checkbox");
describe("MemberPermissions", () => {
afterEach(() => {
vi.clearAllMocks();
@ -69,32 +72,27 @@ describe("MemberPermissions", () => {
expect(screen.getByText("Member Permissions")).toBeInTheDocument();
});
const checkboxes = screen.getAllByRole("checkbox");
const unselectedCheckbox = checkboxes.find((cb) => !(cb as HTMLInputElement).checked);
expect(checkboxFor("/key/generate")).toBeChecked();
expect(checkboxFor("/key/list")).not.toBeChecked();
if (unselectedCheckbox) {
await act(async () => {
fireEvent.click(unselectedCheckbox);
});
await act(async () => {
fireEvent.click(checkboxFor("/key/list"));
});
await waitFor(() => {
const saveButton = screen.getByRole("button", { name: /save changes/i });
expect(saveButton).toBeInTheDocument();
});
expect(checkboxFor("/key/list")).toBeChecked();
const saveButton = screen.getByRole("button", { name: /save changes/i });
await act(async () => {
fireEvent.click(saveButton);
});
const saveButton = await screen.findByRole("button", { name: /save changes/i });
await act(async () => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(networking.teamPermissionsUpdateCall).toHaveBeenCalledWith(
"token-123",
"team-123",
expect.arrayContaining(["/key/generate", "/key/list"]),
);
});
}
await waitFor(() => {
expect(networking.teamPermissionsUpdateCall).toHaveBeenCalledWith(
"token-123",
"team-123",
expect.arrayContaining(["/key/generate", "/key/list"]),
);
});
});
it("should render team daily activity permission with correct method and description", async () => {
@ -123,11 +121,13 @@ describe("MemberPermissions", () => {
expect(screen.getByText("Member Permissions")).toBeInTheDocument();
});
const checkboxes = screen.getAllByRole("checkbox");
checkboxes.forEach((checkbox) => {
expect(checkbox).toBeDisabled();
expect(checkboxFor("/key/list")).not.toBeChecked();
await act(async () => {
fireEvent.click(checkboxFor("/key/list"));
});
expect(checkboxFor("/key/list")).not.toBeChecked();
expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
});
@ -143,32 +143,27 @@ describe("MemberPermissions", () => {
expect(screen.getByText("Member Permissions")).toBeInTheDocument();
});
const checkboxes = screen.getAllByRole("checkbox");
const unselectedCheckbox = checkboxes.find((cb) => !(cb as HTMLInputElement).checked);
await act(async () => {
fireEvent.click(checkboxFor("/key/list"));
});
if (unselectedCheckbox) {
await act(async () => {
fireEvent.click(unselectedCheckbox);
});
expect(checkboxFor("/key/list")).toBeChecked();
await waitFor(() => {
const resetButton = screen.getByRole("button", { name: /reset/i });
expect(resetButton).toBeInTheDocument();
});
vi.mocked(networking.getTeamPermissionsCall).mockResolvedValueOnce({
all_available_permissions: ["/key/generate", "/key/list"],
team_member_permissions: ["/key/generate"],
});
vi.mocked(networking.getTeamPermissionsCall).mockResolvedValueOnce({
all_available_permissions: ["/key/generate", "/key/list"],
team_member_permissions: ["/key/generate"],
});
const resetButton = await screen.findByRole("button", { name: /reset/i });
await act(async () => {
fireEvent.click(resetButton);
});
const resetButton = screen.getByRole("button", { name: /reset/i });
await act(async () => {
fireEvent.click(resetButton);
});
await waitFor(() => {
expect(networking.getTeamPermissionsCall).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(networking.getTeamPermissionsCall).toHaveBeenCalledTimes(2);
});
}
expect(checkboxFor("/key/list")).not.toBeChecked();
expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,9 @@
import { getTeamPermissionsCall, teamPermissionsUpdateCall } from "@/components/networking";
import { ReloadOutlined, SaveOutlined } from "@ant-design/icons";
import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react";
import { Button, Checkbox, Empty } from "antd";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { RotateCw, Save } from "lucide-react";
import React, { useEffect, useState } from "react";
import NotificationsManager from "../molecules/notifications_manager";
import { getPermissionInfo } from "./permission_definitions";
@ -75,36 +77,38 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
const hasPermissions = permissions.length > 0;
return (
<Card className="bg-white shadow-md rounded-md p-6">
<Card className="block bg-white shadow-md rounded-md p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b pb-4 mb-6">
<Title className="mb-2 sm:mb-0">Member Permissions</Title>
<h3 className="text-lg font-medium text-gray-900 mb-2 sm:mb-0">Member Permissions</h3>
{canEditTeam && hasChanges && (
<div className="flex gap-3">
<Button icon={<ReloadOutlined />} onClick={handleReset}>
<Button variant="outline" onClick={handleReset}>
<RotateCw className="size-3.5" />
Reset
</Button>
<Button onClick={handleSave} loading={saving} type="primary" icon={<SaveOutlined />}>
<Button onClick={handleSave} disabled={saving}>
<Save className="size-3.5" />
Save Changes
</Button>
</div>
)}
</div>
<Text className="mb-6 text-gray-600">Control what team members can do when they are not team admins.</Text>
<p className="mb-6 text-sm text-gray-600">Control what team members can do when they are not team admins.</p>
{hasPermissions ? (
<div className="overflow-x-auto">
<Table className=" min-w-full">
<TableHead>
<Table className="min-w-full">
<TableHeader>
<TableRow>
<TableHeaderCell>Method</TableHeaderCell>
<TableHeaderCell>Endpoint</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell className="sticky right-0 bg-white shadow-[-4px_0_4px_-4px_rgba(0,0,0,0.1)] text-center">
<TableHead>Method</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Description</TableHead>
<TableHead className="sticky right-0 bg-white shadow-[-4px_0_4px_-4px_rgba(0,0,0,0.1)] text-center">
Allow Access
</TableHeaderCell>
</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{permissions.map((permission) => {
const permInfo = getPermissionInfo(permission);
@ -125,8 +129,9 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
<TableCell className="text-gray-700">{permInfo.description}</TableCell>
<TableCell className="sticky right-0 bg-white shadow-[-4px_0_4px_-4px_rgba(0,0,0,0.1)] text-center">
<Checkbox
className="mx-auto"
checked={selectedPermissions.includes(permission)}
onChange={(e) => handlePermissionChange(permission, e.target.checked)}
onCheckedChange={(checked) => handlePermissionChange(permission, checked)}
disabled={!canEditTeam}
/>
</TableCell>
@ -138,7 +143,7 @@ const MemberPermissions: React.FC<MemberPermissionsProps> = ({ teamId, accessTok
</div>
) : (
<div className="py-12">
<Empty description="No permissions available" />
<p className="text-center text-sm text-gray-500">No permissions available</p>
</div>
)}
</Card>

View file

@ -1,27 +1,35 @@
import React from "react";
import { Button, Typography, Tooltip, Space, Divider, Flex, Popover, Dropdown, Tag } from "antd";
import type { MenuProps } from "antd";
import {
ArrowLeftOutlined,
SyncOutlined,
DeleteOutlined,
PlusOutlined,
UserOutlined,
CalendarOutlined,
ClockCircleOutlined,
ThunderboltOutlined,
SafetyCertificateOutlined,
TransactionOutlined,
FieldTimeOutlined,
MoreOutlined,
StopOutlined,
CheckCircleOutlined,
} from "@ant-design/icons";
ArrowLeft,
ArrowLeftRight,
Ban,
Calendar,
CircleCheck,
Clock,
MoreVertical,
Plus,
RefreshCw,
ShieldCheck,
Timer,
Trash2,
User,
Zap,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import CopyButton from "@/components/shared/CopyButton";
import LabeledField from "../common_components/LabeledField";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
const { Title, Text } = Typography;
export interface KeyInfoData {
keyName: string;
keyId: string;
@ -52,14 +60,12 @@ interface KeyInfoHeaderProps {
function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null; userEmail: string; userId: string }) {
const labelEl = (
<Space size={4}>
<Text type="secondary">
<UserOutlined />
</Text>
<Text type="secondary" style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em" }}>
User
</Text>
</Space>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">
<User className="size-3.5" />
</span>
<span className="text-xs uppercase tracking-[0.05em] text-muted-foreground">User</span>
</div>
);
const isEmpty = !userAlias && !userEmail && !userId;
@ -68,7 +74,7 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
<div>
{labelEl}
<div>
<Text strong>-</Text>
<span className="font-semibold">-</span>
</div>
</div>
);
@ -87,14 +93,12 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
<div key={label} className="flex flex-col min-w-0">
<span className="text-gray-400">{label}</span>
{value ? (
<Typography.Text
className="font-mono text-xs"
style={{ maxWidth: 220 }}
ellipsis={{ tooltip: value }}
copyable
>
{value}
</Typography.Text>
<div className="flex min-w-0 items-center gap-1">
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={value}>
{value}
</span>
<CopyButton value={value} label={`Copy ${label}`} iconClassName="size-3.5" />
</div>
) : (
<span className="font-mono">-</span>
)}
@ -108,11 +112,18 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
<div>
{labelEl}
<div>
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span className="cursor-default">
<DefaultProxyAdminTag userId={userId} />
</span>
</Popover>
<HoverCard>
<HoverCardTrigger
render={
<span className="cursor-default">
<DefaultProxyAdminTag userId={userId} />
</span>
}
/>
<HoverCardContent side="bottom" align="start" className="w-auto">
{popoverContent}
</HoverCardContent>
</HoverCard>
</div>
</div>
);
@ -122,11 +133,14 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
<div>
{labelEl}
<div>
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<Text strong ellipsis style={{ cursor: "default", maxWidth: 200, display: "block" }}>
{displayValue}
</Text>
</Popover>
<HoverCard>
<HoverCardTrigger
render={<span className="block max-w-[200px] cursor-default truncate font-semibold">{displayValue}</span>}
/>
<HoverCardContent side="bottom" align="start" className="w-auto">
{popoverContent}
</HoverCardContent>
</HoverCard>
</div>
</div>
);
@ -146,104 +160,124 @@ export function KeyInfoHeader({
regenerateDisabled = false,
regenerateTooltip,
}: KeyInfoHeaderProps) {
const destructiveActionItems: MenuProps["items"] = [
...(onToggleBlocked
? [
isBlocked
? { key: "unblock", label: "Unblock Key", icon: <CheckCircleOutlined /> }
: { key: "block", label: "Block Key", icon: <StopOutlined />, danger: true },
]
: []),
...(onResetSpend
? [{ key: "reset-spend", label: "Reset Spend", icon: <TransactionOutlined />, danger: true }]
: []),
{ key: "delete", label: "Delete Key", icon: <DeleteOutlined />, danger: true },
];
const handleDestructiveActionClick: MenuProps["onClick"] = ({ key }) => {
if (key === "block" || key === "unblock") onToggleBlocked?.();
if (key === "reset-spend") onResetSpend?.();
if (key === "delete") onDelete?.();
};
const regenerateButton = (
<span>
<Button variant="outline" onClick={onRegenerate} disabled={regenerateDisabled}>
<RefreshCw className="size-3.5" />
Regenerate Key
</Button>
</span>
);
return (
<div>
{onCreateNew && (
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateNew}>
<Button onClick={onCreateNew}>
<Plus className="size-3.5" />
Create New Key
</Button>
</div>
)}
<div style={{ marginBottom: 16 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack}>
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="size-3.5" />
{backButtonText}
</Button>
</div>
<Flex justify="space-between" align="start" style={{ marginBottom: 20 }}>
<div>
<Space align="center">
<Title level={3} copyable={{ tooltips: ["Copy Key Alias", "Copied!"] }} style={{ margin: 0 }}>
<div className="flex items-start justify-between" style={{ marginBottom: 20 }}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="m-0 flex items-center gap-1 text-2xl font-semibold">
{data.keyName}
</Title>
<CopyButton value={data.keyName} label="Copy Key Alias" iconClassName="size-4" />
</h3>
{isBlocked && (
<Tag color="red" icon={<StopOutlined />}>
<Badge variant="destructive">
<Ban className="size-3" />
Blocked
</Tag>
</Badge>
)}
</Space>
<Text type="secondary" copyable={{ text: data.keyId, tooltips: ["Copy Key ID", "Copied!"] }}>
Key ID: {data.keyId}
</Text>
</div>
<div className="flex min-w-0 items-center gap-1">
<span className="min-w-0 break-words text-muted-foreground">Key ID: {data.keyId}</span>
<CopyButton value={data.keyId} label="Copy Key ID" iconClassName="size-3.5" />
</div>
</div>
{canModifyKey && (
<Space>
<Tooltip title={regenerateTooltip || ""}>
<span>
<Button icon={<SyncOutlined />} onClick={onRegenerate} disabled={regenerateDisabled}>
Regenerate Key
</Button>
</span>
</Tooltip>
<Dropdown
menu={{ items: destructiveActionItems, onClick: handleDestructiveActionClick }}
trigger={["click"]}
>
<Button icon={<MoreOutlined />} aria-label="More key actions" />
</Dropdown>
</Space>
<div className="flex items-center gap-2">
{regenerateTooltip ? (
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger render={regenerateButton} />
<TooltipContent>{regenerateTooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
regenerateButton
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="More key actions" />}>
<MoreVertical className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
{onToggleBlocked &&
(isBlocked ? (
<DropdownMenuItem onClick={onToggleBlocked}>
<CircleCheck className="size-3.5" />
Unblock Key
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive" onClick={onToggleBlocked}>
<Ban className="size-3.5" />
Block Key
</DropdownMenuItem>
))}
{onResetSpend && (
<DropdownMenuItem variant="destructive" onClick={onResetSpend}>
<ArrowLeftRight className="size-3.5" />
Reset Spend
</DropdownMenuItem>
)}
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="size-3.5" />
Delete Key
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</Flex>
</div>
<Flex align="stretch" gap={40} style={{ marginBottom: 40 }}>
<Space direction="vertical" size={16}>
<div className="flex items-stretch gap-10" style={{ marginBottom: 40 }}>
<div className="flex min-w-0 flex-col gap-4">
<UserField userAlias={data.userAlias} userEmail={data.userEmail} userId={data.userId} />
<LabeledField label="Expires" value={data.expires} icon={<FieldTimeOutlined />} />
</Space>
<LabeledField label="Expires" value={data.expires} icon={<Timer className="size-3.5" />} />
</div>
<Divider type="vertical" style={{ height: "auto" }} />
<Separator orientation="vertical" />
<Space direction="vertical" size={16}>
<LabeledField label="Created At" value={data.createdAt} icon={<CalendarOutlined />} />
<div className="flex min-w-0 flex-col gap-4">
<LabeledField label="Created At" value={data.createdAt} icon={<Calendar className="size-3.5" />} />
<LabeledField
label="Created By"
value={data.createdBy}
icon={<SafetyCertificateOutlined />}
icon={<ShieldCheck className="size-3.5" />}
truncate
copyable
defaultUserIdCheck
/>
</Space>
</div>
<Divider type="vertical" style={{ height: "auto" }} />
<Separator orientation="vertical" />
<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 className="flex min-w-0 flex-col gap-4">
<LabeledField label="Last Updated" value={data.lastUpdated} icon={<Clock className="size-3.5" />} />
<LabeledField label="Last Active" value={data.lastActive} icon={<Zap className="size-3.5" />} />
</div>
</div>
</div>
);
}

View file

@ -182,20 +182,6 @@ vi.mock("@heroicons/react/outline", async () => {
return { ArrowLeftIcon, TrashIcon, RefreshIcon };
});
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
const React = await import("react");
function CopyIcon() {
return React.createElement("span");
}
(CopyIcon as any).displayName = "CopyIcon";
function CheckIcon() {
return React.createElement("span");
}
(CheckIcon as any).displayName = "CheckIcon";
return { ...actual, CopyIcon, CheckIcon };
});
// Heavy children -> async factories & local React
vi.mock("../organisms/RegenerateKeyModal", () => {
function RegenerateKeyModal() {

View file

@ -4,9 +4,12 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { Modal, Tag } from "antd";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { KeyInfoHeader } from "./KeyInfoHeader";
import { useEffect, useState } from "react";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles";
@ -150,10 +153,11 @@ export default function KeyInfoView({
if (!currentKeyData) {
return (
<div className="p-4">
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
{backButtonText}
</Button>
<Text>Key not found</Text>
<p className="text-sm">Key not found</p>
</div>
);
}
@ -534,93 +538,111 @@ export default function KeyInfoView({
/>
{/* Reset Spend Confirmation Modal */}
<Modal
title="Reset Key Spend"
open={isResetSpendModalOpen}
onOk={handleResetSpend}
onCancel={() => setIsResetSpendModalOpen(false)}
okText="Reset"
okButtonProps={{ danger: true }}
confirmLoading={resetSpendLoading}
>
<p>
Reset spend for <strong>{currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}</strong> to{" "}
<strong>$0</strong>?
</p>
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: 8 }}>
Current spend: <strong>${formatNumberWithCommas(currentKeyData.spend, 4)}</strong>. Spend history is preserved
in logs. This resets the current period spend counter, the same as an automatic budget reset.
</p>
</Modal>
<Dialog open={isResetSpendModalOpen} onOpenChange={(open) => setIsResetSpendModalOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Key Spend</DialogTitle>
</DialogHeader>
<p>
Reset spend for <strong>{currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}</strong> to{" "}
<strong>$0</strong>?
</p>
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: 8 }}>
Current spend: <strong>${formatNumberWithCommas(currentKeyData.spend, 4)}</strong>. Spend history is
preserved in logs. This resets the current period spend counter, the same as an automatic budget reset.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setIsResetSpendModalOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleResetSpend} disabled={resetSpendLoading}>
Reset
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Modal
title={isBlocked ? "Unblock Key" : "Block Key"}
open={isBlockModalOpen}
onOk={handleToggleBlocked}
onCancel={() => setIsBlockModalOpen(false)}
okText={isBlocked ? "Unblock" : "Block"}
okButtonProps={isBlocked ? undefined : { danger: true }}
confirmLoading={blockLoading}
>
<p>
{isBlocked ? "Unblock" : "Block"}{" "}
<strong>{currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}</strong>?
</p>
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: 8 }}>
{isBlocked
? "Requests using this key will be accepted again."
: "Requests using this key will be rejected with a 401 error until it is unblocked. The key is not deleted and can be unblocked at any time."}
</p>
</Modal>
<Dialog open={isBlockModalOpen} onOpenChange={(open) => setIsBlockModalOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isBlocked ? "Unblock Key" : "Block Key"}</DialogTitle>
</DialogHeader>
<p>
{isBlocked ? "Unblock" : "Block"}{" "}
<strong>{currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}</strong>?
</p>
<p style={{ color: "#666", fontSize: "0.875rem", marginTop: 8 }}>
{isBlocked
? "Requests using this key will be accepted again."
: "Requests using this key will be rejected with a 401 error until it is unblocked. The key is not deleted and can be unblocked at any time."}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setIsBlockModalOpen(false)}>
Cancel
</Button>
<Button
variant={isBlocked ? "default" : "destructive"}
onClick={handleToggleBlocked}
disabled={blockLoading}
>
{isBlocked ? "Unblock" : "Block"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TabGroup>
<TabList className="mb-4">
<Tab>Overview</Tab>
<Tab>Settings</Tab>
</TabList>
<Tabs defaultValue="overview">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabPanels>
<div>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Spend</Text>
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p className="text-sm">Spend</p>
<div className="mt-2">
<Title>${formatNumberWithCommas(currentKeyData.spend, 4)}</Title>
<Text>of {budgetDisplay}</Text>
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
<p className="text-sm">of {budgetDisplay}</p>
{currentKeyData.budget_reset_at && (
<Text>Resets {formatTimestamp(currentKeyData.budget_reset_at)}</Text>
<p className="text-sm">Resets {formatTimestamp(currentKeyData.budget_reset_at)}</p>
)}
</div>
</Card>
<Card>
<Text>Rate Limits</Text>
<Card className="block p-6">
<p className="text-sm">Rate Limits</p>
<div className="mt-2">
<Text>TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}</Text>
<Text>RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}</Text>
<p className="text-sm">
TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}
</p>
<p className="text-sm">
RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}
</p>
{Boolean(currentKeyData.metadata?.throttle_on_budget_exceeded) && (
<Text>Throttle on budget exceeded: Yes</Text>
<p className="text-sm">Throttle on budget exceeded: Yes</p>
)}
</div>
</Card>
<Card>
<Text>Models</Text>
<Card className="block p-6">
<p className="text-sm">Models</p>
<div className="mt-2 flex flex-wrap gap-2">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => (
<Badge key={index} color="red">
<Badge key={index} variant="secondary" className="min-w-0 break-words">
{model}
</Badge>
))
) : (
<Text>No models specified</Text>
<p className="text-sm">No models specified</p>
)}
</div>
</Card>
<Card>
<Card className="block p-6">
<ObjectPermissionsView
objectPermission={currentKeyData.object_permission}
variant="inline"
@ -628,43 +650,45 @@ export default function KeyInfoView({
/>
</Card>
<Card>
<Text className="font-medium mb-3">Guardrails</Text>
<Card className="block p-6">
<p className="text-sm font-medium mb-3">Guardrails</p>
{Array.isArray(currentKeyData.metadata?.guardrails) && currentKeyData.metadata.guardrails.length > 0 ? (
<div className="flex flex-wrap gap-2">
{currentKeyData.metadata.guardrails.map((guardrail: string, index: number) => (
<Badge key={index} color="blue">
<Badge key={index} variant="secondary" className="min-w-0 break-words">
{guardrail}
</Badge>
))}
</div>
) : (
<Text className="text-gray-500">No guardrails configured</Text>
<p className="text-sm text-gray-500">No guardrails configured</p>
)}
{typeof currentKeyData.metadata?.disable_global_guardrails === "boolean" &&
currentKeyData.metadata.disable_global_guardrails === true && (
<div className="mt-3 pt-3 border-t border-gray-200">
<Badge color="yellow">Global Guardrails Disabled</Badge>
<Badge variant="destructive">Global Guardrails Disabled</Badge>
</div>
)}
</Card>
<Card>
<Text className="font-medium mb-3">Policies</Text>
<Card className="block p-6">
<p className="text-sm font-medium mb-3">Policies</p>
{Array.isArray(currentKeyData.metadata?.policies) && currentKeyData.metadata.policies.length > 0 ? (
<div className="space-y-4">
{currentKeyData.metadata.policies.map((policy: string, index: number) => (
<div key={index} className="space-y-2">
<div className="flex items-center gap-2">
<Badge color="purple">{policy}</Badge>
{loadingPolicies && <Text className="text-xs text-gray-400">Loading guardrails...</Text>}
<Badge variant="secondary" className="min-w-0 break-words">
{policy}
</Badge>
{loadingPolicies && <p className="text-xs text-gray-400">Loading guardrails...</p>}
</div>
{!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && (
<div className="ml-4 pl-3 border-l-2 border-gray-200">
<Text className="text-xs text-gray-500 mb-1">Resolved Guardrails:</Text>
<p className="text-xs text-gray-500 mb-1">Resolved Guardrails:</p>
<div className="flex flex-wrap gap-1">
{policyGuardrails[policy].map((guardrail: string, gIndex: number) => (
<Badge key={gIndex} color="blue" size="xs">
<Badge key={gIndex} variant="secondary" className="min-w-0 break-words">
{guardrail}
</Badge>
))}
@ -675,7 +699,7 @@ export default function KeyInfoView({
))}
</div>
) : (
<Text className="text-gray-500">No policies configured</Text>
<p className="text-sm text-gray-500">No policies configured</p>
)}
</Card>
@ -697,15 +721,19 @@ export default function KeyInfoView({
nextRotationAt={currentKeyData.next_rotation_at}
variant="card"
/>
</Grid>
</TabPanel>
</div>
</TabsContent>
{/* Settings Panel */}
<TabPanel>
<Card>
<TabsContent value="settings" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<Title>Key Settings</Title>
{!isEditing && canModifyKey && <Button onClick={() => setIsEditing(true)}>Edit Settings</Button>}
<h3 className="text-lg font-medium">Key Settings</h3>
{!isEditing && canModifyKey && (
<Button variant="outline" onClick={() => setIsEditing(true)}>
Edit Settings
</Button>
)}
</div>
{isEditing ? (
@ -722,29 +750,29 @@ export default function KeyInfoView({
) : (
<div className="space-y-4">
<div>
<Text className="font-medium">Key ID</Text>
<Text className="font-mono">{currentKeyData.token_id || currentKeyData.token}</Text>
<p className="text-sm font-medium">Key ID</p>
<p className="text-sm font-mono">{currentKeyData.token_id || currentKeyData.token}</p>
</div>
<div>
<Text className="font-medium">Key Alias</Text>
<Text>{currentKeyData.key_alias || "Not Set"}</Text>
<p className="text-sm font-medium">Key Alias</p>
<p className="text-sm">{currentKeyData.key_alias || "Not Set"}</p>
</div>
<div>
<Text className="font-medium">Secret Key</Text>
<Text className="font-mono">{currentKeyData.key_name}</Text>
<p className="text-sm font-medium">Secret Key</p>
<p className="text-sm font-mono">{currentKeyData.key_name}</p>
</div>
<div>
<Text className="font-medium">Team ID</Text>
<Text>{currentKeyData.team_id || "Not Set"}</Text>
<p className="text-sm font-medium">Team ID</p>
<p className="text-sm">{currentKeyData.team_id || "Not Set"}</p>
</div>
{enableProjectsUI && (
<div>
<Text className="font-medium">Project</Text>
<Text>
<p className="text-sm font-medium">Project</p>
<p className="text-sm">
{currentKeyData.project_id
? (() => {
const project = projects?.find((p) => p.project_id === currentKeyData.project_id);
@ -753,41 +781,43 @@ export default function KeyInfoView({
: currentKeyData.project_id;
})()
: "Not Set"}
</Text>
</p>
</div>
)}
<div>
<Text className="font-medium">Organization</Text>
<Text>{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}</Text>
<p className="text-sm font-medium">Organization</p>
<p className="text-sm">{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}</p>
</div>
<div>
<Text className="font-medium">Created</Text>
<Text>{formatTimestamp(currentKeyData.created_at)}</Text>
<p className="text-sm font-medium">Created</p>
<p className="text-sm">{formatTimestamp(currentKeyData.created_at)}</p>
</div>
{lastRegeneratedAt && (
<div>
<Text className="font-medium">Last Regenerated</Text>
<p className="text-sm font-medium">Last Regenerated</p>
<div className="flex items-center gap-2">
<Text>{formatTimestamp(lastRegeneratedAt)}</Text>
<Badge color="green" size="xs">
Recent
</Badge>
<p className="text-sm">{formatTimestamp(lastRegeneratedAt)}</p>
<Badge variant="secondary">Recent</Badge>
</div>
</div>
)}
<div>
<Text className="font-medium">Expires</Text>
<Text>{currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"}</Text>
<p className="text-sm font-medium">Expires</p>
<p className="text-sm">
{currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"}
</p>
</div>
{Boolean(currentKeyData.metadata?.enable_prompt_caching) && (
<div>
<Text className="font-medium">Prompt Caching</Text>
<Text>Enabled (auto-injects cache_control markers on Anthropic and Bedrock Claude requests)</Text>
<p className="text-sm font-medium">Prompt Caching</p>
<p className="text-sm">
Enabled (auto-injects cache_control markers on Anthropic and Bedrock Claude requests)
</p>
</div>
)}
@ -802,31 +832,31 @@ export default function KeyInfoView({
/>
<div>
<Text className="font-medium">Spend</Text>
<Text>${formatNumberWithCommas(currentKeyData.spend, 4)} USD</Text>
<p className="text-sm font-medium">Spend</p>
<p className="text-sm">${formatNumberWithCommas(currentKeyData.spend, 4)} USD</p>
</div>
<div>
<Text className="font-medium">Budget</Text>
<Text>
<p className="text-sm font-medium">Budget</p>
<p className="text-sm">
{currentKeyData.max_budget !== null
? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}`
: "Unlimited"}
</Text>
</p>
</div>
<div>
<Text className="font-medium">Budget Reset</Text>
<Text>
<p className="text-sm font-medium">Budget Reset</p>
<p className="text-sm">
{currentKeyData.budget_reset_at
? `${currentKeyData.budget_duration ? `Every ${currentKeyData.budget_duration}, next ` : ""}${formatTimestamp(currentKeyData.budget_reset_at)}`
: "Never"}
</Text>
</p>
</div>
{currentKeyData.budget_fallbacks && Object.keys(currentKeyData.budget_fallbacks).length > 0 && (
<div>
<Text className="font-medium">Budget Fallbacks</Text>
<p className="text-sm font-medium">Budget Fallbacks</p>
<div className="mt-1 space-y-1">
{Object.entries(currentKeyData.budget_fallbacks).map(([model, fallbacks]) => (
<div key={model} className="text-xs text-gray-600">
@ -841,7 +871,7 @@ export default function KeyInfoView({
{hasRouterSettings(currentKeyData.router_settings) && (
<div>
<Text className="font-medium">Router Settings</Text>
<p className="text-sm font-medium">Router Settings</p>
<div className="mt-1">
<RouterSettingsSummary routerSettings={currentKeyData.router_settings} />
</div>
@ -849,7 +879,7 @@ export default function KeyInfoView({
)}
<div>
<Text className="font-medium">Tags</Text>
<p className="text-sm font-medium">Tags</p>
<div className="flex flex-wrap gap-2 mt-1">
{Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0
? currentKeyData.metadata.tags.map((tag, index) => (
@ -862,8 +892,8 @@ export default function KeyInfoView({
</div>
<div>
<Text className="font-medium">Prompts</Text>
<Text>
<p className="text-sm font-medium">Prompts</p>
<p className="text-sm">
{Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0
? currentKeyData.metadata.prompts.map((prompt, index) => (
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded-sm text-xs">
@ -871,11 +901,11 @@ export default function KeyInfoView({
</span>
))
: "No prompts specified"}
</Text>
</p>
</div>
<div>
<Text className="font-medium">Allowed Routes</Text>
<p className="text-sm font-medium">Allowed Routes</p>
<div className="flex flex-wrap gap-2 mt-1">
{Array.isArray(currentKeyData.allowed_routes) && currentKeyData.allowed_routes.length > 0 ? (
currentKeyData.allowed_routes.map((route, index) => (
@ -884,14 +914,14 @@ export default function KeyInfoView({
</span>
))
) : (
<Tag color="green">All routes allowed</Tag>
<Badge variant="secondary">All routes allowed</Badge>
)}
</div>
</div>
<div>
<Text className="font-medium">Allowed Pass Through Routes</Text>
<Text>
<p className="text-sm font-medium">Allowed Pass Through Routes</p>
<p className="text-sm">
{Array.isArray(currentKeyData.metadata?.allowed_passthrough_routes) &&
currentKeyData.metadata.allowed_passthrough_routes.length > 0
? currentKeyData.metadata.allowed_passthrough_routes.map((route, index) => (
@ -900,22 +930,22 @@ export default function KeyInfoView({
</span>
))
: "No pass through routes specified"}
</Text>
</p>
</div>
<div>
<Text className="font-medium">Disable Global Guardrails</Text>
<Text>
<p className="text-sm font-medium">Disable Global Guardrails</p>
<p className="text-sm">
{currentKeyData.metadata?.disable_global_guardrails === true ? (
<Badge color="yellow">Enabled - Global guardrails bypassed</Badge>
<Badge variant="destructive">Enabled - Global guardrails bypassed</Badge>
) : (
<Badge color="green">Disabled - Global guardrails active</Badge>
<Badge variant="secondary">Disabled - Global guardrails active</Badge>
)}
</Text>
</p>
</div>
<div>
<Text className="font-medium">Models</Text>
<p className="text-sm font-medium">Models</p>
<div className="flex flex-wrap gap-2 mt-1">
{currentKeyData.models && currentKeyData.models.length > 0 ? (
currentKeyData.models.map((model, index) => (
@ -924,56 +954,60 @@ export default function KeyInfoView({
</span>
))
) : (
<Text>No models specified</Text>
<p className="text-sm">No models specified</p>
)}
</div>
</div>
<div>
<Text className="font-medium">Rate Limits</Text>
<Text>TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}</Text>
<Text>RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}</Text>
<Text>
<p className="text-sm font-medium">Rate Limits</p>
<p className="text-sm">
TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}
</p>
<p className="text-sm">
RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}
</p>
<p className="text-sm">
Max Parallel Requests:{" "}
{currentKeyData.max_parallel_requests !== null
? currentKeyData.max_parallel_requests
: "Unlimited"}
</Text>
<Text>
</p>
<p className="text-sm">
Model TPM Limits:{" "}
{currentKeyData.metadata?.model_tpm_limit
? JSON.stringify(currentKeyData.metadata.model_tpm_limit)
: "Unlimited"}
</Text>
<Text>
</p>
<p className="text-sm">
Model RPM Limits:{" "}
{currentKeyData.metadata?.model_rpm_limit
? JSON.stringify(currentKeyData.metadata.model_rpm_limit)
: "Unlimited"}
</Text>
<Text>
</p>
<p className="text-sm">
Tag RPM Limits:{" "}
{currentKeyData.metadata?.tag_rpm_limit &&
Object.keys(currentKeyData.metadata.tag_rpm_limit).length > 0
? JSON.stringify(currentKeyData.metadata.tag_rpm_limit)
: "Unlimited"}
</Text>
<Text>
</p>
<p className="text-sm">
Estimated Output Tokens:{" "}
{currentKeyData.metadata?.default_estimated_output_tokens != null
? String(currentKeyData.metadata.default_estimated_output_tokens)
: "Default"}
</Text>
<Text>
</p>
<p className="text-sm">
Estimated Output Tokens Per Model:{" "}
{currentKeyData.metadata?.default_estimated_output_tokens_per_model
? JSON.stringify(currentKeyData.metadata.default_estimated_output_tokens_per_model)
: "Default"}
</Text>
</p>
</div>
<div>
<Text className="font-medium">Metadata</Text>
<p className="text-sm font-medium">Metadata</p>
<pre className="bg-gray-100 p-2 rounded-sm text-xs overflow-auto mt-1">
{formatMetadataForDisplay(stripTagsFromMetadata(currentKeyData.metadata))}
</pre>
@ -999,9 +1033,9 @@ export default function KeyInfoView({
</div>
)}
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
</div>
</Tabs>
</div>
);
}