first commit
This commit is contained in:
431
ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx
Normal file
431
ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1537
ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx
Normal file
1537
ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
704
ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
Normal file
704
ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
Normal 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'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 "{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name}"? 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user