From a53363d064a717d9e3ff2c80993efb2dca922bc1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Feb 2026 03:23:54 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20include=20rules=20in=20create=20?= =?UTF-8?q?policy=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreatePolicyForm.tsx | 1119 ++++++++++++++++++++++++++- 1 file changed, 1104 insertions(+), 15 deletions(-) diff --git a/src/components/CreatePolicyForm.tsx b/src/components/CreatePolicyForm.tsx index b1cb49a01..666c3b7ea 100644 --- a/src/components/CreatePolicyForm.tsx +++ b/src/components/CreatePolicyForm.tsx @@ -5,7 +5,6 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle @@ -14,6 +13,14 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; import { Form, FormControl, @@ -23,8 +30,13 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; 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, @@ -32,24 +44,76 @@ import { 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 { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; -import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Binary, + Bot, + Check, + ChevronsUpDown, + InfoIcon, + Key +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useActionState, useMemo, useState } from "react"; +import { useActionState, useCallback, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; +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; +}; + const createPolicySchema = z.object({ name: z.string().min(1).max(255), sso: z.boolean().default(true), @@ -72,7 +136,19 @@ const createPolicySchema = z.object({ id: z.string(), text: z.string() }) - ) + ), + applyRules: z.boolean().default(false), + rules: z + .array( + z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + ) + .default([]) }); export type CreatePolicyFormProps = {}; @@ -86,6 +162,11 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { const router = useRouter(); const { isPaidUser } = usePaidStatus(); + const isMaxmindAvailable = + env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0; + const isMaxmindAsnAvailable = + env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0; + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( orgQueries.roles({ orgId: org.org.orgId @@ -112,7 +193,9 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { emailWhitelistEnabled: false, roles: [], users: [], - emails: [] + emails: [], + applyRules: false, + rules: [] } }); @@ -129,10 +212,176 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { number | null >(null); + // Rules state + 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(() => { + return { + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + } as const; + }, [t]); + + const RuleMatch = useMemo(() => { + return { + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + } as const; + }, [t]); + async function onSubmit() { // ... } + 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 + ); + priority++; + } + + const newRule: LocalRule = { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + }; + + const updatedRules = [...rules, newRule]; + setRules(updatedRules); + form.setValue( + "rules", + updatedRules.map(({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + })) + ); + addRuleForm.reset(); + }, [rules, t, form, addRuleForm]); + + const removeRule = useCallback(function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + form.setValue( + "rules", + updatedRules.map(({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + })) + ); + }, [rules, form]); + + const updateRule = useCallback(function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId ? { ...rule, ...data, updated: true } : rule + ); + setRules(updatedRules); + form.setValue( + "rules", + updatedRules.map(({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + })) + ); + }, [rules, form]); + + 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 allRoles = useMemo(() => { return orgRoles .map((role) => ({ @@ -169,6 +418,348 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { return []; }, [orgIdps]); + 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 + } + } + }); + const pageLoading = isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps; @@ -603,17 +1194,515 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { )} - - - + + + {/* Rules */} + + + + {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")} + + + )} + +
+
+
+ +
+ +
);