first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { DottedSeparator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { ProviderLabels, ProviderName } from "@/lib/constants/logs";
import { VirtualKey } from "@/lib/types/governance";
import { cn } from "@/lib/utils";
import { calculateUsagePercentage, formatCurrency, parseResetPeriod } from "@/lib/utils/governance";
import { formatDistanceToNow } from "date-fns";
import { Lock, Users } from "lucide-react";
import { useVirtualKeyUsage } from "../hooks/useVirtualKeyUsage";
function usageBarClass(pct: number, exhausted: boolean) {
if (exhausted) return "[&>div]:bg-red-500/70";
if (pct > 80) return "[&>div]:bg-amber-500/70";
return "[&>div]:bg-emerald-500/70";
}
function UsageLine({ current, max, format }: { current: number; max: number; format: (n: number) => string }) {
const pct = calculateUsagePercentage(current, max);
const exhausted = max > 0 && current >= max;
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-sm">
{format(current)} <span className="text-muted-foreground">/</span> {format(max)}
</span>
<span className={cn("text-xs font-medium tabular-nums", exhausted ? "text-red-500" : pct > 80 ? "text-amber-500" : "text-muted-foreground")}>
{pct}%
</span>
</div>
<Progress value={Math.min(pct, 100)} className={cn("bg-muted/70 dark:bg-muted/30 h-1.5", usageBarClass(pct, exhausted))} />
</div>
);
}
interface VirtualKeyDetailSheetProps {
virtualKey: VirtualKey;
onClose: () => void;
}
export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKeyDetailSheetProps) {
const { assignedUsers, isManagedByProfile, managingProfile, hasApRateLimit, displayBudgets, displayRateLimit } =
useVirtualKeyUsage(virtualKey);
const getEntityInfo = () => {
if (virtualKey.team) {
return { type: "Team", name: virtualKey.team.name };
}
if (virtualKey.customer) {
return { type: "Customer", name: virtualKey.customer.name };
}
return { type: "None", name: "" };
};
const entityInfo = getEntityInfo();
const isExhausted =
// Budget exhausted (AP-mirrored when managed, VK-own otherwise)
displayBudgets?.some((b) => b.current_usage >= b.max_limit) ||
// Rate limits exhausted
(displayRateLimit?.token_current_usage &&
displayRateLimit?.token_max_limit &&
displayRateLimit.token_current_usage >= displayRateLimit.token_max_limit) ||
(displayRateLimit?.request_current_usage &&
displayRateLimit?.request_max_limit &&
displayRateLimit.request_current_usage >= displayRateLimit.request_max_limit);
return (
<Sheet open onOpenChange={onClose}>
<SheetContent className="flex w-full flex-col overflow-x-hidden p-8 sm:max-w-2xl">
<SheetHeader className="flex flex-col items-start p-0">
<SheetTitle>{virtualKey.name}</SheetTitle>
<SheetDescription>{virtualKey.description || "Virtual key details and usage information"}</SheetDescription>
</SheetHeader>
<div className="space-y-6">
{isManagedByProfile ? (
<Alert variant="info">
<Lock className="h-4 w-4" />
<AlertDescription>
This virtual key is managed by an access profile. You can rename it or update its description from the edit button, but
providers, budgets, rate limits, and MCP access are controlled by the profile and must be changed there.
</AlertDescription>
</Alert>
) : null}
{assignedUsers.length > 0 ? (
<div className="space-y-1">
<Label className="text-sm font-medium">Assigned Users</Label>
<div className="flex items-center gap-2">
<Users className="text-muted-foreground h-4 w-4" />
<span className="text-sm">{assignedUsers.map((u) => u.name || u.email).join(", ")}</span>
</div>
</div>
) : null}
{/* Basic Information */}
<div className="space-y-4">
<h3 className="font-semibold">Basic Information</h3>
<div className="grid gap-4">
<div className="grid grid-cols-3 items-center gap-4">
<span className="text-muted-foreground text-sm">Status</span>
<div className="col-span-2">
<Badge variant={virtualKey.is_active ? (isExhausted ? "destructive" : "default") : "secondary"}>
{virtualKey.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"}
</Badge>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<span className="text-muted-foreground text-sm">Created</span>
<div className="col-span-2 text-sm">{formatDistanceToNow(new Date(virtualKey.created_at), { addSuffix: true })}</div>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<span className="text-muted-foreground text-sm">Last Updated</span>
<div className="col-span-2 text-sm">{formatDistanceToNow(new Date(virtualKey.updated_at), { addSuffix: true })}</div>
</div>
{entityInfo.type !== "None" && (
<div className="grid grid-cols-3 items-center gap-4">
<span className="text-muted-foreground text-sm">Assigned To</span>
<div className="col-span-2 flex items-center gap-2">
<Badge variant={entityInfo.type === "None" ? "outline" : "secondary"}>{entityInfo.type}</Badge>
<span className="text-sm">{entityInfo.name}</span>
</div>
</div>
)}
</div>
</div>
<DottedSeparator />
{/* Provider Configurations */}
<div className="space-y-4">
<h3 className="font-semibold">Provider Configurations</h3>
<div className="space-y-3">
{!virtualKey.provider_configs || virtualKey.provider_configs.length === 0 ? (
<span className="text-muted-foreground text-sm">No providers configured (deny-by-default)</span>
) : (
<div className="space-y-4">
{virtualKey.provider_configs.map((config, index) => (
<div key={`${config.provider}-${index}`} className="rounded-lg border p-4">
{/* Provider Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<RenderProviderIcon provider={config.provider as ProviderIconType} size="sm" className="h-5 w-5" />
<span className="font-medium">{ProviderLabels[config.provider as ProviderName] || config.provider}</span>
</div>
<Badge variant="outline" className="font-mono text-xs">
Weight: {config.weight}
</Badge>
</div>
{/* Basic Config */}
<div className="space-y-3">
<div className="grid grid-cols-3 items-start gap-4">
<span className="text-muted-foreground pt-0.5 text-sm font-medium">Allowed Models</span>
<div className="col-span-2">
{config.allowed_models?.includes("*") ? (
<Badge variant="success" className="text-xs">
All Models
</Badge>
) : config.allowed_models && config.allowed_models.length > 0 ? (
<div className="flex flex-wrap gap-1">
{config.allowed_models.map((model) => (
<Badge key={model} variant="secondary" className="text-xs">
{model}
</Badge>
))}
</div>
) : (
<Badge variant="destructive" className="text-xs">
No models (deny all)
</Badge>
)}
</div>
</div>
<div className="grid grid-cols-3 items-start gap-4">
<span className="text-muted-foreground pt-0.5 text-sm font-medium">Allowed Keys</span>
<div className="col-span-2">
{config.allow_all_keys ? (
<Badge variant="success" className="text-xs">
All Keys
</Badge>
) : config.keys && config.keys.length > 0 ? (
<div className="flex flex-wrap gap-1">
{config.keys.map((key) => (
<Badge key={key.key_id} variant="outline" className="text-xs">
{key.name}
</Badge>
))}
</div>
) : (
<Badge variant="destructive" className="text-xs">
No keys (deny all)
</Badge>
)}
</div>
</div>
{/* Provider Budgets */}
{config.budgets && config.budgets.length > 0 && (
<>
<DottedSeparator />
<div className="space-y-2">
<h4 className="text-sm font-medium">Provider Budgets</h4>
{config.budgets.map((b, bIdx) => (
<div key={bIdx} className="space-y-2">
<UsageLine current={b.current_usage} max={b.max_limit} format={formatCurrency} />
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
Resets {parseResetPeriod(b.reset_duration)}
{virtualKey.calendar_aligned && " (calendar)"}
</span>
{b.last_reset ? (
<span>Last reset {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })}</span>
) : null}
</div>
</div>
))}
</div>
</>
)}
{/* Provider Rate Limits */}
{config.rate_limit && (
<>
<DottedSeparator />
<div className="space-y-3">
<h4 className="text-sm font-medium">Provider Rate Limits</h4>
{/* Token Limits */}
{config.rate_limit.token_max_limit != null ? (
<div className="space-y-2">
<span className="text-muted-foreground text-xs font-medium">TOKEN LIMITS</span>
<UsageLine
current={config.rate_limit.token_current_usage}
max={config.rate_limit.token_max_limit}
format={(n) => n.toLocaleString()}
/>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>Resets {parseResetPeriod(config.rate_limit.token_reset_duration || "")}</span>
{config.rate_limit.token_last_reset ? (
<span>Last reset {formatDistanceToNow(new Date(config.rate_limit.token_last_reset), { addSuffix: true })}</span>
) : null}
</div>
</div>
) : null}
{/* Request Limits */}
{config.rate_limit.request_max_limit != null ? (
<div className="space-y-2">
<span className="text-muted-foreground text-xs font-medium">REQUEST LIMITS</span>
<UsageLine
current={config.rate_limit.request_current_usage}
max={config.rate_limit.request_max_limit}
format={(n) => n.toLocaleString()}
/>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>Resets {parseResetPeriod(config.rate_limit.request_reset_duration || "")}</span>
{config.rate_limit.request_last_reset ? (
<span>Last reset {formatDistanceToNow(new Date(config.rate_limit.request_last_reset), { addSuffix: true })}</span>
) : null}
</div>
</div>
) : null}
{config.rate_limit.token_max_limit == null && config.rate_limit.request_max_limit == null && (
<p className="text-muted-foreground text-sm">No rate limits configured for this provider</p>
)}
</div>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* MCP Client Configurations */}
<div className="space-y-4">
<h3 className="font-semibold">MCP Client Configurations</h3>
<div className="space-y-3">
{!virtualKey.mcp_configs || virtualKey.mcp_configs.length === 0 ? (
<span className="text-muted-foreground text-sm">No MCP clients configured (deny-by-default)</span>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>MCP Client</TableHead>
<TableHead>Allowed Tools</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{virtualKey.mcp_configs.map((config, index) => (
<TableRow key={`${config.mcp_client?.name || config.id}-${index}`}>
<TableCell>{config.mcp_client?.name || "Unknown Client"}</TableCell>
<TableCell>
{config.tools_to_execute?.includes("*") ? (
<Badge variant="success" className="text-xs">
All Tools
</Badge>
) : config.tools_to_execute && config.tools_to_execute.length > 0 ? (
<div className="flex flex-wrap gap-1">
{config.tools_to_execute.map((tool) => (
<Badge key={tool} variant="secondary" className="text-xs">
{tool}
</Badge>
))}
</div>
) : (
<Badge variant="destructive" className="text-xs">
No tools (deny all)
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
<DottedSeparator />
{/* Budget Information */}
<div className="space-y-4">
<h3 className="font-semibold">
Budget Information
{isManagedByProfile && managingProfile?.budgets?.length ? (
<span className="text-muted-foreground ml-2 text-xs font-normal">(from {managingProfile.name})</span>
) : null}
</h3>
{displayBudgets && displayBudgets.length > 0 ? (
<div className="space-y-4">
{displayBudgets.map((b, bIdx) => (
<div key={bIdx} className="rounded-lg border p-4 space-y-2">
<UsageLine current={b.current_usage} max={b.max_limit} format={formatCurrency} />
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
Resets {parseResetPeriod(b.reset_duration)}
{virtualKey.calendar_aligned && " (calendar)"}
</span>
{b.last_reset ? (
<span>Last reset {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })}</span>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">No budget limits configured</p>
)}
</div>
{/* Rate Limits */}
<div className="space-y-4">
<h3 className="font-semibold">
Rate Limits
{isManagedByProfile && hasApRateLimit ? (
<span className="text-muted-foreground ml-2 text-xs font-normal">(from {managingProfile?.name})</span>
) : null}
</h3>
{displayRateLimit ? (
<div className="space-y-4">
{/* Token Limits */}
{displayRateLimit.token_max_limit != null ? (
<div className="rounded-lg border p-4 space-y-3">
<span className="font-medium">Token Limits</span>
<UsageLine
current={displayRateLimit.token_current_usage}
max={displayRateLimit.token_max_limit}
format={(n) => n.toLocaleString()}
/>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>Resets {parseResetPeriod(displayRateLimit.token_reset_duration || "")}</span>
{displayRateLimit.token_last_reset ? (
<span>Last reset {formatDistanceToNow(new Date(displayRateLimit.token_last_reset), { addSuffix: true })}</span>
) : null}
</div>
</div>
) : null}
{/* Request Limits */}
{displayRateLimit.request_max_limit != null ? (
<div className="rounded-lg border p-4 space-y-3">
<span className="font-medium">Request Limits</span>
<UsageLine
current={displayRateLimit.request_current_usage}
max={displayRateLimit.request_max_limit}
format={(n) => n.toLocaleString()}
/>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>Resets {parseResetPeriod(displayRateLimit.request_reset_duration || "")}</span>
{displayRateLimit.request_last_reset ? (
<span>Last reset {formatDistanceToNow(new Date(displayRateLimit.request_last_reset), { addSuffix: true })}</span>
) : null}
</div>
</div>
) : null}
{displayRateLimit.token_max_limit == null && displayRateLimit.request_max_limit == null && (
<p className="text-muted-foreground text-sm">No rate limits configured</p>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">No rate limits configured</p>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
import { Button } from "@/components/ui/button";
import { KeyRound } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
const VIRTUAL_KEYS_DOCS_URL = "https://docs.getbifrost.ai/features/governance/virtual-keys";
interface VirtualKeysEmptyStateProps {
onAddClick: () => void;
canCreate?: boolean;
}
export function VirtualKeysEmptyState({ onAddClick, canCreate = true }: VirtualKeysEmptyStateProps) {
return (
<div
className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center"
data-testid="virtual-keys-empty-state"
>
<div className="text-muted-foreground">
<KeyRound className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">Virtual keys control access, budgets, and rate limits</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
Create virtual keys to assign permissions, spending limits, and usage quotas to teams, customers, or API clients.
</div>
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
aria-label="Read more about virtual keys (opens in new tab)"
data-testid="virtual-keys-button-read-more"
onClick={() => {
window.open(`${VIRTUAL_KEYS_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
}}
>
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
</Button>
<Button aria-label="Add your first virtual key" onClick={onAddClick} disabled={!canCreate} data-testid="create-vk-btn">
Add Virtual Key
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,704 @@
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 <span className="text-muted-foreground text-sm">-</span>;
}
return (
<div className="flex flex-col gap-0.5">
{displayBudgets.map((b, idx) => (
<div key={idx} className="flex flex-col">
<span className={cn("font-mono text-sm", b.current_usage >= b.max_limit && "text-red-400")}>
{formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)}
</span>
<span className="text-muted-foreground text-xs">
Resets {formatResetDuration(b.reset_duration)}
{vk.calendar_aligned && " (calendar)"}
</span>
</div>
))}
</div>
);
}
function VKRateLimitCell({ vk }: { vk: VirtualKey }) {
const { displayRateLimit } = useVirtualKeyUsage(vk);
return <RateLimitDisplay rateLimits={displayRateLimit} />;
}
// 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 (
<Badge variant={vk.is_active ? (isExhausted ? "destructive" : "default") : "secondary"}>
{vk.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"}
</Badge>
);
}
// 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 (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<span className="inline-block cursor-not-allowed">
<Button
variant="ghost"
size="sm"
className="text-destructive border-destructive/30"
disabled
data-testid={`vk-delete-btn-${vk.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px]">
<p className="text-xs">
This virtual key is managed by an access profile and can&apos;t be deleted here. Detach the profile from the user or delete it
from the access profile settings.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive border-destructive/30"
onClick={(e) => e.stopPropagation()}
disabled={!hasDeleteAccess}
data-testid={`vk-delete-btn-${vk.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Virtual Key</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid={`vk-delete-cancel-${vk.name}`}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(vk.id)}
disabled={isDeleting}
className="bg-destructive hover:bg-destructive/90"
data-testid={`vk-delete-confirm-${vk.name}`}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
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<string | null>(null);
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
const [selectedVirtualKeyId, setSelectedVirtualKeyId] = useState<string | null>(null);
const [showDetailSheet, setShowDetailSheet] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportScope, setExportScope] = useState<ExportScope>("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 }) => (
<Button variant="ghost" onClick={() => toggleSort(column)} data-testid={`vk-sort-${column}`}>
{label}
<ArrowUpDown className={cn("ml-2 h-4 w-4", sortBy === column && "text-foreground")} />
</Button>
);
// True empty state: no VKs at all (not just filtered to zero)
if (totalCount === 0 && !hasActiveFilters) {
return (
<>
{showVirtualKeySheet && (
<VirtualKeySheet
virtualKey={editingVirtualKey}
teams={teams}
customers={customers}
onSave={handleVirtualKeySaved}
onCancel={() => setShowVirtualKeySheet(false)}
/>
)}
<VirtualKeysEmptyState onAddClick={handleAddVirtualKey} canCreate={hasCreateAccess} />
</>
);
}
return (
<>
{showVirtualKeySheet && (
<VirtualKeySheet
virtualKey={editingVirtualKey}
teams={teams}
customers={customers}
onSave={handleVirtualKeySaved}
onCancel={() => setShowVirtualKeySheet(false)}
/>
)}
{showDetailSheet && selectedVirtualKey && <VirtualKeyDetailSheet virtualKey={selectedVirtualKey} onClose={handleDetailSheetClose} />}
{/* Export Dialog */}
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="pb-0">
<DialogTitle>Export Virtual Keys</DialogTitle>
<DialogDescription>Download as CSV with current filters and sorting applied.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">Export scope</Label>
<div className="grid grid-cols-2 gap-2" data-testid="vk-export-scope">
<button
type="button"
onClick={() => 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",
)}
>
<span className="font-medium">Current page</span>
<span className="text-muted-foreground text-xs">{virtualKeys.length} entries</span>
</button>
<button
type="button"
onClick={() => 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",
)}
>
<span className="font-medium">All entries</span>
<span className="text-muted-foreground text-xs">{totalCount} total</span>
</button>
</div>
</div>
{exportScope === "all" && (
<div className="space-y-2">
<Label htmlFor="export-max-limit" className="text-sm">
Max entries <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="export-max-limit"
type="number"
min="1"
placeholder={`Leave blank for all ${totalCount}`}
value={exportMaxLimit}
onChange={(e) => setExportMaxLimit(e.target.value)}
data-testid="vk-export-max-limit"
/>
</div>
)}
{hasActiveFilters && (
<p className="text-muted-foreground text-xs">
Filters applied:{" "}
{[debouncedSearch && `search "${debouncedSearch}"`, customerFilter && "customer filter", teamFilter && "team filter"]
.filter(Boolean)
.join(", ")}
</p>
)}
<div className="text-muted-foreground flex items-center gap-2">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<p className="text-xs">API tokens are excluded from the export.</p>
</div>
</div>
<DialogFooter className="pt-0">
<Button variant="outline" onClick={() => setShowExportDialog(false)} disabled={isExporting}>
Cancel
</Button>
<Button onClick={handleExportCSV} disabled={isExporting} data-testid="vk-export-confirm-btn">
{isExporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="h-4 w-4" />
Export CSV
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Virtual Keys</h2>
<p className="text-muted-foreground text-sm">Manage virtual keys, their permissions, budgets, and rate limits.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={openExportDialog} disabled={virtualKeys.length === 0} data-testid="vk-export-btn">
<Download className="h-4 w-4" />
Export CSV
</Button>
<Button onClick={handleAddVirtualKey} disabled={!hasCreateAccess} data-testid="create-vk-btn">
<Plus className="h-4 w-4" />
Add Virtual Key
</Button>
</div>
</div>
{/* Toolbar: Search + Filters */}
<div className="flex items-center gap-3">
<div className="relative max-w-sm flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
aria-label="Search virtual keys by name"
placeholder="Search by name..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
data-testid="vk-search-input"
/>
</div>
<Select value={customerFilter} onValueChange={(val) => onCustomerFilterChange(val === "all" ? "" : val)}>
<SelectTrigger className="w-[180px]" data-testid="vk-customer-filter">
<SelectValue placeholder="All Customers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Customers</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{customerFilter && teamFilter && <span className="text-muted-foreground text-xs font-medium">or</span>}
<Select value={teamFilter} onValueChange={(val) => onTeamFilterChange(val === "all" ? "" : val)}>
<SelectTrigger className="w-[180px]" data-testid="vk-team-filter">
<SelectValue placeholder="All Teams" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Teams</SelectItem>
{teams.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-sm border">
<Table data-testid="vk-table">
<TableHeader>
<TableRow>
<TableHead>
<SortableHeader column="name" label="Name" />
</TableHead>
<TableHead>Assigned To</TableHead>
<TableHead>Key</TableHead>
<TableHead>
<SortableHeader column="budget_spent" label="Budget" />
</TableHead>
<TableHead>Rate Limits</TableHead>
<TableHead>
<SortableHeader column="status" label="Status" />
</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{virtualKeys.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<span className="text-muted-foreground text-sm">No matching virtual keys found.</span>
</TableCell>
</TableRow>
) : (
virtualKeys.map((vk) => {
const isRevealed = revealedKeys.has(vk.id);
return (
<TableRow
key={vk.id}
data-testid={`vk-row-${vk.name}`}
className="hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => handleRowClick(vk)}
>
<TableCell className="max-w-[200px]">
<div className="truncate font-medium">{vk.name}</div>
</TableCell>
<TableCell>
{vk.team ? (
<Badge variant="outline">Team: {vk.team.name}</Badge>
) : vk.customer ? (
<Badge variant="outline">Customer: {vk.customer.name}</Badge>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<code className="cursor-default px-2 py-1 font-mono text-sm" data-testid="vk-key-value">
{maskKey(vk.value, isRevealed)}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => toggleKeyVisibility(vk.id)}
data-testid={`vk-visibility-btn-${vk.name}`}
>
{isRevealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(vk.value)}
data-testid={`vk-copy-btn-${vk.name}`}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell>
<VKBudgetCell vk={vk} />
</TableCell>
<TableCell>
<VKRateLimitCell vk={vk} />
</TableCell>
<TableCell>
<VKStatusBadge vk={vk} />
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleEditVirtualKey(vk, e)}
disabled={!hasUpdateAccess}
data-testid={`vk-edit-btn-${vk.name}`}
>
<Edit className="h-4 w-4" />
</Button>
<VKDeleteButton vk={vk} hasDeleteAccess={hasDeleteAccess} isDeleting={isDeleting} onDelete={handleDelete} />
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalCount > 0 && (
<div className="flex items-center justify-between px-2">
<p className="text-muted-foreground text-sm">
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
data-testid="vk-pagination-prev-btn"
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={offset + limit >= totalCount}
onClick={() => onOffsetChange(offset + limit)}
data-testid="vk-pagination-next-btn"
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</>
);
}