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
+1
View File
@@ -784,6 +784,7 @@
"ruleErrorUpdate": "Operation failed", "ruleErrorUpdate": "Operation failed",
"ruleErrorUpdateDescription": "An error occurred during the save operation", "ruleErrorUpdateDescription": "An error occurred during the save operation",
"rulesPriority": "Priority", "rulesPriority": "Priority",
"rulesReorderDragHandle": "Drag to reorder rule priority",
"rulesAction": "Action", "rulesAction": "Action",
"rulesMatchType": "Match Type", "rulesMatchType": "Match Type",
"value": "Value", "value": "Value",
@@ -12,76 +12,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; 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 { Button } from "@app/components/ui/button";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; import { Plus } from "lucide-react";
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 { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
import { createEmptyRule } from "./policy-access-rule-utils"; import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
import {
// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── createEmptyRule,
type PolicyAccessRule
type LocalRule = { } from "./policy-access-rule-utils";
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
match: string;
value: string;
priority: number;
enabled: boolean;
new?: boolean;
updated?: boolean;
};
export type CreatePolicyRulesSectionFormProps = { export type CreatePolicyRulesSectionFormProps = {
form: UseFormReturn<PolicyFormValues, any, any>; form: UseFormReturn<PolicyFormValues, any, any>;
@@ -95,7 +37,7 @@ export function CreatePolicyRulesSectionForm({
isMaxmindAsnAvailable isMaxmindAsnAvailable
}: CreatePolicyRulesSectionFormProps) { }: CreatePolicyRulesSectionFormProps) {
const t = useTranslations(); const t = useTranslations();
const [rules, setRules] = useState<LocalRule[]>([]); const [rules, setRules] = useState<PolicyAccessRule[]>([]);
const rulesFormSchema = useMemo( const rulesFormSchema = useMemo(
() => createPolicyRulesSectionSchema(t), () => createPolicyRulesSectionSchema(t),
@@ -123,28 +65,8 @@ export function CreatePolicyRulesSectionForm({
name: "applyRules" 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( const syncFormRules = useCallback(
(updatedRules: LocalRule[]) => { (updatedRules: PolicyAccessRule[]) => {
form.setValue( form.setValue(
"rules", "rules",
updatedRules.map( updatedRules.map(
@@ -177,7 +99,7 @@ export function CreatePolicyRulesSectionForm({
); );
const updateRule = useCallback( const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<LocalRule>) { function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
const updatedRules = rules.map((rule) => const updatedRules = rules.map((rule) =>
rule.ruleId === ruleId rule.ruleId === ruleId
? { ...rule, ...data, updated: true } ? { ...rule, ...data, updated: true }
@@ -189,369 +111,14 @@ export function CreatePolicyRulesSectionForm({
[rules, syncFormRules] [rules, syncFormRules]
); );
const columns: ColumnDef<LocalRule>[] = useMemo( const handleRulesChange = useCallback(
() => [ (updatedRules: PolicyAccessRule[]) => {
{ setRules(updatedRules);
accessorKey: "priority", syncFormRules(updatedRules);
size: 96, },
maxSize: 96, [syncFormRules]
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 table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { pagination: { pageIndex: 0, pageSize: 1000 } }
});
const addRuleButton = ( const addRuleButton = (
<Button type="button" variant="outline" onClick={addEmptyRule}> <Button type="button" variant="outline" onClick={addEmptyRule}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
@@ -559,6 +126,8 @@ export function CreatePolicyRulesSectionForm({
</Button> </Button>
); );
const hasRules = rules.length > 0;
return ( return (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -580,117 +149,17 @@ export function CreatePolicyRulesSectionForm({
{rulesEnabled && ( {rulesEnabled && (
<> <>
<Table> <PolicyAccessRulesTable
<TableHeader> rules={rules}
{table onRulesChange={handleRulesChange}
.getHeaderGroups() updateRule={updateRule}
.map((headerGroup) => ( removeRule={removeRule}
<TableRow key={headerGroup.id}> isMaxmindAvailable={isMaxmindAvailable}
{headerGroup.headers.map( isMaxmindAsnAvailable={isMaxmindAsnAvailable}
(header) => { includeRegionMatch={false}
const columnId = emptyStateAction={addRuleButton}
header.column.id; />
const isActionsColumn = {hasRules && addRuleButton}
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}
</> </>
)} )}
</div> </div>
@@ -15,64 +15,12 @@ import { useTranslations } from "next-intl";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import {
createPolicyRulesSectionSchema, createPolicyRulesSectionSchema,
validatePolicyRulePriority,
validatePolicyRuleValue,
validatePolicyRulesForSave, validatePolicyRulesForSave,
type PolicyFormValues type PolicyFormValues
} from "."; } from ".";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; import { Plus } from "lucide-react";
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 { import {
useCallback, useCallback,
@@ -92,22 +40,14 @@ import type { AxiosResponse } from "axios";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; 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 ─────────────────────────────────────────────────────── // ─── 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 = { type PolicyAccessRulesSectionEditProps = {
mode: "edit"; mode: "edit";
isMaxmindAvailable: boolean; isMaxmindAvailable: boolean;
@@ -148,7 +88,6 @@ function PolicyAccessRulesSectionEdit({
const isResourceOverlay = resourceId !== undefined; const isResourceOverlay = resourceId !== undefined;
// ── Fetch resource-specific rules when in overlay mode ───────────────────
const { data: resourceRulesData } = useQuery({ const { data: resourceRulesData } = useQuery({
...resourceQueries.resourceRules({ resourceId: resourceId! }), ...resourceQueries.resourceRules({ resourceId: resourceId! }),
enabled: isResourceOverlay enabled: isResourceOverlay
@@ -176,17 +115,16 @@ function PolicyAccessRulesSectionEdit({
name: "applyRules" name: "applyRules"
}); });
const [rules, setRules] = useState<LocalRule[]>( const [rules, setRules] = useState<PolicyAccessRule[]>(
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
); );
// Initialize resource-specific rules once fetched
useEffect(() => { useEffect(() => {
if (!isResourceOverlay || resourceRulesInitialized) return; if (!isResourceOverlay || resourceRulesInitialized) return;
if (!resourceRulesData) return; if (!resourceRulesData) return;
const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId));
const resourceSpecific: LocalRule[] = resourceRulesData const resourceSpecific: PolicyAccessRule[] = resourceRulesData
.filter((r) => !policyRuleIds.has(r.ruleId)) .filter((r) => !policyRuleIds.has(r.ruleId))
.map((r) => ({ .map((r) => ({
ruleId: r.ruleId, ruleId: r.ruleId,
@@ -210,29 +148,8 @@ function PolicyAccessRulesSectionEdit({
policy.rules 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( const syncFormRules = useCallback(
(updatedRules: LocalRule[]) => { (updatedRules: PolicyAccessRule[]) => {
form.setValue( form.setValue(
"rules", "rules",
updatedRules.map( updatedRules.map(
@@ -258,8 +175,7 @@ function PolicyAccessRulesSectionEdit({
const removeRule = useCallback( const removeRule = useCallback(
function removeRule(ruleId: number) { function removeRule(ruleId: number) {
const rule = rules.find((r) => r.ruleId === ruleId); const rule = rules.find((r) => r.ruleId === ruleId);
if (!rule || rule.fromPolicy) return; // cannot remove policy rules if (!rule || rule.fromPolicy) return;
// Track deletion for resource overlay mode (only for existing DB rules)
if (isResourceOverlay && !rule.new) { if (isResourceOverlay && !rule.new) {
deletedResourceRuleIdsRef.current.add(ruleId); deletedResourceRuleIdsRef.current.add(ruleId);
} }
@@ -271,7 +187,7 @@ function PolicyAccessRulesSectionEdit({
); );
const updateRule = useCallback( const updateRule = useCallback(
function updateRule(ruleId: number, data: Partial<LocalRule>) { function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
const updatedRules = rules.map((rule) => const updatedRules = rules.map((rule) =>
rule.ruleId === ruleId rule.ruleId === ruleId
? { ...rule, ...data, updated: true } ? { ...rule, ...data, updated: true }
@@ -283,517 +199,14 @@ function PolicyAccessRulesSectionEdit({
[rules, syncFormRules] [rules, syncFormRules]
); );
const sortedRules = useMemo( const handleRulesChange = useCallback(
() => [...rules].sort((a, b) => a.priority - b.priority), (updatedRules: PolicyAccessRule[]) => {
[rules] 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(); const [isPending, startTransition] = useTransition();
async function saveRules() { async function saveRules() {
@@ -930,6 +343,8 @@ function PolicyAccessRulesSectionEdit({
</Button> </Button>
); );
const hasRules = rules.length > 0;
return ( return (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -952,117 +367,19 @@ function PolicyAccessRulesSectionEdit({
{rulesEnabled && ( {rulesEnabled && (
<> <>
<Table> <PolicyAccessRulesTable
<TableHeader> rules={rules}
{table onRulesChange={handleRulesChange}
.getHeaderGroups() updateRule={updateRule}
.map((headerGroup) => ( removeRule={removeRule}
<TableRow key={headerGroup.id}> readonly={readonly}
{headerGroup.headers.map( isMaxmindAvailable={isMaxmindAvailable}
(header) => { isMaxmindAsnAvailable={isMaxmindAsnAvailable}
const columnId = includeRegionMatch
header.column.id; markUpdatedOnReorder
const isActionsColumn = emptyStateAction={addRuleButton}
columnId === />
"actions"; {hasRules && addRuleButton}
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}
</> </>
)} )}
</div> </div>
@@ -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>
);
}
+9
View File
@@ -57,6 +57,15 @@ export type LocalRule = {
updated?: boolean; 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 { export {
createPolicyRulePrioritySchema, createPolicyRulePrioritySchema,
createPolicyRuleSchema, createPolicyRuleSchema,
@@ -1,10 +1,16 @@
export type EmptyRuleDraft = { export type PolicyAccessRule = {
ruleId: number; ruleId: number;
action: "ACCEPT" | "DROP" | "PASS"; action: "ACCEPT" | "DROP" | "PASS";
match: string; match: string;
value: string; value: string;
priority: number; priority: number;
enabled: boolean; enabled: boolean;
new?: boolean;
updated?: boolean;
fromPolicy?: boolean;
};
export type EmptyRuleDraft = PolicyAccessRule & {
new: true; new: true;
}; };
@@ -27,3 +33,40 @@ export function createEmptyRule(
new: true 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;
});
}