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,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AccessProfilesPage from "./page";
export const Route = createFileRoute("/workspace/governance/access-profiles")({
component: AccessProfilesPage,
});

View File

@@ -0,0 +1,17 @@
import { NoPermissionView } from "@/components/noPermissionView";
import AccessProfilesIndexView from "@enterprise/components/access-profiles/accessProfilesIndexView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
export default function AccessProfilesPage() {
const hasAccessProfilesAccess = useRbac(RbacResource.AccessProfiles, RbacOperation.View);
if (!hasAccessProfilesAccess) {
return <NoPermissionView entity="access-profiles" />;
}
return (
<div className="mx-auto w-full max-w-7xl">
<AccessProfilesIndexView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import GovernanceBusinessUnitsPage from "./page";
export const Route = createFileRoute("/workspace/governance/business-units")({
component: GovernanceBusinessUnitsPage,
});

View File

@@ -0,0 +1,9 @@
import { BusinessUnitsView } from "@enterprise/components/user-groups/businessUnitsView";
export default function GovernanceBusinessUnitsPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<BusinessUnitsView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import GovernanceCustomersPage from "./page";
export const Route = createFileRoute("/workspace/governance/customers")({
component: GovernanceCustomersPage,
});

View File

@@ -0,0 +1,102 @@
import FullPageLoader from "@/components/fullPageLoader";
import { useDebouncedValue } from "@/hooks/useDebounce";
import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import CustomersTable from "@/app/workspace/governance/views/customerTable";
const POLLING_INTERVAL = 5000;
const PAGE_SIZE = 25;
export default function GovernanceCustomersPage() {
const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
const shownErrorsRef = useRef(new Set<string>());
const [search, setSearch] = useState("");
const [offset, setOffset] = useState(0);
const debouncedSearch = useDebouncedValue(search, 300);
useEffect(() => {
setOffset(0);
}, [debouncedSearch]);
const {
data: virtualKeysData,
error: vkError,
isLoading: vkLoading,
} = useGetVirtualKeysQuery(undefined, {
skip: !hasVirtualKeysAccess,
pollingInterval: POLLING_INTERVAL,
});
const {
data: teamsData,
error: teamsError,
isLoading: teamsLoading,
} = useGetTeamsQuery(undefined, { skip: !hasTeamsAccess, pollingInterval: POLLING_INTERVAL });
const {
data: customersData,
error: customersError,
isLoading: customersLoading,
} = useGetCustomersQuery(
{
limit: PAGE_SIZE,
offset,
search: debouncedSearch || undefined,
},
{
skip: !hasCustomersAccess,
pollingInterval: POLLING_INTERVAL,
},
);
const customersTotal = customersData?.total_count ?? 0;
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
useEffect(() => {
if (!customersData || offset < customersTotal) return;
setOffset(customersTotal === 0 ? 0 : Math.floor((customersTotal - 1) / PAGE_SIZE) * PAGE_SIZE);
}, [customersTotal, offset]);
const isLoading = vkLoading || teamsLoading || customersLoading;
useEffect(() => {
if (!vkError && !teamsError && !customersError) {
shownErrorsRef.current.clear();
return;
}
const errorKey = `${!!vkError}-${!!teamsError}-${!!customersError}`;
if (shownErrorsRef.current.has(errorKey)) return;
shownErrorsRef.current.add(errorKey);
if (vkError && teamsError && customersError) {
toast.error("Failed to load governance data.");
} else {
if (vkError) toast.error(`Failed to load virtual keys: ${getErrorMessage(vkError)}`);
if (teamsError) toast.error(`Failed to load teams: ${getErrorMessage(teamsError)}`);
if (customersError) toast.error(`Failed to load customers: ${getErrorMessage(customersError)}`);
}
}, [vkError, teamsError, customersError]);
if (isLoading) {
return <FullPageLoader />;
}
return (
<div className="mx-auto w-full max-w-7xl">
<CustomersTable
customers={customersData?.customers || []}
totalCount={customersData?.total_count || 0}
teams={teamsData?.teams || []}
virtualKeys={virtualKeysData?.virtual_keys || []}
search={search}
debouncedSearch={debouncedSearch}
onSearchChange={setSearch}
offset={offset}
limit={PAGE_SIZE}
onOffsetChange={setOffset}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import GovernancePage from "./page";
function RouteComponent() {
const hasGovernanceAccess = useRbac(RbacResource.Governance, RbacOperation.View);
const childMatches = useChildMatches();
if (!hasGovernanceAccess) {
return <NoPermissionView entity="governance" />;
}
return childMatches.length === 0 ? <GovernancePage /> : <Outlet />;
}
export const Route = createFileRoute("/workspace/governance")({
component: RouteComponent,
});

View File

@@ -0,0 +1,10 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
export default function GovernancePage() {
const navigate = useNavigate();
useEffect(() => {
navigate({ to: "/workspace/governance/virtual-keys", replace: true });
}, [navigate]);
return null;
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import GovernanceRbacPage from "./page";
export const Route = createFileRoute("/workspace/governance/rbac")({
component: GovernanceRbacPage,
});

View File

@@ -0,0 +1,9 @@
import RBACView from "@enterprise/components/rbac/rbacView";
export default function GovernanceRbacPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<RBACView />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import GovernanceTeamsPage from "./page";
export const Route = createFileRoute("/workspace/governance/teams")({
component: GovernanceTeamsPage,
});

View File

@@ -0,0 +1,5 @@
import { TeamsView } from "@enterprise/components/user-groups/teamsView"
export default function GovernanceTeamsPage() {
return <TeamsView />
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import GovernanceUsersPage from "./page";
export const Route = createFileRoute("/workspace/governance/users")({
component: GovernanceUsersPage,
});

View File

@@ -0,0 +1,9 @@
import UsersView from "@enterprise/components/user-groups/usersView";
export default function GovernanceUsersPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<UsersView />
</div>
);
}

View File

@@ -0,0 +1,374 @@
import FormFooter from "@/components/formFooter";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import NumberAndSelect from "@/components/ui/numberAndSelect";
import { resetDurationOptions } from "@/lib/constants/governance";
import { getErrorMessage, useCreateCustomerMutation, useUpdateCustomerMutation } from "@/lib/store";
import { CreateCustomerRequest, Customer, UpdateCustomerRequest } from "@/lib/types/governance";
import { formatCurrency } from "@/lib/utils/governance";
import { Validator } from "@/lib/utils/validation";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { formatDistanceToNow } from "date-fns";
import isEqual from "lodash.isequal";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
interface CustomerDialogProps {
customer?: Customer | null;
onSave: () => void;
onCancel: () => void;
}
interface CustomerFormData {
name: string;
// Budget
budgetMaxLimit: number | undefined;
budgetResetDuration: string;
// Rate Limit
tokenMaxLimit: number | undefined;
tokenResetDuration: string;
requestMaxLimit: number | undefined;
requestResetDuration: string;
isDirty: boolean;
}
// Helper function to create initial state
const createInitialState = (customer?: Customer | null): Omit<CustomerFormData, "isDirty"> => {
return {
name: customer?.name || "",
// Budget
budgetMaxLimit: customer?.budget?.max_limit ?? undefined,
budgetResetDuration: customer?.budget?.reset_duration || "1M",
// Rate Limit
tokenMaxLimit: customer?.rate_limit?.token_max_limit ?? undefined,
tokenResetDuration: customer?.rate_limit?.token_reset_duration || "1h",
requestMaxLimit: customer?.rate_limit?.request_max_limit ?? undefined,
requestResetDuration: customer?.rate_limit?.request_reset_duration || "1h",
};
};
export default function CustomerDialog({ customer, onSave, onCancel }: CustomerDialogProps) {
const isEditing = !!customer;
const [initialState] = useState<Omit<CustomerFormData, "isDirty">>(createInitialState(customer));
const [formData, setFormData] = useState<CustomerFormData>({
...initialState,
isDirty: false,
});
const hasCreateAccess = useRbac(RbacResource.Customers, RbacOperation.Create);
const hasUpdateAccess = useRbac(RbacResource.Customers, RbacOperation.Update);
const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess;
// RTK Query hooks
const [createCustomer, { isLoading: isCreating }] = useCreateCustomerMutation();
const [updateCustomer, { isLoading: isUpdating }] = useUpdateCustomerMutation();
const loading = isCreating || isUpdating;
// Track isDirty state
useEffect(() => {
const currentData = {
name: formData.name,
budgetMaxLimit: formData.budgetMaxLimit,
budgetResetDuration: formData.budgetResetDuration,
tokenMaxLimit: formData.tokenMaxLimit,
tokenResetDuration: formData.tokenResetDuration,
requestMaxLimit: formData.requestMaxLimit,
requestResetDuration: formData.requestResetDuration,
};
setFormData((prev) => ({
...prev,
isDirty: !isEqual(initialState, currentData),
}));
}, [
formData.name,
formData.budgetMaxLimit,
formData.budgetResetDuration,
formData.tokenMaxLimit,
formData.tokenResetDuration,
formData.requestMaxLimit,
formData.requestResetDuration,
initialState,
]);
// Values for validation and submission (already numbers)
const budgetMaxLimitNum = formData.budgetMaxLimit;
const tokenMaxLimitNum = formData.tokenMaxLimit;
const requestMaxLimitNum = formData.requestMaxLimit;
// Validation
const validator = useMemo(
() =>
new Validator([
// Basic validation
Validator.required(formData.name.trim(), "Customer name is required"),
// Check if anything is dirty
Validator.custom(formData.isDirty, "No changes to save"),
// Budget validation
...(formData.budgetMaxLimit !== undefined && formData.budgetMaxLimit !== null
? [
Validator.minValue(budgetMaxLimitNum ?? 0, 0.01, "Budget max limit must be greater than $0.01"),
Validator.required(formData.budgetResetDuration, "Budget reset duration is required"),
]
: []),
// Rate limit validation - token limits
...(formData.tokenMaxLimit !== undefined && formData.tokenMaxLimit !== null
? [
Validator.minValue(tokenMaxLimitNum ?? 0, 1, "Token max limit must be at least 1"),
Validator.required(formData.tokenResetDuration, "Token reset duration is required"),
]
: []),
// Rate limit validation - request limits
...(formData.requestMaxLimit !== undefined && formData.requestMaxLimit !== null
? [
Validator.minValue(requestMaxLimitNum ?? 0, 1, "Request max limit must be at least 1"),
Validator.required(formData.requestResetDuration, "Request reset duration is required"),
]
: []),
]),
[formData, budgetMaxLimitNum, tokenMaxLimitNum, requestMaxLimitNum],
);
const updateField = <K extends keyof CustomerFormData>(field: K, value: CustomerFormData[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validator.isValid()) {
toast.error(validator.getFirstError());
return;
}
try {
if (isEditing && customer) {
// Update existing customer
const updateData: UpdateCustomerRequest = {
name: formData.name,
};
// Detect budget changes using had/has pattern
const hadBudget = !!customer.budget;
const hasBudget = budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null;
if (hasBudget) {
updateData.budget = {
max_limit: budgetMaxLimitNum,
reset_duration: formData.budgetResetDuration,
};
} else if (hadBudget) {
updateData.budget = {} as UpdateCustomerRequest["budget"];
}
// Detect rate limit changes using had/has pattern
const hadRateLimit = !!customer.rate_limit;
const hasRateLimit =
(tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
(requestMaxLimitNum !== undefined && requestMaxLimitNum !== null);
if (hasRateLimit) {
updateData.rate_limit = {
token_max_limit: tokenMaxLimitNum,
token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined,
request_max_limit: requestMaxLimitNum,
request_reset_duration:
requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined,
};
} else if (hadRateLimit) {
updateData.rate_limit = {} as UpdateCustomerRequest["rate_limit"];
}
await updateCustomer({ customerId: customer.id, data: updateData }).unwrap();
toast.success("Customer updated successfully");
} else {
// Create new customer
const createData: CreateCustomerRequest = {
name: formData.name,
};
// Add budget if enabled
if (budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null) {
createData.budget = {
max_limit: budgetMaxLimitNum,
reset_duration: formData.budgetResetDuration,
};
}
// Add rate limit if enabled (token or request limits)
if (
(tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
(requestMaxLimitNum !== undefined && requestMaxLimitNum !== null)
) {
createData.rate_limit = {
token_max_limit: tokenMaxLimitNum,
token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined,
request_max_limit: requestMaxLimitNum,
request_reset_duration:
requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined,
};
}
await createCustomer(createData).unwrap();
toast.success("Customer created successfully");
}
onSave();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
return (
<Dialog open onOpenChange={onCancel}>
<DialogContent className="max-w-2xl" data-testid="customer-dialog-content">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">{isEditing ? "Edit Customer" : "Create Customer"}</DialogTitle>
<DialogDescription>
{isEditing
? "Update the customer information and settings."
: "Create a new customer account to organize teams and manage resources."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Customer Name *</Label>
<Input
id="name"
data-testid="customer-name-input"
placeholder="e.g., Acme Corporation"
value={formData.name}
maxLength={50}
onChange={(e) => updateField("name", e.target.value)}
/>
<p className="text-muted-foreground text-sm">This name will be used to identify the customer account.</p>
</div>
</div>
{/* Budget Configuration */}
<NumberAndSelect
id="budgetMaxLimit"
label="Maximum Spend (USD)"
value={formData.budgetMaxLimit}
selectValue={formData.budgetResetDuration}
onChangeNumber={(value) => updateField("budgetMaxLimit", value)}
onChangeSelect={(value) => updateField("budgetResetDuration", value)}
options={resetDurationOptions}
dataTestId="budget-max-limit-input"
/>
{/* Rate Limit Configuration - Token Limits */}
<NumberAndSelect
id="tokenMaxLimit"
label="Maximum Tokens"
value={formData.tokenMaxLimit}
selectValue={formData.tokenResetDuration}
onChangeNumber={(value) => updateField("tokenMaxLimit", value)}
onChangeSelect={(value) => updateField("tokenResetDuration", value)}
options={resetDurationOptions}
/>
{/* Rate Limit Configuration - Request Limits */}
<NumberAndSelect
id="requestMaxLimit"
label="Maximum Requests"
value={formData.requestMaxLimit}
selectValue={formData.requestResetDuration}
onChangeNumber={(value) => updateField("requestMaxLimit", value)}
onChangeSelect={(value) => updateField("requestResetDuration", value)}
options={resetDurationOptions}
/>
{/* Current Usage Section (only shown when editing with existing limits) */}
{isEditing && (customer?.budget || customer?.rate_limit) && (
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
<p className="text-sm font-medium">Current Usage</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{customer?.budget && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Budget</p>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{formatCurrency(customer.budget.current_usage)} / {formatCurrency(customer.budget.max_limit)}
</span>
<Badge
variant={customer.budget.current_usage >= customer.budget.max_limit ? "destructive" : "default"}
className="text-xs"
>
{Math.round((customer.budget.current_usage / customer.budget.max_limit) * 100)}%
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Last Reset: {formatDistanceToNow(new Date(customer.budget.last_reset), { addSuffix: true })}
</p>
</div>
)}
{customer?.rate_limit?.token_max_limit && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Tokens</p>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{customer.rate_limit.token_current_usage.toLocaleString()} /{" "}
{customer.rate_limit.token_max_limit.toLocaleString()}
</span>
<Badge
variant={
customer.rate_limit.token_current_usage >= customer.rate_limit.token_max_limit ? "destructive" : "default"
}
className="text-xs"
>
{Math.round((customer.rate_limit.token_current_usage / customer.rate_limit.token_max_limit) * 100)}%
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.token_last_reset), { addSuffix: true })}
</p>
</div>
)}
{customer?.rate_limit?.request_max_limit && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Requests</p>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{customer.rate_limit.request_current_usage.toLocaleString()} /{" "}
{customer.rate_limit.request_max_limit.toLocaleString()}
</span>
<Badge
variant={
customer.rate_limit.request_current_usage >= customer.rate_limit.request_max_limit ? "destructive" : "default"
}
className="text-xs"
>
{Math.round((customer.rate_limit.request_current_usage / customer.rate_limit.request_max_limit) * 100)}%
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.request_last_reset), { addSuffix: true })}
</p>
</div>
)}
</div>
</div>
)}
</div>
<FormFooter
validator={validator}
label="Customer"
onCancel={onCancel}
isLoading={loading}
isEditing={isEditing}
hasPermission={hasPermission}
/>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,455 @@
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 { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { resetDurationLabels } from "@/lib/constants/governance";
import { getErrorMessage, useDeleteCustomerMutation } from "@/lib/store";
import { Customer, Team, VirtualKey } from "@/lib/types/governance";
import { cn } from "@/lib/utils";
import { formatCurrency } from "@/lib/utils/governance";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { Input } from "@/components/ui/input";
import { ChevronLeft, ChevronRight, Edit, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import CustomerDialog from "./customerDialog";
import { CustomersEmptyState } from "./customersEmptyState";
// Helper to format reset duration for display
const formatResetDuration = (duration: string) => {
return resetDurationLabels[duration] || duration;
};
interface CustomersTableProps {
customers: Customer[];
totalCount: number;
teams: Team[];
virtualKeys: VirtualKey[];
search: string;
debouncedSearch: string;
onSearchChange: (value: string) => void;
offset: number;
limit: number;
onOffsetChange: (offset: number) => void;
}
export default function CustomersTable({
customers,
totalCount,
teams,
virtualKeys,
search,
debouncedSearch,
onSearchChange,
offset,
limit,
onOffsetChange,
}: CustomersTableProps) {
const [showCustomerDialog, setShowCustomerDialog] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const hasCreateAccess = useRbac(RbacResource.Customers, RbacOperation.Create);
const hasUpdateAccess = useRbac(RbacResource.Customers, RbacOperation.Update);
const hasDeleteAccess = useRbac(RbacResource.Customers, RbacOperation.Delete);
const [deleteCustomer, { isLoading: isDeleting }] = useDeleteCustomerMutation();
const handleDelete = async (customerId: string) => {
try {
await deleteCustomer(customerId).unwrap();
toast.success("Customer deleted successfully");
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const handleAddCustomer = () => {
setEditingCustomer(null);
setShowCustomerDialog(true);
};
const handleEditCustomer = (customer: Customer) => {
setEditingCustomer(customer);
setShowCustomerDialog(true);
};
const handleCustomerSaved = () => {
setShowCustomerDialog(false);
setEditingCustomer(null);
};
const getTeamsForCustomer = (customerId: string) => {
return teams.filter((team) => team.customer_id === customerId);
};
const getVirtualKeysForCustomer = (customerId: string) => {
return virtualKeys.filter((vk) => vk.customer_id === customerId);
};
const hasActiveFilters = debouncedSearch;
// True empty state: no customers at all (not just filtered to zero)
if (totalCount === 0 && !hasActiveFilters) {
return (
<>
<TooltipProvider>
{showCustomerDialog && (
<CustomerDialog customer={editingCustomer} onSave={handleCustomerSaved} onCancel={() => setShowCustomerDialog(false)} />
)}
<CustomersEmptyState onAddClick={handleAddCustomer} canCreate={hasCreateAccess} />
</TooltipProvider>
</>
);
}
return (
<>
<TooltipProvider>
{showCustomerDialog && (
<CustomerDialog customer={editingCustomer} onSave={handleCustomerSaved} onCancel={() => setShowCustomerDialog(false)} />
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Customers</h2>
<p className="text-muted-foreground text-sm">Manage customer accounts with their own teams, budgets, and access controls.</p>
</div>
<Button data-testid="customer-button-create" onClick={handleAddCustomer} disabled={!hasCreateAccess}>
<Plus className="h-4 w-4" />
Add Customer
</Button>
</div>
<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 customers by name"
placeholder="Search by name..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
data-testid="customers-search-input"
/>
</div>
</div>
<div className="overflow-hidden rounded-sm border" data-testid="customer-table-container">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Teams</TableHead>
<TableHead>Budget</TableHead>
<TableHead>Rate Limit</TableHead>
<TableHead>Virtual Keys</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
<span className="text-muted-foreground text-sm">No matching customers found.</span>
</TableCell>
</TableRow>
) : (
customers.map((customer) => {
const customerTeams = getTeamsForCustomer(customer.id);
const vks = getVirtualKeysForCustomer(customer.id);
// Budget calculations
const isBudgetExhausted =
customer.budget?.max_limit &&
customer.budget.max_limit > 0 &&
customer.budget.current_usage >= customer.budget.max_limit;
const budgetPercentage =
customer.budget?.max_limit && customer.budget.max_limit > 0
? Math.min((customer.budget.current_usage / customer.budget.max_limit) * 100, 100)
: 0;
// Rate limit calculations
const isTokenLimitExhausted =
customer.rate_limit?.token_max_limit &&
customer.rate_limit.token_max_limit > 0 &&
customer.rate_limit.token_current_usage >= customer.rate_limit.token_max_limit;
const isRequestLimitExhausted =
customer.rate_limit?.request_max_limit &&
customer.rate_limit.request_max_limit > 0 &&
customer.rate_limit.request_current_usage >= customer.rate_limit.request_max_limit;
const isRateLimitExhausted = isTokenLimitExhausted || isRequestLimitExhausted;
const tokenPercentage =
customer.rate_limit?.token_max_limit && customer.rate_limit.token_max_limit > 0
? Math.min((customer.rate_limit.token_current_usage / customer.rate_limit.token_max_limit) * 100, 100)
: 0;
const requestPercentage =
customer.rate_limit?.request_max_limit && customer.rate_limit.request_max_limit > 0
? Math.min((customer.rate_limit.request_current_usage / customer.rate_limit.request_max_limit) * 100, 100)
: 0;
const isExhausted = isBudgetExhausted || isRateLimitExhausted;
return (
<TableRow
key={customer.id}
data-testid={`customer-row-${customer.name}`}
className={cn("group transition-colors", isExhausted && "bg-red-500/5 hover:bg-red-500/10")}
>
<TableCell className="max-w-[200px] py-4">
<div className="flex flex-col gap-2">
<span className="truncate font-medium">{customer.name}</span>
{isExhausted && (
<Badge variant="destructive" className="w-fit text-xs">
Limit Reached
</Badge>
)}
</div>
</TableCell>
<TableCell>
{customerTeams?.length > 0 ? (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs">
{customerTeams.length} {customerTeams.length === 1 ? "team" : "teams"}
</Badge>
</TooltipTrigger>
<TooltipContent>{customerTeams.map((team) => team.name).join(", ")}</TooltipContent>
</Tooltip>
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="min-w-[180px]">
{customer.budget ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="font-medium">{formatCurrency(customer.budget.max_limit)}</span>
<span className="text-muted-foreground text-xs">
{formatResetDuration(customer.budget.reset_duration)}
</span>
</div>
<Progress
value={budgetPercentage}
className={cn(
"bg-muted/70 dark:bg-muted/30 h-1.5",
isBudgetExhausted
? "[&>div]:bg-red-500/70"
: budgetPercentage > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{formatCurrency(customer.budget.current_usage)} / {formatCurrency(customer.budget.max_limit)}
</p>
<p className="text-primary-foreground/80 text-xs">
Resets {formatResetDuration(customer.budget.reset_duration)}
</p>
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="min-w-[180px]">
{customer.rate_limit ? (
<div className="space-y-2.5">
{customer.rate_limit.token_max_limit && (
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-4 text-xs">
<span className="font-medium">{customer.rate_limit.token_max_limit.toLocaleString()} tokens</span>
<span className="text-muted-foreground">
{formatResetDuration(customer.rate_limit.token_reset_duration || "1h")}
</span>
</div>
<Progress
value={tokenPercentage}
className={cn(
"bg-muted/70 dark:bg-muted/30 h-1",
isTokenLimitExhausted
? "[&>div]:bg-red-500/70"
: tokenPercentage > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{customer.rate_limit.token_current_usage.toLocaleString()} /{" "}
{customer.rate_limit.token_max_limit.toLocaleString()} tokens
</p>
<p className="text-primary-foreground/80 text-xs">
Resets {formatResetDuration(customer.rate_limit.token_reset_duration || "1h")}
</p>
</TooltipContent>
</Tooltip>
)}
{customer.rate_limit.request_max_limit && (
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-4 text-xs">
<span className="font-medium">{customer.rate_limit.request_max_limit.toLocaleString()} req</span>
<span className="text-muted-foreground">
{formatResetDuration(customer.rate_limit.request_reset_duration || "1h")}
</span>
</div>
<Progress
value={requestPercentage}
className={cn(
"bg-muted/70 dark:bg-muted/30 h-1",
isRequestLimitExhausted
? "[&>div]:bg-red-500/70"
: requestPercentage > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{customer.rate_limit.request_current_usage.toLocaleString()} /{" "}
{customer.rate_limit.request_max_limit.toLocaleString()} requests
</p>
<p className="text-primary-foreground/80 text-xs">
Resets {formatResetDuration(customer.rate_limit.request_reset_duration || "1h")}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
{vks?.length > 0 ? (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs">
{vks.length} {vks.length === 1 ? "key" : "keys"}
</Badge>
</TooltipTrigger>
<TooltipContent>{vks.map((vk) => vk.name).join(", ")}</TooltipContent>
</Tooltip>
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditCustomer(customer)}
disabled={!hasUpdateAccess}
aria-label={`Edit customer ${customer.name}`}
data-testid={`customer-button-edit-${customer.id}`}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:bg-red-500/10 hover:text-red-500"
disabled={!hasDeleteAccess}
aria-label={`Delete customer ${customer.name}`}
data-testid={`customer-button-delete-${customer.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Customer</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{customer.name}&quot;? This will also delete all associated teams
and unassign any virtual keys. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="customer-button-delete-cancel">Cancel</AlertDialogCancel>
<AlertDialogAction
data-testid="customer-button-delete-confirm"
onClick={() => handleDelete(customer.id)}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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="customers-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="customers-pagination-next-btn"
>
Next <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</TooltipProvider>
</>
);
}

View File

@@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { WalletCards } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
const CUSTOMERS_DOCS_URL = "https://docs.getbifrost.ai/features/governance/virtual-keys#customers";
interface CustomersEmptyStateProps {
onAddClick: () => void;
canCreate?: boolean;
}
export function CustomersEmptyState({ onAddClick, canCreate = true }: CustomersEmptyStateProps) {
return (
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
<div className="text-muted-foreground">
<WalletCards 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">Customers have their own teams, budgets, and access controls</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
Create customer accounts to manage multi-tenant usage, assign teams, and set spending and rate limits per customer.
</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 customers (opens in new tab)"
data-testid="customer-button-read-more"
onClick={() => {
window.open(`${CUSTOMERS_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 customer" onClick={onAddClick} disabled={!canCreate} data-testid="customer-button-create">
Add Customer
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,760 @@
import FormFooter from "@/components/formFooter";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alertDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import NumberAndSelect from "@/components/ui/numberAndSelect";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
resetDurationOptions,
supportsCalendarAlignment,
} from "@/lib/constants/governance";
import {
getErrorMessage,
useCreateTeamMutation,
useUpdateTeamMutation,
} from "@/lib/store";
import {
CreateTeamRequest,
Customer,
Team,
UpdateTeamRequest,
} from "@/lib/types/governance";
import { formatCurrency } from "@/lib/utils/governance";
import { Validator } from "@/lib/utils/validation";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { formatDistanceToNow } from "date-fns";
import isEqual from "lodash.isequal";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { v4 as uuid } from "uuid";
interface TeamDialogProps {
team?: Team | null;
customers: Customer[];
onSave: () => void;
onCancel: () => void;
}
// One editable budget row; teams own multiple, each keyed by reset_duration
// on the wire. The client-side `id` is stable across re-renders and equals
// the persisted budget's id for existing rows, or a fresh UUID for new ones —
// used as the React key and for matching against `team.budgets` when we need
// to distinguish "already persisted" from "just added in the form".
interface TeamBudgetRow {
id: string;
maxLimit: number | undefined;
resetDuration: string;
calendarAligned: boolean;
}
interface TeamFormData {
name: string;
customerId: string;
// Multi-budget: each row has a unique reset_duration on submit
budgets: TeamBudgetRow[];
// Rate Limit
tokenMaxLimit: number | undefined;
tokenResetDuration: string;
requestMaxLimit: number | undefined;
requestResetDuration: string;
isDirty: boolean;
}
// Helper function to create initial state
const createInitialState = (
team?: Team | null,
): Omit<TeamFormData, "isDirty"> => {
return {
name: team?.name || "",
customerId: team?.customer_id || "",
budgets:
team?.budgets?.map((b) => ({
id: b.id,
maxLimit: b.max_limit,
resetDuration: b.reset_duration,
calendarAligned: b.calendar_aligned ?? false,
})) ?? [],
// Rate Limit
tokenMaxLimit: team?.rate_limit?.token_max_limit ?? undefined,
tokenResetDuration: team?.rate_limit?.token_reset_duration || "1h",
requestMaxLimit: team?.rate_limit?.request_max_limit ?? undefined,
requestResetDuration: team?.rate_limit?.request_reset_duration || "1h",
};
};
export default function TeamDialog({
team,
customers,
onSave,
onCancel,
}: TeamDialogProps) {
const isEditing = !!team;
const [initialState, setInitialState] = useState<
Omit<TeamFormData, "isDirty">
>(createInitialState(team));
const [formData, setFormData] = useState<TeamFormData>({
...initialState,
isDirty: false,
});
useEffect(() => {
const nextInitial = createInitialState(team);
setInitialState(nextInitial);
setFormData({ ...nextInitial, isDirty: false });
setPendingCalendarAlignIdx(null);
}, [team]);
const hasCreateAccess = useRbac(RbacResource.Teams, RbacOperation.Create);
const hasUpdateAccess = useRbac(RbacResource.Teams, RbacOperation.Update);
const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess;
// RTK Query hooks
const [createTeam, { isLoading: isCreating }] = useCreateTeamMutation();
const [updateTeam, { isLoading: isUpdating }] = useUpdateTeamMutation();
const loading = isCreating || isUpdating;
// Tracks which row (by index) is awaiting calendar-align confirmation.
const [pendingCalendarAlignIdx, setPendingCalendarAlignIdx] = useState<
number | null
>(null);
const showCalendarAlignWarning = pendingCalendarAlignIdx !== null;
const updateBudgetRow = (idx: number, patch: Partial<TeamBudgetRow>) => {
setFormData((prev) => {
const next = prev.budgets.map((row, i) =>
i === idx ? { ...row, ...patch } : row,
);
return { ...prev, budgets: next };
});
};
const addBudgetRow = () => {
setFormData((prev) => ({
...prev,
budgets: [
...prev.budgets,
{
id: uuid(),
maxLimit: undefined,
resetDuration: "1M",
calendarAligned: false,
},
],
}));
};
const removeBudgetRow = (idx: number) => {
setFormData((prev) => ({
...prev,
budgets: prev.budgets.filter((_, i) => i !== idx),
}));
};
const handleCalendarAlignedChange = (idx: number, checked: boolean) => {
// Match the persisted budget by stable row id — for seeded rows this equals
// the server-side budget id; for newly-added rows it's a client-only UUID
// that won't match anything in team.budgets (correctly: no warning for new rows).
// Avoids the reset_duration-duplicate ambiguity before validation resolves.
const rowId = formData.budgets[idx]?.id;
const existingBudget = team?.budgets?.find((b) => b.id === rowId);
if (checked && isEditing && existingBudget && !existingBudget.calendar_aligned) {
setPendingCalendarAlignIdx(idx);
} else {
updateBudgetRow(idx, { calendarAligned: checked });
}
};
// Track isDirty state
useEffect(() => {
const currentData: Omit<TeamFormData, "isDirty"> = {
name: formData.name,
customerId: formData.customerId,
budgets: formData.budgets,
tokenMaxLimit: formData.tokenMaxLimit,
tokenResetDuration: formData.tokenResetDuration,
requestMaxLimit: formData.requestMaxLimit,
requestResetDuration: formData.requestResetDuration,
};
setFormData((prev) => ({
...prev,
isDirty: !isEqual(initialState, currentData),
}));
}, [
formData.name,
formData.customerId,
formData.budgets,
formData.tokenMaxLimit,
formData.tokenResetDuration,
formData.requestMaxLimit,
formData.requestResetDuration,
initialState,
]);
const tokenMaxLimitNum = formData.tokenMaxLimit;
const requestMaxLimitNum = formData.requestMaxLimit;
// Validation
const validator = useMemo(() => {
// Per-row budget validation plus cross-row uniqueness on reset_duration.
const budgetValidators = formData.budgets.flatMap((row, idx) => {
if (row.maxLimit === undefined || row.maxLimit === null) return [];
return [
Validator.minValue(
row.maxLimit,
0.01,
`Budget #${idx + 1} max limit must be greater than $0.01`,
),
Validator.required(
row.resetDuration,
`Budget #${idx + 1} reset duration is required`,
),
];
});
const populatedDurations = formData.budgets
.filter((r) => r.maxLimit !== undefined && r.maxLimit !== null)
.map((r) => r.resetDuration);
const uniqueDurations = new Set(populatedDurations).size;
return new Validator([
Validator.required(formData.name.trim(), "Team name is required"),
Validator.custom(formData.isDirty, "No changes to save"),
...budgetValidators,
Validator.custom(
uniqueDurations === populatedDurations.length,
"Each budget must have a distinct reset duration",
),
// Rate limit validation - token limits
...(formData.tokenMaxLimit !== undefined &&
formData.tokenMaxLimit !== null
? [
Validator.minValue(
tokenMaxLimitNum || 0,
1,
"Token max limit must be at least 1",
),
Validator.required(
formData.tokenResetDuration,
"Token reset duration is required",
),
]
: []),
// Rate limit validation - request limits
...(formData.requestMaxLimit !== undefined &&
formData.requestMaxLimit !== null
? [
Validator.minValue(
requestMaxLimitNum || 0,
1,
"Request max limit must be at least 1",
),
Validator.required(
formData.requestResetDuration,
"Request reset duration is required",
),
]
: []),
]);
}, [formData, tokenMaxLimitNum, requestMaxLimitNum]);
const updateField = <K extends keyof TeamFormData>(
field: K,
value: TeamFormData[K],
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validator.isValid()) {
toast.error(validator.getFirstError());
return;
}
// Serialize budget rows whose max_limit was filled in — rows left blank
// are silently dropped (the backend treats the slice as authoritative).
const submittableBudgets = formData.budgets
.filter((r) => r.maxLimit !== undefined && r.maxLimit !== null)
.map((r) => ({
max_limit: r.maxLimit as number,
reset_duration: r.resetDuration,
calendar_aligned: r.calendarAligned,
}));
try {
if (isEditing && team) {
// Update existing team
const updateData: UpdateTeamRequest = {
name: formData.name,
customer_id: formData.customerId || undefined,
// Always send: backend treats `budgets` as a full replacement.
budgets: submittableBudgets,
};
// Detect rate limit changes using had/has pattern
const hadRateLimit = !!team.rate_limit;
const hasRateLimit =
(tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
(requestMaxLimitNum !== undefined && requestMaxLimitNum !== null);
if (hasRateLimit) {
updateData.rate_limit = {
token_max_limit: tokenMaxLimitNum,
token_reset_duration:
tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null
? formData.tokenResetDuration
: undefined,
request_max_limit: requestMaxLimitNum,
request_reset_duration:
requestMaxLimitNum !== undefined && requestMaxLimitNum !== null
? formData.requestResetDuration
: undefined,
};
} else if (hadRateLimit) {
updateData.rate_limit = {} as UpdateTeamRequest["rate_limit"];
}
await updateTeam({ teamId: team.id, data: updateData }).unwrap();
toast.success("Team updated successfully");
} else {
// Create new team
const createData: CreateTeamRequest = {
name: formData.name,
customer_id: formData.customerId || undefined,
budgets:
submittableBudgets.length > 0 ? submittableBudgets : undefined,
};
// Add rate limit if enabled (token or request limits)
if (
(tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
(requestMaxLimitNum !== undefined && requestMaxLimitNum !== null)
) {
createData.rate_limit = {
token_max_limit: tokenMaxLimitNum,
token_reset_duration:
tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null
? formData.tokenResetDuration
: undefined,
request_max_limit: requestMaxLimitNum,
request_reset_duration:
requestMaxLimitNum !== undefined && requestMaxLimitNum !== null
? formData.requestResetDuration
: undefined,
};
}
await createTeam(createData).unwrap();
toast.success("Team created successfully");
}
onSave();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
return (
<Dialog open onOpenChange={onCancel}>
<DialogContent className="max-w-2xl" data-testid="team-dialog-content">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isEditing ? "Edit Team" : "Create Team"}
</DialogTitle>
<DialogDescription>
{isEditing
? "Update the team information and settings."
: "Create a new team to organize users and manage shared resources."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="">
<div className="space-y-6">
{/* Basic Information */}
<div className="">
<div className="space-y-2">
<Label htmlFor="name">Team Name *</Label>
<Input
id="name"
placeholder="e.g., Engineering Team"
value={formData.name}
maxLength={50}
onChange={(e) => updateField("name", e.target.value)}
data-testid="team-name-input"
/>
</div>
{/* Customer Assignment */}
{customers?.length > 0 && (
<div className="space-y-2">
<Label htmlFor="customer">Customer (optional)</Label>
<Select
value={formData.customerId || "__none__"}
onValueChange={(value) =>
updateField(
"customerId",
value === "__none__" ? "" : value,
)
}
>
<SelectTrigger
id="customer"
className="w-full"
data-testid="team-customer-select-trigger"
>
<SelectValue placeholder="Select a customer" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="__none__"
data-testid="team-customer-option-none"
>
None
</SelectItem>
{customers.map((customer) => (
<SelectItem
key={customer.id}
value={customer.id}
data-testid={`team-customer-option-${customer.id}`}
>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-sm">
Assign to a customer or leave independent.
</p>
</div>
)}
</div>
{/* Multi-budget configuration: one row per budget, each keyed by reset_duration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Budgets</Label>
<button
type="button"
onClick={addBudgetRow}
className="text-primary text-xs font-medium hover:underline"
data-testid="team-add-budget-btn"
>
+ Add budget
</button>
</div>
{formData.budgets.length === 0 && (
<p className="text-muted-foreground text-xs">
No budgets. Click "Add budget" to enforce a spend limit.
</p>
)}
{formData.budgets.map((row, idx) => (
<div
key={row.id}
className="space-y-2 rounded-md border p-3"
data-testid={`team-budget-row-${idx}`}
>
<div className="flex items-start gap-2">
<div className="flex-1">
<NumberAndSelect
id={`budgetMaxLimit-${idx}`}
label={`Budget #${idx + 1} — Maximum Spend (USD)`}
value={row.maxLimit}
selectValue={row.resetDuration}
onChangeNumber={(value) =>
updateBudgetRow(idx, { maxLimit: value })
}
onChangeSelect={(value) => {
const patch: Partial<TeamBudgetRow> = {
resetDuration: value,
};
if (!supportsCalendarAlignment(value)) {
patch.calendarAligned = false;
}
updateBudgetRow(idx, patch);
}}
options={resetDurationOptions}
dataTestId={`budget-max-limit-input-${idx}`}
/>
</div>
<button
type="button"
onClick={() => removeBudgetRow(idx)}
className="text-muted-foreground hover:text-destructive mt-6 text-xs font-medium"
data-testid={`team-remove-budget-btn-${idx}`}
>
Remove
</button>
</div>
{row.maxLimit !== undefined &&
supportsCalendarAlignment(row.resetDuration) && (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="space-y-0.5">
<Label
htmlFor={`team-budget-calendar-aligned-toggle-${idx}`}
className="text-sm font-normal"
>
Align to calendar cycle
</Label>
<p className="text-muted-foreground text-xs">
Reset at the start of each period (e.g. 1st of
month) instead of rolling from creation date
</p>
</div>
<Switch
id={`team-budget-calendar-aligned-toggle-${idx}`}
checked={row.calendarAligned}
onCheckedChange={(checked) =>
handleCalendarAlignedChange(idx, checked)
}
data-testid={`team-budget-calendar-aligned-toggle-${idx}`}
/>
</div>
)}
</div>
))}
</div>
{/* Warning dialog shown when enabling calendar alignment on an existing budget */}
<AlertDialog
open={showCalendarAlignWarning}
onOpenChange={(open) => {
if (!open) setPendingCalendarAlignIdx(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset budget usage?</AlertDialogTitle>
<AlertDialogDescription>
Enabling calendar alignment will reset this budget&apos;s
current usage to{" "}
<span className="font-semibold">$0.00</span> and snap the
reset date to the start of the current{" "}
{pendingCalendarAlignIdx !== null &&
formData.budgets[pendingCalendarAlignIdx]?.resetDuration ===
"1d"
? "day"
: pendingCalendarAlignIdx !== null &&
formData.budgets[pendingCalendarAlignIdx]
?.resetDuration === "1w"
? "week"
: pendingCalendarAlignIdx !== null &&
formData.budgets[pendingCalendarAlignIdx]
?.resetDuration === "1M"
? "month"
: pendingCalendarAlignIdx !== null &&
formData.budgets[pendingCalendarAlignIdx]
?.resetDuration === "1Y"
? "year"
: "period"}
. The usage reset to $0.00 cannot be undone, but calendar
alignment can be turned off later. This will take effect
when you save.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="team-calendar-align-cancel-btn"
onClick={() => setPendingCalendarAlignIdx(null)}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
data-testid="team-calendar-align-enable-btn"
onClick={() => {
if (pendingCalendarAlignIdx !== null) {
updateBudgetRow(pendingCalendarAlignIdx, {
calendarAligned: true,
});
}
setPendingCalendarAlignIdx(null);
}}
>
Enable Calendar Alignment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Rate Limit Configuration - Token Limits */}
<NumberAndSelect
id="tokenMaxLimit"
label="Maximum Tokens"
value={formData.tokenMaxLimit}
selectValue={formData.tokenResetDuration}
onChangeNumber={(value) => updateField("tokenMaxLimit", value)}
onChangeSelect={(value) =>
updateField("tokenResetDuration", value)
}
options={resetDurationOptions}
/>
{/* Rate Limit Configuration - Request Limits */}
<NumberAndSelect
id="requestMaxLimit"
label="Maximum Requests"
value={formData.requestMaxLimit}
selectValue={formData.requestResetDuration}
onChangeNumber={(value) => updateField("requestMaxLimit", value)}
onChangeSelect={(value) =>
updateField("requestResetDuration", value)
}
options={resetDurationOptions}
/>
{/* Current Usage Section (only shown when editing with existing limits) */}
{isEditing &&
((team?.budgets && team.budgets.length > 0) ||
team?.rate_limit) && (
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
<p className="text-sm font-medium">Current Usage</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{team?.budgets?.map((b) => (
<div key={b.id} className="space-y-1">
<p className="text-muted-foreground text-xs">
Budget ({b.reset_duration})
</p>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{formatCurrency(b.current_usage)} /{" "}
{formatCurrency(b.max_limit)}
</span>
<Badge
variant={
b.max_limit > 0 && b.current_usage >= b.max_limit
? "destructive"
: "default"
}
className="text-xs"
>
{b.max_limit > 0
? Math.round((b.current_usage / b.max_limit) * 100)
: 0}
%
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Last Reset:{" "}
{formatDistanceToNow(new Date(b.last_reset), {
addSuffix: true,
})}
</p>
</div>
))}
{team?.rate_limit?.token_max_limit && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Tokens</p>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{team.rate_limit.token_current_usage.toLocaleString()}{" "}
/ {team.rate_limit.token_max_limit.toLocaleString()}
</span>
<Badge
variant={
team.rate_limit.token_max_limit > 0 &&
team.rate_limit.token_current_usage >=
team.rate_limit.token_max_limit
? "destructive"
: "default"
}
className="text-xs"
>
{team.rate_limit.token_max_limit > 0
? Math.round(
(team.rate_limit.token_current_usage /
team.rate_limit.token_max_limit) *
100,
)
: 0}
%
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Last Reset:{" "}
{formatDistanceToNow(
new Date(team.rate_limit.token_last_reset),
{ addSuffix: true },
)}
</p>
</div>
)}
{team?.rate_limit?.request_max_limit && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Requests</p>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{team.rate_limit.request_current_usage.toLocaleString()}{" "}
/ {team.rate_limit.request_max_limit.toLocaleString()}
</span>
<Badge
variant={
team.rate_limit.request_max_limit > 0 &&
team.rate_limit.request_current_usage >=
team.rate_limit.request_max_limit
? "destructive"
: "default"
}
className="text-xs"
>
{team.rate_limit.request_max_limit > 0
? Math.round(
(team.rate_limit.request_current_usage /
team.rate_limit.request_max_limit) *
100,
)
: 0}
%
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Last Reset:{" "}
{formatDistanceToNow(
new Date(team.rate_limit.request_last_reset),
{ addSuffix: true },
)}
</p>
</div>
)}
</div>
</div>
)}
</div>
<FormFooter
validator={validator}
label="Team"
onCancel={onCancel}
isLoading={loading}
isEditing={isEditing}
hasPermission={hasPermission}
/>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Building } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
const TEAMS_DOCS_URL = "https://docs.getbifrost.ai/features/governance/virtual-keys#teams";
interface TeamsEmptyStateProps {
onAddClick: () => void;
canCreate?: boolean;
}
export function TeamsEmptyState({ onAddClick, canCreate = true }: TeamsEmptyStateProps) {
return (
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
<div className="text-muted-foreground">
<Building 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">Teams organize users with shared budgets and access</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
Create teams to group users, assign customer accounts, and set budgets and rate limits at the team level.
</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 teams (opens in new tab)"
data-testid="team-button-read-more"
onClick={() => {
window.open(`${TEAMS_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 team" onClick={onAddClick} disabled={!canCreate} data-testid="team-button-add">
Add Team
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,450 @@
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 { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { resetDurationLabels } from "@/lib/constants/governance";
import { getErrorMessage, useDeleteTeamMutation } from "@/lib/store";
import { Customer, Team, VirtualKey } from "@/lib/types/governance";
import { cn } from "@/lib/utils";
import { formatCurrency } from "@/lib/utils/governance";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { Input } from "@/components/ui/input";
import { ChevronLeft, ChevronRight, Edit, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import TeamDialog from "./teamDialog";
import { TeamsEmptyState } from "./teamsEmptyState";
// Helper to format reset duration for display
const formatResetDuration = (duration: string) => {
return resetDurationLabels[duration] || duration;
};
interface TeamsTableProps {
teams: Team[];
totalCount: number;
customers: Customer[];
virtualKeys: VirtualKey[];
search: string;
debouncedSearch: string;
onSearchChange: (value: string) => void;
offset: number;
limit: number;
onOffsetChange: (offset: number) => void;
}
export default function TeamsTable({
teams,
totalCount,
customers,
virtualKeys,
search,
debouncedSearch,
onSearchChange,
offset,
limit,
onOffsetChange,
}: TeamsTableProps) {
const [showTeamDialog, setShowTeamDialog] = useState(false);
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
const hasCreateAccess = useRbac(RbacResource.Teams, RbacOperation.Create);
const hasUpdateAccess = useRbac(RbacResource.Teams, RbacOperation.Update);
const hasDeleteAccess = useRbac(RbacResource.Teams, RbacOperation.Delete);
const [deleteTeam, { isLoading: isDeleting }] = useDeleteTeamMutation();
const handleDelete = async (teamId: string) => {
try {
await deleteTeam(teamId).unwrap();
toast.success("Team deleted successfully");
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const handleAddTeam = () => {
setEditingTeam(null);
setShowTeamDialog(true);
};
const handleEditTeam = (team: Team) => {
setEditingTeam(team);
setShowTeamDialog(true);
};
const handleTeamSaved = () => {
setShowTeamDialog(false);
setEditingTeam(null);
};
const getVirtualKeysForTeam = (teamId: string) => {
return virtualKeys.filter((vk) => vk.team_id === teamId);
};
const getCustomerName = (customerId?: string) => {
if (!customerId) return "-";
const customer = customers.find((c) => c.id === customerId);
return customer ? customer.name : "Unknown Customer";
};
const hasActiveFilters = debouncedSearch;
// True empty state: no teams at all (not just filtered to zero)
if (totalCount === 0 && !hasActiveFilters) {
return (
<>
<TooltipProvider>
{showTeamDialog && (
<TeamDialog team={editingTeam} customers={customers} onSave={handleTeamSaved} onCancel={() => setShowTeamDialog(false)} />
)}
<TeamsEmptyState onAddClick={handleAddTeam} canCreate={hasCreateAccess} />
</TooltipProvider>
</>
);
}
return (
<>
<TooltipProvider>
{showTeamDialog && (
<TeamDialog team={editingTeam} customers={customers} onSave={handleTeamSaved} onCancel={() => setShowTeamDialog(false)} />
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Teams</h2>
<p className="text-muted-foreground text-sm">Organize users into teams with shared budgets and access controls.</p>
</div>
<Button data-testid="create-team-btn" onClick={handleAddTeam} disabled={!hasCreateAccess}>
<Plus className="h-4 w-4" />
Add Team
</Button>
</div>
<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 teams by name"
placeholder="Search by name..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
data-testid="teams-search-input"
/>
</div>
</div>
<div className="overflow-hidden rounded-sm border" data-testid="teams-table">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Budget</TableHead>
<TableHead>Rate Limit</TableHead>
<TableHead>Virtual Keys</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teams.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
<span className="text-muted-foreground text-sm">No matching teams found.</span>
</TableCell>
</TableRow>
) : (
teams.map((team) => {
const vks = getVirtualKeysForTeam(team.id);
const customerName = getCustomerName(team.customer_id);
// Budget calculations — any of the team's budgets exhausted
const teamBudgets = team.budgets ?? [];
const isBudgetExhausted = teamBudgets.some(
(b) => b.max_limit > 0 && b.current_usage >= b.max_limit,
);
// Rate limit calculations
const isTokenLimitExhausted =
team.rate_limit?.token_max_limit &&
team.rate_limit.token_max_limit > 0 &&
team.rate_limit.token_current_usage >= team.rate_limit.token_max_limit;
const isRequestLimitExhausted =
team.rate_limit?.request_max_limit &&
team.rate_limit.request_max_limit > 0 &&
team.rate_limit.request_current_usage >= team.rate_limit.request_max_limit;
const isRateLimitExhausted = isTokenLimitExhausted || isRequestLimitExhausted;
const tokenPercentage =
team.rate_limit?.token_max_limit && team.rate_limit.token_max_limit > 0
? Math.min((team.rate_limit.token_current_usage / team.rate_limit.token_max_limit) * 100, 100)
: 0;
const requestPercentage =
team.rate_limit?.request_max_limit && team.rate_limit.request_max_limit > 0
? Math.min((team.rate_limit.request_current_usage / team.rate_limit.request_max_limit) * 100, 100)
: 0;
const isExhausted = isBudgetExhausted || isRateLimitExhausted;
return (
<TableRow
key={team.id}
data-testid={`team-row-${team.name}`}
className={cn("group transition-colors", isExhausted && "bg-red-500/5 hover:bg-red-500/10")}
>
<TableCell className="max-w-[200px] py-4">
<div className="flex flex-col gap-2">
<span className="truncate font-medium">{team.name}</span>
{isExhausted && (
<Badge variant="destructive" className="w-fit text-xs">
Limit Reached
</Badge>
)}
</div>
</TableCell>
<TableCell data-testid={`team-row-${team.name}-customer`}>
<div className="flex items-center gap-2">
<Badge variant={team.customer_id ? "secondary" : "outline"}>{customerName}</Badge>
</div>
</TableCell>
<TableCell className="min-w-[180px]">
{teamBudgets.length > 0 ? (
<div className="space-y-2.5">
{teamBudgets.map((b) => {
const budgetPercentage =
b.max_limit > 0 ? Math.min((b.current_usage / b.max_limit) * 100, 100) : 0;
const isExhausted = b.max_limit > 0 && b.current_usage >= b.max_limit;
return (
<Tooltip key={b.id}>
<TooltipTrigger asChild>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-4">
<span className="font-medium">{formatCurrency(b.max_limit)}</span>
<span className="text-muted-foreground text-xs">
{formatResetDuration(b.reset_duration)}
</span>
</div>
<Progress
value={budgetPercentage}
className={cn(
"bg-muted/70 dark:bg-muted/30 h-1.5",
isExhausted
? "[&>div]:bg-red-500/70"
: budgetPercentage > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)}
</p>
<p className="text-primary-foreground/80 text-xs">
Resets {formatResetDuration(b.reset_duration)}
</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="min-w-[180px]">
{team.rate_limit ? (
<div className="space-y-2.5">
{team.rate_limit.token_max_limit && (
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-4 text-xs">
<span className="font-medium">{team.rate_limit.token_max_limit.toLocaleString()} tokens</span>
<span className="text-muted-foreground">
{formatResetDuration(team.rate_limit.token_reset_duration || "1h")}
</span>
</div>
<Progress
value={tokenPercentage}
className={cn(
"bg-muted/70 dark:bg-muted/30 h-1",
isTokenLimitExhausted
? "[&>div]:bg-red-500/70"
: tokenPercentage > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{team.rate_limit.token_current_usage.toLocaleString()} /{" "}
{team.rate_limit.token_max_limit.toLocaleString()} tokens
</p>
<p className="text-primary-foreground/80 text-xs">
Resets {formatResetDuration(team.rate_limit.token_reset_duration || "1h")}
</p>
</TooltipContent>
</Tooltip>
)}
{team.rate_limit.request_max_limit && (
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-4 text-xs">
<span className="font-medium">{team.rate_limit.request_max_limit.toLocaleString()} req</span>
<span className="text-muted-foreground">
{formatResetDuration(team.rate_limit.request_reset_duration || "1h")}
</span>
</div>
<Progress
value={requestPercentage}
className={cn(
"bg-muted/70 dark:bg-muted/30 h-1",
isRequestLimitExhausted
? "[&>div]:bg-red-500/70"
: requestPercentage > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{team.rate_limit.request_current_usage.toLocaleString()} /{" "}
{team.rate_limit.request_max_limit.toLocaleString()} requests
</p>
<p className="text-primary-foreground/80 text-xs">
Resets {formatResetDuration(team.rate_limit.request_reset_duration || "1h")}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
{vks.length > 0 ? (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs">
{vks.length} {vks.length === 1 ? "key" : "keys"}
</Badge>
</TooltipTrigger>
<TooltipContent>{vks.map((vk) => vk.name).join(", ")}</TooltipContent>
</Tooltip>
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditTeam(team)}
disabled={!hasUpdateAccess}
aria-label={`Edit team ${team.name}`}
data-testid={`team-edit-btn-${team.name}`}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:bg-red-500/10 hover:text-red-500"
disabled={!hasDeleteAccess}
aria-label={`Delete team ${team.name}`}
data-testid={`team-delete-btn-${team.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Team</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{team.name}&quot;? This will also unassign any virtual keys from
this team. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(team.id)}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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="teams-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="teams-pagination-next-btn"
>
Next <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</TooltipProvider>
</>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import GovernanceVirtualKeysPage from "./page";
export const Route = createFileRoute("/workspace/governance/virtual-keys")({
component: GovernanceVirtualKeysPage,
});

View File

@@ -0,0 +1,145 @@
import VirtualKeysTable from "@/app/workspace/virtual-keys/views/virtualKeysTable";
import FullPageLoader from "@/components/fullPageLoader";
import { useDebouncedValue } from "@/hooks/useDebounce";
import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
const POLLING_INTERVAL = 5000;
const PAGE_SIZE = 25;
export default function GovernanceVirtualKeysPage() {
const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
const shownErrorsRef = useRef(new Set<string>());
const [urlState, setUrlState] = useQueryStates(
{
search: parseAsString.withDefault(""),
customer_id: parseAsString.withDefault(""),
team_id: parseAsString.withDefault(""),
offset: parseAsInteger.withDefault(0),
sort_by: parseAsString.withDefault(""),
order: parseAsString.withDefault(""),
},
{ history: "push" },
);
const debouncedSearch = useDebouncedValue(urlState.search, 300);
const {
data: virtualKeysData,
error: vkError,
isLoading: vkLoading,
} = useGetVirtualKeysQuery(
{
limit: PAGE_SIZE,
offset: urlState.offset,
search: debouncedSearch || undefined,
customer_id: urlState.customer_id || undefined,
team_id: urlState.team_id || undefined,
sort_by: (urlState.sort_by as "name" | "budget_spent" | "created_at" | "status") || undefined,
order: (urlState.order as "asc" | "desc") || undefined,
},
{
skip: !hasVirtualKeysAccess,
pollingInterval: POLLING_INTERVAL,
},
);
const {
data: teamsData,
error: teamsError,
isLoading: teamsLoading,
} = useGetTeamsQuery(undefined, {
skip: !hasTeamsAccess,
pollingInterval: POLLING_INTERVAL,
});
const {
data: customersData,
error: customersError,
isLoading: customersLoading,
} = useGetCustomersQuery(undefined, {
skip: !hasCustomersAccess,
pollingInterval: POLLING_INTERVAL,
});
const vkTotal = virtualKeysData?.total_count ?? 0;
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
useEffect(() => {
if (!virtualKeysData || urlState.offset < vkTotal) return;
setUrlState({ offset: vkTotal === 0 ? 0 : Math.floor((vkTotal - 1) / PAGE_SIZE) * PAGE_SIZE });
}, [vkTotal, urlState.offset]);
const isLoading = vkLoading || teamsLoading || customersLoading;
useEffect(() => {
if (!vkError && !teamsError && !customersError) {
shownErrorsRef.current.clear();
return;
}
const errorKey = `${!!vkError}-${!!teamsError}-${!!customersError}`;
if (shownErrorsRef.current.has(errorKey)) return;
shownErrorsRef.current.add(errorKey);
if (vkError && teamsError && customersError) {
toast.error("Failed to load governance data.");
} else {
if (vkError) toast.error(`Failed to load virtual keys: ${getErrorMessage(vkError)}`);
if (teamsError) toast.error(`Failed to load teams: ${getErrorMessage(teamsError)}`);
if (customersError) toast.error(`Failed to load customers: ${getErrorMessage(customersError)}`);
}
}, [vkError, teamsError, customersError]);
if (isLoading) {
return <FullPageLoader />;
}
const handleSearchChange = (value: string) => {
setUrlState({ search: value || null, offset: 0 });
};
const handleCustomerFilterChange = (value: string) => {
setUrlState({ customer_id: value || null, offset: 0 });
};
const handleTeamFilterChange = (value: string) => {
setUrlState({ team_id: value || null, offset: 0 });
};
const handleOffsetChange = (newOffset: number) => {
setUrlState({ offset: newOffset });
};
const handleSortChange = (newSortBy: string, newOrder: string) => {
setUrlState({ sort_by: newSortBy || null, order: newOrder || null, offset: 0 });
};
return (
<div className="mx-auto w-full max-w-7xl">
<VirtualKeysTable
virtualKeys={virtualKeysData?.virtual_keys || []}
totalCount={virtualKeysData?.total_count || 0}
teams={teamsData?.teams || []}
customers={customersData?.customers || []}
search={urlState.search}
debouncedSearch={debouncedSearch}
onSearchChange={handleSearchChange}
customerFilter={urlState.customer_id}
onCustomerFilterChange={handleCustomerFilterChange}
teamFilter={urlState.team_id}
onTeamFilterChange={handleTeamFilterChange}
offset={urlState.offset}
limit={PAGE_SIZE}
onOffsetChange={handleOffsetChange}
sortBy={urlState.sort_by}
order={urlState.order}
onSortChange={handleSortChange}
/>
</div>
);
}