"use client"; import { useEffect, useState, use } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { AxiosResponse } from "axios"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { ColumnDef, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, getCoreRowModel, useReactTable, flexRender } from "@tanstack/react-table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ArrayElement } from "@server/types/ArrayElement"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { SettingsContainer, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, SettingsSectionFooter } from "@app/components/Settings"; import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react"; import { InfoSection, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import { InfoPopup } from "@app/components/ui/info-popup"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { Switch } from "@app/components/ui/switch"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; // Schema for rule validation const addRuleSchema = z.object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), match: z.string(), value: z.string(), priority: z.coerce.number().int().optional() }); type LocalRule = ArrayElement & { new?: boolean; updated?: boolean; }; export default function ResourceRules(props: { params: Promise<{ resourceId: number }>; }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const api = createApiClient(useEnvContext()); const [rules, setRules] = useState([]); const [rulesToRemove, setRulesToRemove] = useState([]); const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); const router = useRouter(); const t = useTranslations(); const RuleAction = { ACCEPT: t('alwaysAllow'), DROP: t('alwaysDeny'), PASS: t('passToAuth') } as const; const RuleMatch = { PATH: t('path'), IP: "IP", CIDR: t('ipAddressRange') } as const; const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT", match: "IP", value: "" } }); useEffect(() => { const fetchRules = async () => { try { const res = await api.get< AxiosResponse >(`/resource/${resource.resourceId}/rules`); if (res.status === 200) { setRules(res.data.data.rules); } } catch (err) { console.error(err); toast({ variant: "destructive", title: t('rulesErrorFetch'), description: formatAxiosError( err, t('rulesErrorFetchDescription') ) }); } finally { setPageLoading(false); } }; fetchRules(); }, []); async 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') }); setLoading(false); return; } if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { toast({ variant: "destructive", title: t('rulesErrorInvalidUrl'), description: t('rulesErrorInvalidUrlDescription') }); setLoading(false); return; } if (data.match === "IP" && !isValidIP(data.value)) { toast({ variant: "destructive", title: t('rulesErrorInvalidIpAddress'), description: t('rulesErrorInvalidIpAddressDescription') }); setLoading(false); return; } // find the highest priority and add one 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, resourceId: resource.resourceId, priority, enabled: true }; setRules([...rules, newRule]); addRuleForm.reset(); } const removeRule = (ruleId: number) => { setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]); if (!rules.find((rule) => rule.ruleId === ruleId)?.new) { setRulesToRemove([...rulesToRemove, ruleId]); } }; async function updateRule(ruleId: number, data: Partial) { setRules( rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } : rule ) ); } function getValueHelpText(type: string) { switch (type) { case "CIDR": return t('rulesMatchIpAddressRangeDescription'); case "IP": return t('rulesMatchIpAddress'); case "PATH": return t('rulesMatchUrl'); } } async function saveAllSettings() { try { setLoading(true); // Save rules enabled state const res = await api .post(`/resource/${resource.resourceId}`, { applyRules: rulesEnabled }) .catch((err) => { console.error(err); toast({ variant: "destructive", title: t('rulesErrorUpdate'), description: formatAxiosError( err, t('rulesErrorUpdateDescription') ) }); throw err; }); if (res && res.status === 200) { updateResource({ applyRules: rulesEnabled }); } // Save rules for (const rule of rules) { const data = { action: rule.action, match: rule.match, value: rule.value, priority: rule.priority, enabled: rule.enabled }; if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { toast({ variant: "destructive", title: t('rulesErrorInvalidIpAddressRange'), description: t('rulesErrorInvalidIpAddressRangeDescription') }); setLoading(false); return; } if ( rule.match === "PATH" && !isValidUrlGlobPattern(rule.value) ) { toast({ variant: "destructive", title: t('rulesErrorInvalidUrl'), description: t('rulesErrorInvalidUrlDescription') }); setLoading(false); return; } if (rule.match === "IP" && !isValidIP(rule.value)) { toast({ variant: "destructive", title: t('rulesErrorInvalidIpAddress'), description: t('rulesErrorInvalidIpAddressDescription') }); setLoading(false); return; } if (rule.priority === undefined) { toast({ variant: "destructive", title: t('rulesErrorInvalidPriority'), description: t('rulesErrorInvalidPriorityDescription') }); setLoading(false); return; } // make sure no duplicate priorities const priorities = rules.map((r) => r.priority); if (priorities.length !== new Set(priorities).size) { toast({ variant: "destructive", title: t('rulesErrorDuplicatePriority'), description: t('rulesErrorDuplicatePriorityDescription') }); setLoading(false); return; } if (rule.new) { const res = await api.put( `/resource/${resource.resourceId}/rule`, data ); rule.ruleId = res.data.data.ruleId; } else if (rule.updated) { await api.post( `/resource/${resource.resourceId}/rule/${rule.ruleId}`, data ); } setRules([ ...rules.map((r) => { const res = { ...r, new: false, updated: false }; return res; }) ]); } for (const ruleId of rulesToRemove) { await api.delete( `/resource/${resource.resourceId}/rule/${ruleId}` ); setRules(rules.filter((r) => r.ruleId !== ruleId)); } toast({ title: t('ruleUpdated'), description: t('ruleUpdatedDescription') }); setRulesToRemove([]); router.refresh(); } catch (err) { console.error(err); toast({ variant: "destructive", title: t('ruleErrorUpdate'), description: formatAxiosError( err, t('ruleErrorUpdateDescription') ) }); } setLoading(false); } const columns: ColumnDef[] = [ { accessorKey: "priority", header: ({ column }) => { return ( ); }, cell: ({ row }) => ( { const parsed = z.coerce .number() .int() .optional() .safeParse(e.target.value); if (!parsed.data) { toast({ variant: "destructive", title: t('rulesErrorInvalidIpAddress'), // correct priority or IP? description: t('rulesErrorInvalidPriorityDescription') }); setLoading(false); 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 }) => ( updateRule(row.original.ruleId, { value: e.target.value }) } /> ) }, { accessorKey: "enabled", header: t('enabled'), cell: ({ row }) => ( updateRule(row.original.ruleId, { enabled: val }) } /> ) }, { id: "actions", cell: ({ row }) => (
) } ]; const table = useReactTable({ data: rules, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { pagination: { pageIndex: 0, pageSize: 1000 } } }); if (pageLoading) { return <>; } return ( {/* */} {/* */} {/* {t('rulesAbout')} */} {/* */} {/*
*/} {/*

*/} {/* {t('rulesAboutDescription')} */} {/*

*/} {/*
*/} {/* */} {/* */} {/* {t('rulesActions')} */} {/*
    */} {/*
  • */} {/* */} {/* {t('rulesActionAlwaysAllow')} */} {/*
  • */} {/*
  • */} {/* */} {/* {t('rulesActionAlwaysDeny')} */} {/*
  • */} {/*
*/} {/*
*/} {/* */} {/* */} {/* {t('rulesMatchCriteria')} */} {/* */} {/*
    */} {/*
  • */} {/* {t('rulesMatchCriteriaIpAddress')} */} {/*
  • */} {/*
  • */} {/* {t('rulesMatchCriteriaIpAddressRange')} */} {/*
  • */} {/*
  • */} {/* {t('rulesMatchCriteriaUrl')} */} {/*
  • */} {/*
*/} {/*
*/} {/*
*/} {/*
*/} {/*
*/} {t('rulesResource')} {t('rulesResourceDescription')}
setRulesEnabled(val)} />
( {t('rulesAction')} )} /> ( {t('rulesMatchType')} )} /> ( )} />
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef .header, header.getContext() )} ))} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ))} )) ) : ( {t('rulesNoOne')} )} {/* */} {/* {t('rulesOrder')} */} {/* */}
); }