"use client"; import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import z from "zod"; import { type PolicyFormValues } from "."; import { toast } from "@app/hooks/useToast"; import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { InfoPopup } from "@app/components/ui/info-popup"; 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 { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; // ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── const addRuleSchema = z.object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), match: z.string(), value: z.string(), priority: z.coerce.number().int().optional() }); type LocalRule = { ruleId: number; action: "ACCEPT" | "DROP" | "PASS"; match: string; value: string; priority: number; enabled: boolean; new?: boolean; updated?: boolean; }; export type CreatePolicyRulesSectionFormProps = { form: UseFormReturn; isMaxmindAvailable: boolean; isMaxmindAsnAvailable: boolean; }; export function CreatePolicyRulesSectionForm({ form, isMaxmindAvailable, isMaxmindAsnAvailable }: CreatePolicyRulesSectionFormProps) { const t = useTranslations(); const [isOpen, setIsOpen] = useState(false); const [rules, setRules] = useState([]); const [rulesEnabled, setRulesEnabled] = useState(false); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT" as const, match: "IP", value: "" } }); const RuleAction = useMemo( () => ({ ACCEPT: t("alwaysAllow"), DROP: t("alwaysDeny"), PASS: t("passToAuth") }), [t] ); const RuleMatch = useMemo( () => ({ PATH: t("path"), IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), ASN: "ASN" }), [t] ); const syncFormRules = useCallback( (updatedRules: LocalRule[]) => { form.setValue( "rules", updatedRules.map( ({ action, match, value, priority, enabled }) => ({ action, match, value, priority, enabled }) ) ); }, [form] ); const addRule = useCallback( function addRule(data: z.infer) { const isDuplicate = rules.some( (rule) => rule.action === data.action && rule.match === data.match && rule.value === data.value ); if (isDuplicate) { toast({ variant: "destructive", title: t("rulesErrorDuplicate"), description: t("rulesErrorDuplicateDescription") }); return; } if (data.match === "CIDR" && !isValidCIDR(data.value)) { toast({ variant: "destructive", title: t("rulesErrorInvalidIpAddressRange"), description: t("rulesErrorInvalidIpAddressRangeDescription") }); return; } if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { toast({ variant: "destructive", title: t("rulesErrorInvalidUrl"), description: t("rulesErrorInvalidUrlDescription") }); return; } if (data.match === "IP" && !isValidIP(data.value)) { toast({ variant: "destructive", title: t("rulesErrorInvalidIpAddress"), description: t("rulesErrorInvalidIpAddressDescription") }); return; } if ( data.match === "COUNTRY" && !COUNTRIES.some((c) => c.code === data.value) ) { toast({ variant: "destructive", title: t("rulesErrorInvalidCountry"), description: t("rulesErrorInvalidCountryDescription") || "" }); return; } let priority = data.priority; if (priority === undefined) { priority = rules.reduce( (acc, rule) => rule.priority > acc ? rule.priority : acc, 0 ) + 1; } const updatedRules = [ ...rules, { ...data, ruleId: new Date().getTime(), new: true, priority, enabled: true } ]; setRules(updatedRules); syncFormRules(updatedRules); addRuleForm.reset(); }, [rules, t, addRuleForm, syncFormRules] ); const removeRule = useCallback( function removeRule(ruleId: number) { const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); setRules(updatedRules); syncFormRules(updatedRules); }, [rules, syncFormRules] ); const updateRule = useCallback( function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } : rule ); setRules(updatedRules); syncFormRules(updatedRules); }, [rules, syncFormRules] ); const getValueHelpText = useCallback( function getValueHelpText(type: string) { switch (type) { case "CIDR": return t("rulesMatchIpAddressRangeDescription"); case "IP": return t("rulesMatchIpAddress"); case "PATH": return t("rulesMatchUrl"); case "COUNTRY": return t("rulesMatchCountry"); case "ASN": return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; } }, [t] ); const columns: ColumnDef[] = useMemo( () => [ { accessorKey: "priority", header: ({ column }) => ( ), cell: ({ row }) => ( e.currentTarget.focus()} onBlur={(e) => { const parsed = z.coerce .number() .int() .optional() .safeParse(e.target.value); if (!parsed.success) { toast({ variant: "destructive", title: t("rulesErrorInvalidPriority"), description: t( "rulesErrorInvalidPriorityDescription" ) }); return; } updateRule(row.original.ruleId, { priority: parsed.data }); }} /> ) }, { accessorKey: "action", header: () => {t("rulesAction")}, cell: ({ row }) => ( ) }, { accessorKey: "match", header: () => ( {t("rulesMatchType")} ), cell: ({ row }) => ( ) }, { accessorKey: "value", header: () => {t("value")}, cell: ({ row }) => row.original.match === "COUNTRY" ? ( {t("noCountryFound")} {COUNTRIES.map((country) => ( updateRule( row.original.ruleId, { value: country.code } ) } > {country.name} ( {country.code}) ))} ) : row.original.match === "ASN" ? ( No ASN found. Enter a custom ASN below. {MAJOR_ASNS.map((asn) => ( updateRule( row.original.ruleId, { value: asn.code } ) } > {asn.name} ({asn.code}) ))}
asn.code === row.original.value ) ? row.original.value : "" } onKeyDown={(e) => { if (e.key === "Enter") { const value = e.currentTarget.value .toUpperCase() .replace(/^AS/, ""); if (/^\d+$/.test(value)) { updateRule( row.original.ruleId, { value: "AS" + value } ); } } }} className="text-sm" />
) : ( updateRule(row.original.ruleId, { value: e.target.value }) } /> ) }, { accessorKey: "enabled", header: () => {t("enabled")}, cell: ({ row }) => ( updateRule(row.original.ruleId, { enabled: val }) } /> ) }, { id: "actions", header: () => {t("actions")}, cell: ({ row }) => (
) } ], [ t, RuleAction, RuleMatch, isMaxmindAvailable, isMaxmindAsnAvailable, updateRule, removeRule ] ); const table = useReactTable({ data: rules, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); if (!isOpen) { return ( {t("rulesResource")} {t("rulesResourcePolicyDescription")} ); } return ( {t("rulesResource")} {t("rulesResourceDescription")}
{ setRulesEnabled(val); form.setValue("applyRules", val); }} />
( {t("rulesAction")} )} /> ( {t("rulesMatchType")} )} /> ( {addRuleForm.watch("match") === "COUNTRY" ? ( {t( "noCountryFound" )} {COUNTRIES.map( ( country ) => ( { field.onChange( country.code ); setOpenAddRuleCountrySelect( false ); }} > { country.name }{" "} ( { country.code } ) ) )} ) : addRuleForm.watch( "match" ) === "ASN" ? ( No ASN found. Use the custom input below. {MAJOR_ASNS.map( ( asn ) => ( { field.onChange( asn.code ); setOpenAddRuleAsnSelect( false ); }} > { asn.name }{" "} ( { asn.code } ) ) )}
{ if ( e.key === "Enter" ) { const value = e.currentTarget.value .toUpperCase() .replace( /^AS/, "" ); if ( /^\d+$/.test( value ) ) { field.onChange( "AS" + value ); setOpenAddRuleAsnSelect( false ); } } }} className="text-sm" />
) : ( )}
)} />
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const isActionsColumn = header.column.id === "actions"; return ( {header.isPlaceholder ? null : flexRender( header.column .columnDef.header, header.getContext() )} ); })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => { const isActionsColumn = cell.column.id === "actions"; return ( {flexRender( cell.column.columnDef .cell, cell.getContext() )} ); })} )) ) : ( {t("rulesNoOne")} )}
); }