diff --git a/messages/en-US.json b/messages/en-US.json index 428d0a2d4..8454467b2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -784,6 +784,7 @@ "ruleErrorUpdate": "Operation failed", "ruleErrorUpdateDescription": "An error occurred during the save operation", "rulesPriority": "Priority", + "rulesReorderDragHandle": "Drag to reorder rule priority", "rulesAction": "Action", "rulesMatchType": "Match Type", "value": "Value", diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx index c78ebd37f..042b63e63 100644 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -12,76 +12,18 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; -import { toast } from "@app/hooks/useToast"; -import { - validatePolicyRulePriority, - validatePolicyRuleValue -} from "./policy-access-rule-validation"; - 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 { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; +import { Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; -import { createEmptyRule } from "./policy-access-rule-utils"; - -// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── - -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; -}; +import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { + createEmptyRule, + type PolicyAccessRule +} from "./policy-access-rule-utils"; export type CreatePolicyRulesSectionFormProps = { form: UseFormReturn; @@ -95,7 +37,7 @@ export function CreatePolicyRulesSectionForm({ isMaxmindAsnAvailable }: CreatePolicyRulesSectionFormProps) { const t = useTranslations(); - const [rules, setRules] = useState([]); + const [rules, setRules] = useState([]); const rulesFormSchema = useMemo( () => createPolicyRulesSectionSchema(t), @@ -123,28 +65,8 @@ export function CreatePolicyRulesSectionForm({ name: "applyRules" }); - 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"), - ASN: "ASN" - }), - [t] - ); - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { + (updatedRules: PolicyAccessRule[]) => { form.setValue( "rules", updatedRules.map( @@ -177,7 +99,7 @@ export function CreatePolicyRulesSectionForm({ ); const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { + function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } @@ -189,369 +111,14 @@ export function CreatePolicyRulesSectionForm({ [rules, syncFormRules] ); - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - size: 96, - maxSize: 96, - header: ({ column }) => ( -
- -
- ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const validated = validatePolicyRulePriority( - t, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast - }); - 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" ? ( - - - - - - - - - - {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" - /> -
-
-
- ) : ( - { - 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 }) => ( -
- -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule, - rules - ] + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] ); - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - const addRuleButton = ( ); + const hasRules = rules.length > 0; + return ( @@ -580,117 +149,17 @@ export function CreatePolicyRulesSectionForm({ {rulesEnabled && ( <> - - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const columnId = - header.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const columnId = - cell.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - )} - -
- {table.getRowModel().rows?.length > 0 && - addRuleButton} + + {hasRules && addRuleButton} )} diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index daa7eba32..196fd9b21 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -15,64 +15,12 @@ import { useTranslations } from "next-intl"; import { toast } from "@app/hooks/useToast"; import { createPolicyRulesSectionSchema, - validatePolicyRulePriority, - validatePolicyRuleValue, validatePolicyRulesForSave, type PolicyFormValues } from "."; 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 { 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, - LockIcon, - Plus -} from "lucide-react"; +import { Plus } from "lucide-react"; import { useCallback, @@ -92,22 +40,14 @@ import type { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; -import { createEmptyRule } from "./policy-access-rule-utils"; +import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { + createEmptyRule, + type PolicyAccessRule +} from "./policy-access-rule-utils"; // ─── PolicyRulesSection ─────────────────────────────────────────────────────── -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; - fromPolicy?: boolean; -}; - type PolicyAccessRulesSectionEditProps = { mode: "edit"; isMaxmindAvailable: boolean; @@ -148,7 +88,6 @@ function PolicyAccessRulesSectionEdit({ const isResourceOverlay = resourceId !== undefined; - // ── Fetch resource-specific rules when in overlay mode ─────────────────── const { data: resourceRulesData } = useQuery({ ...resourceQueries.resourceRules({ resourceId: resourceId! }), enabled: isResourceOverlay @@ -176,17 +115,16 @@ function PolicyAccessRulesSectionEdit({ name: "applyRules" }); - const [rules, setRules] = useState( + const [rules, setRules] = useState( policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) ); - // Initialize resource-specific rules once fetched useEffect(() => { if (!isResourceOverlay || resourceRulesInitialized) return; if (!resourceRulesData) return; const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); - const resourceSpecific: LocalRule[] = resourceRulesData + const resourceSpecific: PolicyAccessRule[] = resourceRulesData .filter((r) => !policyRuleIds.has(r.ruleId)) .map((r) => ({ ruleId: r.ruleId, @@ -210,29 +148,8 @@ function PolicyAccessRulesSectionEdit({ policy.rules ]); - 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"), - ASN: "ASN", - REGION: t("region") - }), - [t] - ); - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { + (updatedRules: PolicyAccessRule[]) => { form.setValue( "rules", updatedRules.map( @@ -258,8 +175,7 @@ function PolicyAccessRulesSectionEdit({ const removeRule = useCallback( function removeRule(ruleId: number) { const rule = rules.find((r) => r.ruleId === ruleId); - if (!rule || rule.fromPolicy) return; // cannot remove policy rules - // Track deletion for resource overlay mode (only for existing DB rules) + if (!rule || rule.fromPolicy) return; if (isResourceOverlay && !rule.new) { deletedResourceRuleIdsRef.current.add(ruleId); } @@ -271,7 +187,7 @@ function PolicyAccessRulesSectionEdit({ ); const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { + function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } @@ -283,517 +199,14 @@ function PolicyAccessRulesSectionEdit({ [rules, syncFormRules] ); - const sortedRules = useMemo( - () => [...rules].sort((a, b) => a.priority - b.priority), - [rules] + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] ); - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - size: 96, - maxSize: 96, - header: ({ column }) => ( -
- -
- ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const validated = validatePolicyRulePriority( - t, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast - }); - 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" ? ( - - - - - - - - - - {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 }) => ( -
- {row.original.fromPolicy ? ( - - ) : ( - - )} -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule, - readonly, - rules - ] - ); - - const table = useReactTable({ - data: sortedRules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - const [isPending, startTransition] = useTransition(); async function saveRules() { @@ -930,6 +343,8 @@ function PolicyAccessRulesSectionEdit({ ); + const hasRules = rules.length > 0; + return ( @@ -952,117 +367,19 @@ function PolicyAccessRulesSectionEdit({ {rulesEnabled && ( <> - - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const columnId = - header.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const columnId = - cell.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - )} - -
- {table.getRowModel().rows?.length > 0 && - addRuleButton} + + {hasRules && addRuleButton} )} diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx new file mode 100644 index 000000000..4f1052d4f --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -0,0 +1,824 @@ +"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 { + reorderPolicyRules, + sortPolicyRulesByPriority, + 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; + 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-40 max-w-40"; + } + if (columnId === "match") { + return "w-36 max-w-36"; + } + return ""; +} + +export function PolicyAccessRulesTable({ + rules, + onRulesChange, + updateRule, + removeRule, + isMaxmindAvailable, + isMaxmindAsnAvailable, + emptyStateAction, + readonly = false, + includeRegionMatch = false, + markUpdatedOnReorder = 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( + () => sortPolicyRulesByPriority(rules), + [rules] + ); + + const handleReorder = useCallback( + (fromRuleId: number, toRuleId: number) => { + 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); + }, + [sortedRules, onRulesChange, markUpdatedOnReorder] + ); + + 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"), + 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 }) => ( +
+ +
+ ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + 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" ? ( + + + + + + + + + + {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, + removeRule, + readonly, + rules, + 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 + ) { + handleReorder( + draggedRuleId, + rule.ruleId + ); + } + setDraggedRuleId(null); + setDragOverRuleId(null); + }} + className={cn( + draggedRuleId === rule.ruleId && + "opacity-50", + dragOverRuleId === rule.ruleId && + "border-t-2 border-primary" + )} + > + {row.getVisibleCells().map((cell) => { + const columnId = cell.column.id; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + ); + }) + ) : ( + + )} + +
+ ); +} diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts index 1010d006e..a0456515b 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -57,6 +57,15 @@ export type LocalRule = { updated?: boolean; }; +export { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +export type { PolicyAccessRulesTableProps } from "./PolicyAccessRulesTable"; +export { + createEmptyRule, + reorderPolicyRules, + sortPolicyRulesByPriority, + type EmptyRuleDraft, + type PolicyAccessRule +} from "./policy-access-rule-utils"; export { createPolicyRulePrioritySchema, createPolicyRuleSchema, diff --git a/src/components/resource-policy/policy-access-rule-utils.ts b/src/components/resource-policy/policy-access-rule-utils.ts index 07302caed..50178a4a5 100644 --- a/src/components/resource-policy/policy-access-rule-utils.ts +++ b/src/components/resource-policy/policy-access-rule-utils.ts @@ -1,10 +1,16 @@ -export type EmptyRuleDraft = { +export type PolicyAccessRule = { ruleId: number; action: "ACCEPT" | "DROP" | "PASS"; match: string; value: string; priority: number; enabled: boolean; + new?: boolean; + updated?: boolean; + fromPolicy?: boolean; +}; + +export type EmptyRuleDraft = PolicyAccessRule & { new: true; }; @@ -27,3 +33,40 @@ export function createEmptyRule( new: true }; } + +export function sortPolicyRulesByPriority( + rules: T[] +): T[] { + return [...rules].sort((a, b) => a.priority - b.priority); +} + +export function reorderPolicyRules< + T extends { priority: number; new?: boolean; updated?: boolean } +>( + rules: T[], + fromIndex: number, + toIndex: number, + options?: { markUpdated?: boolean } +): T[] { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= rules.length || + toIndex >= rules.length + ) { + return rules; + } + + const reordered = [...rules]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + return reordered.map((rule, index) => { + const next = { ...rule, priority: index + 1 }; + if (options?.markUpdated && !rule.new) { + return { ...next, updated: true }; + } + return next; + }); +}