import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alertDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { resetDurationLabels } from "@/lib/constants/governance";
import { getErrorMessage, useDeleteVirtualKeyMutation, useLazyGetVirtualKeysQuery } from "@/lib/store";
import { Customer, Team, VirtualKey } from "@/lib/types/governance";
import { cn } from "@/lib/utils";
import { RateLimitDisplay } from "@/components/rateLimitDisplay";
import { formatCurrency } from "@/lib/utils/governance";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { useVirtualKeyUsage } from "../hooks/useVirtualKeyUsage";
import {
ArrowUpDown,
ChevronLeft,
ChevronRight,
Copy,
Download,
Edit,
Eye,
EyeOff,
Loader2,
Plus,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import VirtualKeyDetailSheet from "./virtualKeyDetailsSheet";
import { VirtualKeysEmptyState } from "./virtualKeysEmptyState";
import VirtualKeySheet from "./virtualKeySheet";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
const formatResetDuration = (duration: string) => resetDurationLabels[duration] || duration;
type ExportScope = "current_page" | "all";
function virtualKeysToCSV(vks: VirtualKey[]): string {
const headers = ["Name", "Status", "Assigned To", "Budget Limit", "Budget Spent", "Budget Reset", "Description", "Created At"];
const rows = vks.map((vk) => {
const isExhausted =
vk.budgets?.some((b) => b.current_usage >= b.max_limit) ||
(vk.rate_limit?.token_current_usage &&
vk.rate_limit?.token_max_limit &&
vk.rate_limit.token_current_usage >= vk.rate_limit.token_max_limit) ||
(vk.rate_limit?.request_current_usage &&
vk.rate_limit?.request_max_limit &&
vk.rate_limit.request_current_usage >= vk.rate_limit.request_max_limit);
const status = vk.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive";
const assignedTo = vk.team ? `Team: ${vk.team.name}` : vk.customer ? `Customer: ${vk.customer.name}` : "";
const budgetLimit = vk.budgets?.length ? vk.budgets.map((b) => formatCurrency(b.max_limit)).join("; ") : "";
const budgetSpent = vk.budgets?.length ? vk.budgets.map((b) => formatCurrency(b.current_usage)).join("; ") : "";
const budgetReset = vk.budgets?.length ? vk.budgets.map((b) => formatResetDuration(b.reset_duration)).join("; ") : "";
return [vk.name, status, assignedTo, budgetLimit, budgetSpent, budgetReset, vk.description || "", vk.created_at];
});
return [headers, ...rows].map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")).join("\n");
}
function downloadCSV(content: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `virtual-keys-${new Date().toISOString().split("T")[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
}
function VKBudgetCell({ vk }: { vk: VirtualKey }) {
const { displayBudgets } = useVirtualKeyUsage(vk);
if (!displayBudgets || displayBudgets.length === 0) {
return - ;
}
return (
{displayBudgets.map((b, idx) => (
= b.max_limit && "text-red-400")}>
{formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)}
Resets {formatResetDuration(b.reset_duration)}
{vk.calendar_aligned && " (calendar)"}
))}
);
}
function VKRateLimitCell({ vk }: { vk: VirtualKey }) {
const { displayRateLimit } = useVirtualKeyUsage(vk);
return ;
}
// Status badge derives exhaustion from the same AP-backed source as the budget/rate-limit cells
// so managed keys don't show "Active" next to an exhausted-looking bar.
function VKStatusBadge({ vk }: { vk: VirtualKey }) {
const { isExhausted } = useVirtualKeyUsage(vk);
return (
{vk.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"}
);
}
// Per-row delete button. Calls useVirtualKeyUsage (same cached query as the budget/
// rate-limit cells — RTK dedupes) to detect managed-by-AP VKs and swap the normal
// delete AlertDialog for a disabled button + tooltip so users aren't lured into a
// confirm-then-403 loop.
function VKDeleteButton({
vk,
hasDeleteAccess,
isDeleting,
onDelete,
}: {
vk: VirtualKey;
hasDeleteAccess: boolean;
isDeleting: boolean;
onDelete: (vkId: string) => void;
}) {
const { isManagedByProfile } = useVirtualKeyUsage(vk);
if (isManagedByProfile) {
return (
This virtual key is managed by an access profile and can't be deleted here. Detach the profile from the user or delete it
from the access profile settings.
);
}
return (
e.stopPropagation()}
disabled={!hasDeleteAccess}
data-testid={`vk-delete-btn-${vk.name}`}
>
Delete Virtual Key
Are you sure you want to delete "{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name}"? This action cannot be undone.
Cancel
onDelete(vk.id)}
disabled={isDeleting}
className="bg-destructive hover:bg-destructive/90"
data-testid={`vk-delete-confirm-${vk.name}`}
>
{isDeleting ? "Deleting..." : "Delete"}
);
}
interface VirtualKeysTableProps {
virtualKeys: VirtualKey[];
totalCount: number;
teams: Team[];
customers: Customer[];
search: string;
debouncedSearch: string;
onSearchChange: (value: string) => void;
customerFilter: string;
onCustomerFilterChange: (value: string) => void;
teamFilter: string;
onTeamFilterChange: (value: string) => void;
offset: number;
limit: number;
onOffsetChange: (offset: number) => void;
sortBy?: string;
order?: string;
onSortChange: (sortBy: string, order: string) => void;
}
export default function VirtualKeysTable({
virtualKeys,
totalCount,
teams,
customers,
search,
debouncedSearch,
onSearchChange,
customerFilter,
onCustomerFilterChange,
teamFilter,
onTeamFilterChange,
offset,
limit,
onOffsetChange,
sortBy,
order,
onSortChange,
}: VirtualKeysTableProps) {
const [showVirtualKeySheet, setShowVirtualKeySheet] = useState(false);
const [editingVirtualKeyId, setEditingVirtualKeyId] = useState(null);
const [revealedKeys, setRevealedKeys] = useState>(new Set());
const [selectedVirtualKeyId, setSelectedVirtualKeyId] = useState(null);
const [showDetailSheet, setShowDetailSheet] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportScope, setExportScope] = useState("current_page");
const [exportMaxLimit, setExportMaxLimit] = useState("");
const [fetchVirtualKeys, { isFetching: isExporting }] = useLazyGetVirtualKeysQuery();
// Derive objects from props so they stay in sync with RTK cache updates
const editingVirtualKey = useMemo(
() => (editingVirtualKeyId ? (virtualKeys.find((vk) => vk.id === editingVirtualKeyId) ?? null) : null),
[editingVirtualKeyId, virtualKeys],
);
const selectedVirtualKey = useMemo(
() => (selectedVirtualKeyId ? (virtualKeys.find((vk) => vk.id === selectedVirtualKeyId) ?? null) : null),
[selectedVirtualKeyId, virtualKeys],
);
const hasCreateAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Create);
const hasUpdateAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Update);
const hasDeleteAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Delete);
const [deleteVirtualKey, { isLoading: isDeleting }] = useDeleteVirtualKeyMutation();
const handleDelete = async (vkId: string) => {
try {
await deleteVirtualKey(vkId).unwrap();
toast.success("Virtual key deleted successfully");
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const handleAddVirtualKey = () => {
setEditingVirtualKeyId(null);
setShowVirtualKeySheet(true);
};
const handleEditVirtualKey = (vk: VirtualKey, e: React.MouseEvent) => {
e.stopPropagation(); // Prevent row click
setEditingVirtualKeyId(vk.id);
setShowVirtualKeySheet(true);
};
const handleVirtualKeySaved = () => {
setShowVirtualKeySheet(false);
setEditingVirtualKeyId(null);
};
const handleRowClick = (vk: VirtualKey) => {
setSelectedVirtualKeyId(vk.id);
setShowDetailSheet(true);
};
const handleDetailSheetClose = () => {
setShowDetailSheet(false);
setSelectedVirtualKeyId(null);
};
const toggleKeyVisibility = (vkId: string) => {
const newRevealed = new Set(revealedKeys);
if (newRevealed.has(vkId)) {
newRevealed.delete(vkId);
} else {
newRevealed.add(vkId);
}
setRevealedKeys(newRevealed);
};
const maskKey = (key: string, revealed: boolean) => {
if (revealed) return key;
return key.substring(0, 8) + "•".repeat(Math.max(0, key.length - 8));
};
const { copy: copyToClipboard } = useCopyToClipboard();
const hasActiveFilters = debouncedSearch || customerFilter || teamFilter;
const toggleSort = (column: string) => {
if (sortBy === column) {
if (order === "asc") {
onSortChange(column, "desc");
} else {
// Clicking again clears sort
onSortChange("", "");
}
} else {
onSortChange(column, "asc");
}
};
const handleExportCSV = async () => {
if (exportScope === "current_page") {
downloadCSV(virtualKeysToCSV(virtualKeys));
toast.success(`Exported ${virtualKeys.length} virtual keys`);
setShowExportDialog(false);
return;
}
// Fetch all with same filters/sort applied
const maxLimit = exportMaxLimit ? parseInt(exportMaxLimit, 10) : undefined;
const fetchLimit = maxLimit && maxLimit > 0 ? maxLimit : 10000;
try {
const result = await fetchVirtualKeys({
limit: fetchLimit,
offset: 0,
search: debouncedSearch || undefined,
customer_id: customerFilter || undefined,
team_id: teamFilter || undefined,
sort_by: (sortBy as "name" | "budget_spent" | "created_at" | "status") || undefined,
order: (order as "asc" | "desc") || undefined,
export: true,
}).unwrap();
downloadCSV(virtualKeysToCSV(result.virtual_keys));
toast.success(`Exported ${result.virtual_keys.length} virtual keys`);
setShowExportDialog(false);
} catch (error) {
toast.error(`Export failed: ${getErrorMessage(error)}`);
}
};
const openExportDialog = () => {
setExportScope("current_page");
setExportMaxLimit("");
setShowExportDialog(true);
};
const SortableHeader = ({ column, label }: { column: string; label: string }) => (
toggleSort(column)} data-testid={`vk-sort-${column}`}>
{label}
);
// True empty state: no VKs at all (not just filtered to zero)
if (totalCount === 0 && !hasActiveFilters) {
return (
<>
{showVirtualKeySheet && (
setShowVirtualKeySheet(false)}
/>
)}
>
);
}
return (
<>
{showVirtualKeySheet && (
setShowVirtualKeySheet(false)}
/>
)}
{showDetailSheet && selectedVirtualKey && }
{/* Export Dialog */}
Export Virtual Keys
Download as CSV with current filters and sorting applied.
Export scope
setExportScope("current_page")}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-md border px-3 py-3 text-sm transition-colors",
exportScope === "current_page"
? "border-primary bg-primary/5 text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground",
)}
>
Current page
{virtualKeys.length} entries
setExportScope("all")}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-md border px-3 py-3 text-sm transition-colors",
exportScope === "all"
? "border-primary bg-primary/5 text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground",
)}
>
All entries
{totalCount} total
{exportScope === "all" && (
Max entries (optional)
setExportMaxLimit(e.target.value)}
data-testid="vk-export-max-limit"
/>
)}
{hasActiveFilters && (
Filters applied:{" "}
{[debouncedSearch && `search "${debouncedSearch}"`, customerFilter && "customer filter", teamFilter && "team filter"]
.filter(Boolean)
.join(", ")}
)}
API tokens are excluded from the export.
setShowExportDialog(false)} disabled={isExporting}>
Cancel
{isExporting ? (
<>
Exporting...
>
) : (
<>
Export CSV
>
)}
Virtual Keys
Manage virtual keys, their permissions, budgets, and rate limits.
Export CSV
Add Virtual Key
{/* Toolbar: Search + Filters */}
onSearchChange(e.target.value)}
className="pl-9"
data-testid="vk-search-input"
/>
onCustomerFilterChange(val === "all" ? "" : val)}>
All Customers
{customers.map((c) => (
{c.name}
))}
{customerFilter && teamFilter &&
or }
onTeamFilterChange(val === "all" ? "" : val)}>
All Teams
{teams.map((t) => (
{t.name}
))}
Assigned To
Key
Rate Limits
{virtualKeys.length === 0 ? (
No matching virtual keys found.
) : (
virtualKeys.map((vk) => {
const isRevealed = revealedKeys.has(vk.id);
return (
handleRowClick(vk)}
>
{vk.name}
{vk.team ? (
Team: {vk.team.name}
) : vk.customer ? (
Customer: {vk.customer.name}
) : (
-
)}
e.stopPropagation()}>
{maskKey(vk.value, isRevealed)}
toggleKeyVisibility(vk.id)}
data-testid={`vk-visibility-btn-${vk.name}`}
>
{isRevealed ? : }
copyToClipboard(vk.value)}
data-testid={`vk-copy-btn-${vk.name}`}
>
e.stopPropagation()}>
handleEditVirtualKey(vk, e)}
disabled={!hasUpdateAccess}
data-testid={`vk-edit-btn-${vk.name}`}
>
);
})
)}
{/* Pagination */}
{totalCount > 0 && (
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
onOffsetChange(Math.max(0, offset - limit))}
data-testid="vk-pagination-prev-btn"
>
Previous
= totalCount}
onClick={() => onOffsetChange(offset + limit)}
data-testid="vk-pagination-next-btn"
>
Next
)}
>
);
}