"use client"; import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; 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, TableCell, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { ArrowUpDown, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; import { ConfirmationDialog } from "@app/components/ConfirmationDialog"; const addRuleSchema = z.object({ action: z.enum(["ACCEPT", "DROP"]), match: z.enum(["CIDR", "IP", "PATH"]), value: z.string().min(1), priority: z.coerce.number().int().optional() }); type TemplateRule = { ruleId: number; templateId: string; enabled: boolean; priority: number; action: string; match: string; value: string; }; type TemplateRulesManagerProps = { templateId: string; orgId: string; }; export function TemplateRulesManager({ templateId, orgId }: TemplateRulesManagerProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); const [addingRule, setAddingRule] = useState(false); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [ruleToDelete, setRuleToDelete] = useState(null); const [deletingRule, setDeletingRule] = useState(false); const RuleAction = { ACCEPT: t('alwaysAllow'), DROP: t('alwaysDeny') } as const; const RuleMatch = { PATH: t('path'), IP: "IP", CIDR: t('ipAddressRange') } as const; const form = useForm>({ resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT", match: "IP", value: "", priority: undefined } }); const fetchRules = async () => { try { const response = await api.get(`/org/${orgId}/rule-templates/${templateId}/rules`); setRules(response.data.data.rules); } catch (error) { console.error("Failed to fetch template rules:", error); toast({ variant: "destructive", title: "Error", description: formatAxiosError(error, "Failed to fetch template rules") }); } finally { setLoading(false); } }; useEffect(() => { fetchRules(); }, [templateId, orgId]); const addRule = async (data: z.infer) => { try { setAddingRule(true); // Validate the value based on match type if (data.match === "CIDR" && !isValidCIDR(data.value)) { toast({ variant: "destructive", title: "Invalid CIDR format", description: "Please enter a valid CIDR notation (e.g., 192.168.1.0/24)" }); return; } if (data.match === "IP" && !isValidIP(data.value)) { toast({ variant: "destructive", title: "Invalid IP address", description: "Please enter a valid IP address" }); return; } if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { toast({ variant: "destructive", title: "Invalid URL pattern", description: "Please enter a valid URL pattern" }); return; } const response = await api.post(`/org/${orgId}/rule-templates/${templateId}/rules`, data); toast({ title: "Template Rule Added", description: "A new rule has been added to the template. It will be available for assignment to resources.", variant: "default" }); form.reset(); fetchRules(); } catch (error) { toast({ variant: "destructive", title: "Add Rule Failed", description: formatAxiosError(error, "Failed to add rule. Please check your input and try again.") }); } finally { setAddingRule(false); } }; const removeRule = async (ruleId: number) => { setRuleToDelete(ruleId); setDeleteDialogOpen(true); }; const confirmDeleteRule = async () => { if (!ruleToDelete) return; setDeletingRule(true); try { await api.delete(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleToDelete}`); toast({ title: "Template Rule Removed", description: "The rule has been removed from the template and from all assigned resources.", variant: "default" }); fetchRules(); } catch (error) { toast({ variant: "destructive", title: "Removal Failed", description: formatAxiosError(error, "Failed to remove template rule") }); } finally { setDeletingRule(false); setRuleToDelete(null); } }; const updateRule = async (ruleId: number, data: Partial) => { try { const response = await api.put(`/org/${orgId}/rule-templates/${templateId}/rules/${ruleId}`, data); // Show success notification with propagation info if available const message = response.data?.message || "The template rule has been updated and changes have been propagated to all assigned resources."; toast({ title: "Template Rule Updated", description: message, variant: "default" }); fetchRules(); } catch (error) { console.error("Failed to update rule:", error); toast({ title: "Update Failed", description: formatAxiosError(error, "Failed to update template rule. Please try again."), variant: "destructive" }); } }; 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'), 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 }) => ( 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 }, onPaginationChange: setPagination, manualPagination: false }); if (loading) { return
Loading rules...
; } return (
( {t('rulesAction')} )} /> ( {t('rulesMatchType')} )} /> ( {t('value')} )} /> ( {t('rulesPriority')} (optional) )} />
{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() )} ))} )) ) : ( No rules found. Add your first rule above. )}
{/* Pagination Controls */} {rules.length > 0 && (
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "} {Math.min( (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, table.getFilteredRowModel().rows.length )}{" "} of {table.getFilteredRowModel().rows.length} rules

Rows per page

Page {table.getState().pagination.pageIndex + 1} of{" "} {table.getPageCount()}
)} {/* Confirmation Dialog */}
); }