"use client"; import { Button } from "@app/components/ui/button"; import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; import { Input } from "@app/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { toast } from "@app/hooks/useToast"; import { cn } from "@app/lib/cn"; import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; import { REGIONS, getRegionNameById } from "@server/db/regions"; import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; import { ArrowUpDown, Check, ChevronsUpDown, GripVertical, LockIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { validatePolicyRulePriority, validatePolicyRuleValue } from "./policy-access-rule-validation"; import { buildDisplayPrioritiesForResourceOverlay, reorderPolicyRules, reorderResourceOverlayRules, setResourceRuleDisplayPriority, sortPolicyRulesByPriority, sortPolicyRulesForResourceOverlay, type PolicyAccessRule } from "./policy-access-rule-utils"; export type PolicyAccessRulesTableProps = { rules: PolicyAccessRule[]; onRulesChange: (rules: PolicyAccessRule[]) => void; updateRule: (ruleId: number, data: Partial) => void; removeRule: (ruleId: number) => void; isMaxmindAvailable: boolean; isMaxmindAsnAvailable: boolean; emptyStateAction: ReactNode; readonly?: boolean; includeRegionMatch?: boolean; markUpdatedOnReorder?: boolean; resourceOverlayMode?: boolean; isRuleDraggable?: (rule: PolicyAccessRule) => boolean; isRuleLocked?: (rule: PolicyAccessRule) => boolean; }; function getColumnClassName(columnId: string) { if (columnId === "actions") { return "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"; } if (columnId === "dragHandle") { return "w-8 max-w-8 px-2"; } if (columnId === "priority") { return "w-24 max-w-24"; } if (columnId === "action") { return "w-42 max-w-42"; } if (columnId === "match") { return "w-42 max-w-42"; } return ""; } export function PolicyAccessRulesTable({ rules, onRulesChange, updateRule, removeRule, isMaxmindAvailable, isMaxmindAsnAvailable, emptyStateAction, readonly = false, includeRegionMatch = false, markUpdatedOnReorder = false, resourceOverlayMode = false, isRuleDraggable: isRuleDraggableProp, isRuleLocked: isRuleLockedProp }: PolicyAccessRulesTableProps) { const t = useTranslations(); const [draggedRuleId, setDraggedRuleId] = useState(null); const [dragOverRuleId, setDragOverRuleId] = useState(null); const isRuleLocked = useCallback( (rule: PolicyAccessRule) => isRuleLockedProp ? isRuleLockedProp(rule) : Boolean(rule.fromPolicy), [isRuleLockedProp] ); const isRuleDraggable = useCallback( (rule: PolicyAccessRule) => isRuleDraggableProp ? isRuleDraggableProp(rule) : !readonly && !isRuleLocked(rule), [isRuleDraggableProp, isRuleLocked, readonly] ); const sortedRules = useMemo( () => resourceOverlayMode ? sortPolicyRulesForResourceOverlay(rules) : sortPolicyRulesByPriority(rules), [rules, resourceOverlayMode] ); const displayPriorities = useMemo( () => resourceOverlayMode ? buildDisplayPrioritiesForResourceOverlay(rules) : null, [rules, resourceOverlayMode] ); const resourceRuleCount = useMemo( () => rules.filter((rule) => !rule.fromPolicy).length, [rules] ); const handleReorder = useCallback( (fromRuleId: number, toRuleId: number) => { if (resourceOverlayMode) { onRulesChange( reorderResourceOverlayRules(rules, fromRuleId, toRuleId, { markUpdated: markUpdatedOnReorder }) ); return; } const fromIndex = sortedRules.findIndex( (rule) => rule.ruleId === fromRuleId ); const toIndex = sortedRules.findIndex( (rule) => rule.ruleId === toRuleId ); if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { return; } const reordered = reorderPolicyRules( sortedRules, fromIndex, toIndex, { markUpdated: markUpdatedOnReorder } ); onRulesChange(reordered); }, [ rules, sortedRules, onRulesChange, markUpdatedOnReorder, resourceOverlayMode ] ); const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { setDraggedRuleId(ruleId); e.dataTransfer.effectAllowed = "move"; }, []); const handleDragEnd = useCallback(() => { setDraggedRuleId(null); setDragOverRuleId(null); }, []); const RuleAction = useMemo( () => ({ ACCEPT: t("alwaysAllow"), DROP: t("alwaysDeny"), PASS: t("passToAuth") }), [t] ); const RuleMatch = useMemo( () => ({ PATH: t("path"), IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), COUNTRY_IS_NOT: t("countryIsNot"), ASN: "ASN", REGION: t("region") }), [t] ); const columns: ColumnDef[] = useMemo( () => [ { id: "dragHandle", size: 32, maxSize: 32, header: () => null, cell: ({ row }) => isRuleDraggable(row.original) ? ( ) : null }, { accessorKey: "priority", size: 96, maxSize: 96, header: ({ column }) => (
{resourceOverlayMode ? ( {t("rulesPriority")} ) : ( )}
), cell: ({ row }) => { const displayPriority = resourceOverlayMode ? (displayPriorities?.get(row.original.ruleId) ?? row.original.priority) : row.original.priority; return ( e.currentTarget.focus()} onBlur={(e) => { const validated = validatePolicyRulePriority( t, e.target.value ); if (!validated.success) { toast({ variant: "destructive", ...validated.toast }); return; } if (resourceOverlayMode) { if ( validated.data > resourceRuleCount || validated.data < 1 ) { toast({ variant: "destructive", title: t( "rulesErrorInvalidPriority" ), description: t( "rulesErrorInvalidPriorityDescription" ) }); return; } const duplicateDisplayPriority = rules.some( (rule) => !rule.fromPolicy && rule.ruleId !== row.original.ruleId && displayPriorities?.get( rule.ruleId ) === validated.data ); if (duplicateDisplayPriority) { toast({ variant: "destructive", title: t( "rulesErrorDuplicatePriority" ), description: t( "rulesErrorDuplicatePriorityDescription" ) }); return; } if (validated.data === displayPriority) { return; } onRulesChange( setResourceRuleDisplayPriority( rules, row.original.ruleId, validated.data, { markUpdated: markUpdatedOnReorder } ) ); return; } const duplicatePriority = rules.some( (rule) => rule.ruleId !== row.original.ruleId && rule.priority === validated.data ); if (duplicatePriority) { toast({ variant: "destructive", title: t("rulesErrorDuplicatePriority"), description: t( "rulesErrorDuplicatePriorityDescription" ) }); return; } updateRule(row.original.ruleId, { priority: validated.data }); }} /> ); } }, { accessorKey: "action", size: 160, maxSize: 160, header: () => {t("rulesAction")}, cell: ({ row }) => ( ) }, { accessorKey: "match", size: 144, maxSize: 144, header: () => ( {t("rulesMatchType")} ), cell: ({ row }) => ( ) }, { accessorKey: "value", header: () => {t("value")}, cell: ({ row }) => row.original.match === "COUNTRY" || row.original.match === "COUNTRY_IS_NOT" ? ( {t("noCountryFound")} {COUNTRIES.map((country) => ( updateRule( row.original.ruleId, { value: country.code } ) } > {country.name} ( {country.code}) ))} ) : row.original.match === "ASN" ? ( No ASN found. Enter a custom ASN below. {MAJOR_ASNS.map((asn) => ( updateRule( row.original.ruleId, { value: asn.code } ) } > {asn.name} ({asn.code}) ))}
asn.code === row.original.value ) ? row.original.value : "" } onKeyDown={(e) => { if (e.key === "Enter") { const value = e.currentTarget.value .toUpperCase() .replace(/^AS/, ""); if (/^\d+$/.test(value)) { updateRule( row.original.ruleId, { value: "AS" + value } ); } } }} className="text-sm" />
) : row.original.match === "REGION" ? ( {t("noRegionFound")} {REGIONS.map((continent) => ( updateRule( row.original.ruleId, { value: continent.id } ) } > {t(continent.name)} ( {continent.id}) {continent.includes.map( (subregion) => ( updateRule( row.original .ruleId, { value: subregion.id } ) } > {t(subregion.name)}{" "} ({subregion.id}) ) )} ))} ) : ( { const validated = validatePolicyRuleValue( t, row.original.match, e.target.value ); if (!validated.success) { toast({ variant: "destructive", ...validated.toast }); return; } updateRule(row.original.ruleId, { value: validated.data }); }} /> ) }, { accessorKey: "enabled", header: () => {t("enabled")}, cell: ({ row }) => (
updateRule(row.original.ruleId, { enabled: val }) } />
) }, { id: "actions", header: () => null, cell: ({ row }) => (
{isRuleLocked(row.original) ? ( ) : ( )}
) } ], [ t, RuleAction, RuleMatch, isMaxmindAvailable, isMaxmindAsnAvailable, includeRegionMatch, updateRule, onRulesChange, removeRule, readonly, rules, resourceOverlayMode, displayPriorities, resourceRuleCount, markUpdatedOnReorder, isRuleDraggable, isRuleLocked, handleDragStart, handleDragEnd ] ); const table = useReactTable({ data: sortedRules, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); return ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const columnId = header.column.id; return ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ); })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => { const rule = row.original; return ( { e.preventDefault(); if ( draggedRuleId !== null && draggedRuleId !== rule.ruleId ) { setDragOverRuleId(rule.ruleId); } }} onDrop={(e) => { e.preventDefault(); if ( draggedRuleId !== null && draggedRuleId !== rule.ruleId && isRuleDraggable(rule) ) { handleReorder( draggedRuleId, rule.ruleId ); } setDraggedRuleId(null); setDragOverRuleId(null); }} className={cn( draggedRuleId === rule.ruleId && "opacity-50", dragOverRuleId === rule.ruleId && "border-t border-primary" )} > {row.getVisibleCells().map((cell) => { const columnId = cell.column.id; return ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ); })} ); }) ) : ( )}
); }