make the rule rows draggable

This commit is contained in:
miloschwartz
2026-06-06 12:27:11 -07:00
parent dd8bcbb3e3
commit 7b1f8d98f3
6 changed files with 938 additions and 1275 deletions

View File

@@ -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",

View File

@@ -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<PolicyFormValues, any, any>;
@@ -95,7 +37,7 @@ export function CreatePolicyRulesSectionForm({
isMaxmindAsnAvailable
}: CreatePolicyRulesSectionFormProps) {
const t = useTranslations();
const [rules, setRules] = useState<LocalRule[]>([]);
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
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<LocalRule>) {
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
const updatedRules = rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
@@ -189,369 +111,14 @@ export function CreatePolicyRulesSectionForm({
[rules, syncFormRules]
);
const columns: ColumnDef<LocalRule>[] = useMemo(
() => [
{
accessorKey: "priority",
size: 96,
maxSize: 96,
header: ({ column }) => (
<div className="p-3">
<Button
variant="ghost"
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</div>
),
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-full min-w-0"
type="number"
onClick={(e) => 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: () => <span className="p-3">{t("rulesAction")}</span>,
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
updateRule(row.original.ruleId, { action: value })
}
>
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "match",
size: 144,
maxSize: 144,
header: () => (
<span className="p-3">{t("rulesMatchType")}</span>
),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY"
? "US"
: value === "ASN"
? "AS15169"
: row.original.value
})
}
>
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="COUNTRY">
{RuleMatch.COUNTRY}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
</SelectItem>
)}
</SelectContent>
</Select>
)
},
{
accessorKey: "value",
header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) =>
row.original.match === "COUNTRY" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="min-w-50 justify-between"
>
{row.original.value
? COUNTRIES.find(
(c) =>
c.code === row.original.value
)?.name +
" (" +
row.original.value +
")"
: t("selectCountry")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput
placeholder={t("searchCountries")}
/>
<CommandList>
<CommandEmpty>
{t("noCountryFound")}
</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() =>
updateRule(
row.original.ruleId,
{
value: country.code
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
/>
{country.name} (
{country.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "ASN" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="min-w-50 justify-between"
>
{row.original.value
? (() => {
const found = MAJOR_ASNS.find(
(asn) =>
asn.code ===
row.original.value
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
})()
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput placeholder="Search ASNs or enter custom..." />
<CommandList>
<CommandEmpty>
No ASN found. Enter a custom ASN
below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map((asn) => (
<CommandItem
key={asn.code}
value={
asn.name +
" " +
asn.code
}
onSelect={() =>
updateRule(
row.original.ruleId,
{ value: asn.code }
)
}
>
<Check
className={`mr-2 h-4 w-4 ${row.original.value === asn.code ? "opacity-100" : "opacity-0"}`}
/>
{asn.name} ({asn.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
defaultValue={
!MAJOR_ASNS.find(
(asn) =>
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"
/>
</div>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
className="min-w-50"
onBlur={(e) => {
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: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, {
enabled: val
})
}
/>
</div>
)
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
{t("delete")}
</Button>
</div>
)
}
],
[
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 = (
<Button type="button" variant="outline" onClick={addEmptyRule}>
<Plus className="h-4 w-4 mr-2" />
@@ -559,6 +126,8 @@ export function CreatePolicyRulesSectionForm({
</Button>
);
const hasRules = rules.length > 0;
return (
<SettingsSection>
<SettingsSectionHeader>
@@ -580,117 +149,17 @@ export function CreatePolicyRulesSectionForm({
{rulesEnabled && (
<>
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{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 (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"
: isPriorityColumn
? "w-24 max-w-24"
: isActionColumn
? "w-40 max-w-40"
: isMatchColumn
? "w-36 max-w-36"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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 (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"
: isPriorityColumn
? "w-24 max-w-24"
: isActionColumn
? "w-40 max-w-40"
: isMatchColumn
? "w-36 max-w-36"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("rulesNoOne")}
action={addRuleButton}
/>
)}
</TableBody>
</Table>
{table.getRowModel().rows?.length > 0 &&
addRuleButton}
<PolicyAccessRulesTable
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch={false}
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>

View File

@@ -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<LocalRule[]>(
const [rules, setRules] = useState<PolicyAccessRule[]>(
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<LocalRule>) {
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
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<LocalRule>[] = useMemo(
() => [
{
accessorKey: "priority",
size: 96,
maxSize: 96,
header: ({ column }) => (
<div className="p-3">
<Button
variant="ghost"
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</div>
),
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-full min-w-0"
type="number"
disabled={readonly || row.original.fromPolicy}
onClick={(e) => 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: () => <span className="p-3">{t("rulesAction")}</span>,
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
disabled={readonly || row.original.fromPolicy}
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
updateRule(row.original.ruleId, {
action: value
})
}
>
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "match",
size: 144,
maxSize: 144,
header: () => (
<span className="p-3">{t("rulesMatchType")}</span>
),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
disabled={readonly || row.original.fromPolicy}
onValueChange={(
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY"
? "US"
: value === "ASN"
? "AS15169"
: value === "REGION"
? "021"
: row.original.value
})
}
>
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="COUNTRY">
{RuleMatch.COUNTRY}
</SelectItem>
)}
{isMaxmindAvailable && (
<SelectItem value="REGION">
{RuleMatch.REGION}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
</SelectItem>
)}
</SelectContent>
</Select>
)
},
{
accessorKey: "value",
header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) =>
row.original.match === "COUNTRY" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || row.original.fromPolicy
}
className="min-w-50 justify-between"
>
{row.original.value
? COUNTRIES.find(
(c) =>
c.code === row.original.value
)?.name +
" (" +
row.original.value +
")"
: t("selectCountry")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput
placeholder={t("searchCountries")}
/>
<CommandList>
<CommandEmpty>
{t("noCountryFound")}
</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() =>
updateRule(
row.original.ruleId,
{
value: country.code
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
/>
{country.name} (
{country.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "ASN" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || row.original.fromPolicy
}
className="min-w-50 justify-between"
>
{row.original.value
? (() => {
const found = MAJOR_ASNS.find(
(asn) =>
asn.code ===
row.original.value
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
})()
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput placeholder="Search ASNs or enter custom..." />
<CommandList>
<CommandEmpty>
No ASN found. Enter a custom ASN
below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map((asn) => (
<CommandItem
key={asn.code}
value={
asn.name +
" " +
asn.code
}
onSelect={() =>
updateRule(
row.original.ruleId,
{ value: asn.code }
)
}
>
<Check
className={`mr-2 h-4 w-4 ${row.original.value === asn.code ? "opacity-100" : "opacity-0"}`}
/>
{asn.name} ({asn.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
defaultValue={
!MAJOR_ASNS.find(
(asn) =>
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"
/>
</div>
</PopoverContent>
</Popover>
) : row.original.match === "REGION" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || row.original.fromPolicy
}
className="min-w-50 justify-between"
>
{(() => {
const regionName = getRegionNameById(
row.original.value
);
if (!regionName) {
return t("selectRegion");
}
return `${t(regionName)} (${row.original.value})`;
})()}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput
placeholder={t("searchRegions")}
/>
<CommandList>
<CommandEmpty>
{t("noRegionFound")}
</CommandEmpty>
{REGIONS.map((continent) => (
<CommandGroup
key={continent.id}
heading={t(continent.name)}
>
<CommandItem
value={continent.id}
keywords={[
t(continent.name),
continent.id
]}
onSelect={() =>
updateRule(
row.original.ruleId,
{
value: continent.id
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original
.value ===
continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(continent.name)} (
{continent.id})
</CommandItem>
{continent.includes.map(
(subregion) => (
<CommandItem
key={subregion.id}
value={subregion.id}
keywords={[
t(
subregion.name
),
subregion.id
]}
onSelect={() =>
updateRule(
row.original
.ruleId,
{
value: subregion.id
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original
.value ===
subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(subregion.name)}{" "}
({subregion.id})
</CommandItem>
)
)}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
className="min-w-50"
disabled={readonly || row.original.fromPolicy}
onBlur={(e) => {
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: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
disabled={readonly || row.original.fromPolicy}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, {
enabled: val
})
}
/>
</div>
)
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
{row.original.fromPolicy ? (
<Button
variant="outline"
disabled
className="cursor-not-allowed"
>
<LockIcon className="h-4 w-4" />
</Button>
) : (
<Button
variant="outline"
disabled={readonly}
onClick={() => removeRule(row.original.ruleId)}
>
{t("delete")}
</Button>
)}
</div>
)
}
],
[
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({
</Button>
);
const hasRules = rules.length > 0;
return (
<SettingsSection>
<SettingsSectionHeader>
@@ -952,117 +367,19 @@ function PolicyAccessRulesSectionEdit({
{rulesEnabled && (
<>
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{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 (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"
: isPriorityColumn
? "w-24 max-w-24"
: isActionColumn
? "w-40 max-w-40"
: isMatchColumn
? "w-36 max-w-36"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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 (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"
: isPriorityColumn
? "w-24 max-w-24"
: isActionColumn
? "w-40 max-w-40"
: isMatchColumn
? "w-36 max-w-36"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("rulesNoOne")}
action={addRuleButton}
/>
)}
</TableBody>
</Table>
{table.getRowModel().rows?.length > 0 &&
addRuleButton}
<PolicyAccessRulesTable
rules={rules}
onRulesChange={handleRulesChange}
updateRule={updateRule}
removeRule={removeRule}
readonly={readonly}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
includeRegionMatch
markUpdatedOnReorder
emptyStateAction={addRuleButton}
/>
{hasRules && addRuleButton}
</>
)}
</div>

View File

@@ -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<PolicyAccessRule>) => 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<number | null>(null);
const [dragOverRuleId, setDragOverRuleId] = useState<number | null>(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<PolicyAccessRule>[] = useMemo(
() => [
{
id: "dragHandle",
size: 32,
maxSize: 32,
header: () => null,
cell: ({ row }) =>
isRuleDraggable(row.original) ? (
<button
type="button"
draggable
tabIndex={-1}
aria-label={t("rulesReorderDragHandle")}
className="flex items-center justify-center text-muted-foreground cursor-grab active:cursor-grabbing"
onDragStart={(e) =>
handleDragStart(row.original.ruleId, e)
}
onDragEnd={handleDragEnd}
>
<GripVertical className="h-4 w-4" />
</button>
) : null
},
{
accessorKey: "priority",
size: 96,
maxSize: 96,
header: ({ column }) => (
<div className="p-3">
<Button
variant="ghost"
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("rulesPriority")}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
</div>
),
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-full min-w-0"
type="number"
disabled={readonly || isRuleLocked(row.original)}
onClick={(e) => 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: () => <span className="p-3">{t("rulesAction")}</span>,
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
disabled={readonly || isRuleLocked(row.original)}
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
updateRule(row.original.ruleId, {
action: value
})
}
>
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
</SelectItem>
</SelectContent>
</Select>
)
},
{
accessorKey: "match",
size: 144,
maxSize: 144,
header: () => (
<span className="p-3">{t("rulesMatchType")}</span>
),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
disabled={readonly || isRuleLocked(row.original)}
onValueChange={(
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY"
? "US"
: value === "ASN"
? "AS15169"
: value === "REGION"
? "021"
: row.original.value
})
}
>
<SelectTrigger className="h-8 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="COUNTRY">
{RuleMatch.COUNTRY}
</SelectItem>
)}
{includeRegionMatch && isMaxmindAvailable && (
<SelectItem value="REGION">
{RuleMatch.REGION}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{RuleMatch.ASN}
</SelectItem>
)}
</SelectContent>
</Select>
)
},
{
accessorKey: "value",
header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) =>
row.original.match === "COUNTRY" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || isRuleLocked(row.original)
}
className="w-full min-w-0 justify-between"
>
{row.original.value
? COUNTRIES.find(
(c) =>
c.code === row.original.value
)?.name +
" (" +
row.original.value +
")"
: t("selectCountry")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput
placeholder={t("searchCountries")}
/>
<CommandList>
<CommandEmpty>
{t("noCountryFound")}
</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() =>
updateRule(
row.original.ruleId,
{
value: country.code
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
/>
{country.name} (
{country.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "ASN" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || isRuleLocked(row.original)
}
className="w-full min-w-0 justify-between"
>
{row.original.value
? (() => {
const found = MAJOR_ASNS.find(
(asn) =>
asn.code ===
row.original.value
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
})()
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput placeholder="Search ASNs or enter custom..." />
<CommandList>
<CommandEmpty>
No ASN found. Enter a custom ASN
below.
</CommandEmpty>
<CommandGroup>
{MAJOR_ASNS.map((asn) => (
<CommandItem
key={asn.code}
value={
asn.name +
" " +
asn.code
}
onSelect={() =>
updateRule(
row.original.ruleId,
{ value: asn.code }
)
}
>
<Check
className={`mr-2 h-4 w-4 ${row.original.value === asn.code ? "opacity-100" : "opacity-0"}`}
/>
{asn.name} ({asn.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div className="border-t p-2">
<Input
placeholder="Enter custom ASN (e.g., AS15169)"
defaultValue={
!MAJOR_ASNS.find(
(asn) =>
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"
/>
</div>
</PopoverContent>
</Popover>
) : row.original.match === "REGION" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={
readonly || isRuleLocked(row.original)
}
className="w-full min-w-0 justify-between"
>
{(() => {
const regionName = getRegionNameById(
row.original.value
);
if (!regionName) {
return t("selectRegion");
}
return `${t(regionName)} (${row.original.value})`;
})()}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandInput
placeholder={t("searchRegions")}
/>
<CommandList>
<CommandEmpty>
{t("noRegionFound")}
</CommandEmpty>
{REGIONS.map((continent) => (
<CommandGroup
key={continent.id}
heading={t(continent.name)}
>
<CommandItem
value={continent.id}
keywords={[
t(continent.name),
continent.id
]}
onSelect={() =>
updateRule(
row.original.ruleId,
{
value: continent.id
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original
.value ===
continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(continent.name)} (
{continent.id})
</CommandItem>
{continent.includes.map(
(subregion) => (
<CommandItem
key={subregion.id}
value={subregion.id}
keywords={[
t(
subregion.name
),
subregion.id
]}
onSelect={() =>
updateRule(
row.original
.ruleId,
{
value: subregion.id
}
)
}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original
.value ===
subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(subregion.name)}{" "}
({subregion.id})
</CommandItem>
)
)}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
className="w-full min-w-0"
disabled={readonly || isRuleLocked(row.original)}
onBlur={(e) => {
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: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
disabled={readonly || isRuleLocked(row.original)}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, {
enabled: val
})
}
/>
</div>
)
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
{isRuleLocked(row.original) ? (
<Button
variant="outline"
disabled
className="cursor-not-allowed"
>
<LockIcon className="h-4 w-4" />
</Button>
) : (
<Button
variant="outline"
disabled={readonly}
onClick={() => removeRule(row.original.ruleId)}
>
{t("delete")}
</Button>
)}
</div>
)
}
],
[
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>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id;
return (
<TableHead
key={header.id}
className={getColumnClassName(columnId)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const rule = row.original;
return (
<TableRow
key={row.id}
onDragOver={(e) => {
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 (
<TableCell
key={cell.id}
className={getColumnClassName(
columnId
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
);
})
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("rulesNoOne")}
action={emptyStateAction}
/>
)}
</TableBody>
</Table>
);
}

View File

@@ -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,

View File

@@ -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<T extends { priority: number }>(
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;
});
}