mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-08 21:48:02 +02:00
make the rule rows draggable
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user