From 60617f99f24bdd5c5346e44d574e793b538878da Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:08:30 +0100 Subject: [PATCH] feat: rewrite WAF page with shadcn (sonner replaces Snackbar) Co-Authored-By: Claude Sonnet 4.6 --- app/(dashboard)/waf/WafEventsClient.tsx | 798 ++++++++++++------------ 1 file changed, 387 insertions(+), 411 deletions(-) diff --git a/app/(dashboard)/waf/WafEventsClient.tsx b/app/(dashboard)/waf/WafEventsClient.tsx index fc4e3fd1..0e2f35e0 100644 --- a/app/(dashboard)/waf/WafEventsClient.tsx +++ b/app/(dashboard)/waf/WafEventsClient.tsx @@ -3,37 +3,27 @@ import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { useFormState } from "react-dom"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { - Alert, - Box, - Button, - Card, - Checkbox, - Chip, - Collapse, - Divider, - Drawer, - FormControlLabel, - IconButton, - Snackbar, - Stack, - Switch, - Tab, - Tabs, - TextField, - Tooltip, - Typography, -} from "@mui/material"; +import { toast } from "sonner"; +import { Search, X, ShieldOff, Trash2, Copy, ChevronDown } from "lucide-react"; -import SearchIcon from "@mui/icons-material/Search"; -import CloseIcon from "@mui/icons-material/Close"; -import BlockIcon from "@mui/icons-material/Block"; -import DeleteIcon from "@mui/icons-material/Delete"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { DataTable } from "@/src/components/ui/DataTable"; -import type { WafEvent } from "@/src/lib/models/waf-events"; -import type { WafSettings } from "@/src/lib/settings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +import { DataTable } from "@/components/ui/DataTable"; +import type { WafEvent } from "@/lib/models/waf-events"; +import type { WafSettings } from "@/lib/settings"; import { suppressWafRuleGloballyAction, suppressWafRuleForHostAction, @@ -53,36 +43,42 @@ type Props = { globalWaf: WafSettings | null; }; -const SEVERITY_COLOR: Record = { - CRITICAL: "error", - ERROR: "error", - HIGH: "error", - WARNING: "warning", - NOTICE: "info", - INFO: "info", +const SEVERITY_CLASSES: Record = { + CRITICAL: "border-red-500 text-red-500", + ERROR: "border-red-500 text-red-500", + HIGH: "border-red-500 text-red-500", + WARNING: "border-yellow-500 text-yellow-500", + NOTICE: "border-blue-500 text-blue-500", + INFO: "border-blue-500 text-blue-500", }; function SeverityChip({ severity }: { severity: string | null }) { - if (!severity) return ; + if (!severity) return ; const upper = severity.toUpperCase(); - const color = SEVERITY_COLOR[upper] ?? "default"; - return ; + const classes = SEVERITY_CLASSES[upper] ?? "border-muted text-muted-foreground"; + return ( + + {upper} + + ); } function BlockedChip({ blocked }: { blocked: boolean }) { - return blocked - ? - : ; + return blocked ? ( + Blocked + ) : ( + + Detected + + ); } function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return ( - - - {label} - - {children} - +
+

{label}

+
{children}
+
); } @@ -102,7 +98,6 @@ function WafEventDrawer({ onSuppressHost: (ruleId: number, host: string) => void; }) { const [pending, startTransition] = useTransition(); - const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; success: boolean }>({ open: false, message: "", success: true }); let parsedRaw: unknown = null; if (event?.rawData) { @@ -117,8 +112,12 @@ function WafEventDrawer({ if (!event?.ruleId) return; startTransition(async () => { const result = await suppressWafRuleGloballyAction(event.ruleId!); - setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); - if (result.success) onSuppressGlobal(event.ruleId!); + if (result.success) { + toast.success(result.message ?? "Done"); + onSuppressGlobal(event.ruleId!); + } else { + toast.error(result.message ?? "Failed"); + } }); } @@ -126,118 +125,108 @@ function WafEventDrawer({ if (!event?.ruleId || !event?.host) return; startTransition(async () => { const result = await suppressWafRuleForHostAction(event.ruleId!, event.host!); - setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); - if (result.success) onSuppressHost(event.ruleId!, event.host!); + if (result.success) { + toast.success(result.message ?? "Done"); + onSuppressHost(event.ruleId!, event.host!); + } else { + toast.error(result.message ?? "Failed"); + } }); } return ( - <> - + { if (!open) onClose(); }}> + + WAF Event Details {event && ( - - - +
+
+
- WAF Event - - - +

WAF Event

+
+ +
- + - {new Date(event.ts * 1000).toLocaleString()} +

{new Date(event.ts * 1000).toLocaleString()}

- {event.host || "—"} +

{event.host || "—"}

- - {event.clientIp} - {event.countryCode && } - +
+

{event.clientIp}

+ {event.countryCode && ( + + {event.countryCode} + + )} +
- - {event.method} {event.uri} - +

{event.method} {event.uri}

- - {event.ruleId ?? "—"} +
+

{event.ruleId ?? "—"}

{event.ruleId != null && ( <> {event.host && ( )} )} - +
- {event.ruleMessage ?? "—"} +

{event.ruleMessage ?? "—"}

- + {parsedRaw !== null ? ( - +
                   {JSON.stringify(parsedRaw, null, 2)}
-                
+                
) : ( - + )}
- +
)} -
- setSnackbar((s) => ({ ...s, open: false }))} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - > - setSnackbar((s) => ({ ...s, open: false }))}> - {snackbar.message} - - - + + ); } @@ -255,7 +244,6 @@ function GlobalSuppressedRules({ onAdd: (ruleId: number, message: string | null) => void; }) { const [pending, startTransition] = useTransition(); - const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; success: boolean }>({ open: false, message: "", success: true }); const [messages, setMessages] = useState(initialMessages); // Add-rule state @@ -269,8 +257,12 @@ function GlobalSuppressedRules({ function handleRemove(ruleId: number) { startTransition(async () => { const result = await removeWafRuleGloballyAction(ruleId); - setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); - if (result.success) onRemove(ruleId); + if (result.success) { + toast.success(result.message ?? "Done"); + onRemove(ruleId); + } else { + toast.error(result.message ?? "Failed"); + } }); } @@ -278,7 +270,7 @@ function GlobalSuppressedRules({ const n = parseInt(addInput.trim(), 10); if (!Number.isInteger(n) || n <= 0) return; if (excluded.includes(n)) { - setSnackbar({ open: true, message: `Rule ${n} is already suppressed.`, success: false }); + toast.error(`Rule ${n} is already suppressed.`); return; } setLookupPending(true); @@ -294,12 +286,14 @@ function GlobalSuppressedRules({ if (!pendingRule) return; startTransition(async () => { const result = await suppressWafRuleGloballyAction(pendingRule.id); - setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); if (result.success) { + toast.success(result.message ?? "Done"); onAdd(pendingRule.id, pendingRule.message); setMessages((prev) => ({ ...prev, [pendingRule.id]: pendingRule.message })); setAddInput(""); setPendingRule(null); + } else { + toast.error(result.message ?? "Failed"); } }); } @@ -314,153 +308,116 @@ function GlobalSuppressedRules({ }); return ( - <> - - - Global WAF Rule Exclusions - - Rules listed here are suppressed globally via SecRuleRemoveById for all proxy hosts using global WAF settings. - - {!wafEnabled && ( - Global WAF is currently disabled. Exclusions are saved but have no effect until WAF is enabled. - )} - +
+
+

Global WAF Rule Exclusions

+

+ Rules listed here are suppressed globally via SecRuleRemoveById for all proxy hosts using global WAF settings. +

+ {!wafEnabled && ( + + Global WAF is currently disabled. Exclusions are saved but have no effect until WAF is enabled. + + )} +
- {/* Add rule */} - - - Add Rule by ID - - - { setAddInput(e.target.value); setPendingRule(null); }} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleLookup(); } }} - inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} - sx={{ flex: 1 }} - disabled={lookupPending || pending} - /> - - + {/* Add rule */} +
+

Add Rule by ID

+
+ { setAddInput(e.target.value); setPendingRule(null); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleLookup(); } }} + inputMode="numeric" + pattern="[0-9]*" + className="flex-1" + disabled={lookupPending || pending} + /> + +
- {pendingRule && ( - - - Rule {pendingRule.id} - - - {pendingRule.message ?? "No description available — rule has not triggered yet"} - - - - - - - )} - + {pendingRule && ( +
+

Rule {pendingRule.id}

+

+ {pendingRule.message ?? "No description available — rule has not triggered yet"} +

+
+ + +
+
+ )} +
- {/* Search */} - {excluded.length > 0 && ( - 0 && ( +
+ + setSearch(e.target.value)} - size="small" - sx={{ maxWidth: 400 }} - slotProps={{ - input: { startAdornment: }, - }} + className="pl-8" /> - )} +
+ )} - {excluded.length === 0 ? ( - - - No globally suppressed rules. - Add a rule above or open a WAF event and click "Suppress Globally". - - ) : filtered.length === 0 ? ( - No rules match your search. - ) : ( - - {filtered.map((id) => ( - - - - Rule {id} - - - {messages[id] ?? "No description available — rule has not triggered yet"} - - - - handleRemove(id)} - disabled={pending} - color="error" - sx={{ flexShrink: 0 }} - > - - + {excluded.length === 0 ? ( +
+ +

No globally suppressed rules.

+

Add a rule above or open a WAF event and click "Suppress Globally".

+
+ ) : filtered.length === 0 ? ( +

No rules match your search.

+ ) : ( +
+ {filtered.map((id) => ( +
+
+

Rule {id}

+

+ {messages[id] ?? "No description available — rule has not triggered yet"} +

+
+ + + + + + Remove suppression - - ))} - - )} - - setSnackbar((s) => ({ ...s, open: false }))} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - > - setSnackbar((s) => ({ ...s, open: false }))}> - {snackbar.message} - - - + +
+ ))} +
+ )} +
); } @@ -468,7 +425,7 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const [tab, setTab] = useState(0); + const [tab, setTab] = useState("events"); const [searchTerm, setSearchTerm] = useState(initialSearch); const [selected, setSelected] = useState(null); const [localGlobalExcluded, setLocalGlobalExcluded] = useState(globalExcluded); @@ -501,29 +458,24 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo const mobileCard = (event: WafEvent) => ( setSelected(event)} > - - - + +
+
- - +
+ {new Date(event.ts * 1000).toLocaleString()} - - - - {event.host || "—"} - + +
+

{event.host || "—"}

{event.ruleId && ( - - Rule #{event.ruleId} - + Rule #{event.ruleId} )} -
+
); @@ -531,9 +483,9 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo { id: "ts", label: "Time", width: 150, render: (r: WafEvent) => ( - + {new Date(r.ts * 1000).toLocaleString()} - + ), }, { @@ -547,87 +499,97 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo { id: "host", label: "Host", width: 150, render: (r: WafEvent) => ( - - - {r.host || } - - + + + + + {r.host || } + + + {r.host ?? ""} + + ), }, { id: "clientIp", label: "Client IP", width: 140, render: (r: WafEvent) => ( - - - {r.clientIp} - +
+ {r.clientIp} {r.countryCode && ( - + + {r.countryCode} + )} - +
), }, { id: "method", label: "M", width: 60, render: (r: WafEvent) => ( - + {r.method || "—"} ), }, { id: "uri", label: "URI", width: 200, render: (r: WafEvent) => ( - - - {r.uri || } - - + + + + + {r.uri || } + + + {r.uri} + + ), }, { id: "ruleId", label: "Rule ID", width: 80, render: (r: WafEvent) => ( - - {r.ruleId ?? "—"} - + {r.ruleId ?? "—"} ), }, { id: "ruleMessage", label: "Rule Message", render: (r: WafEvent) => ( - - - {r.ruleMessage ?? } - - + + + + + {r.ruleMessage ?? } + + + {r.ruleMessage ?? ""} + + ), }, ]; return ( - - WAF - - Web Application Firewall events and rule management. - +
+

WAF

+

Web Application Firewall events and rule management.

- setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}> - - - - + + + Events + Suppressed Rules + Settings + - {tab === 0 && ( - <> - { setSearchTerm(e.target.value); updateSearch(e.target.value); }} - slotProps={{ - input: { startAdornment: }, - }} - size="small" - sx={{ maxWidth: 480 }} - /> + +
+ + { setSearchTerm(e.target.value); updateSearch(e.target.value); }} + className="pl-8" + /> +
setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])])} onSuppressHost={(ruleId, host) => setLocalHostWafMap((prev) => ({ ...prev, [host]: [...new Set([...(prev[host] ?? []), ruleId])] }))} /> - - )} +
- {tab === 1 && ( - setLocalGlobalExcluded((prev) => prev.filter((id) => id !== ruleId))} - onAdd={(ruleId, message) => { - setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])]); - setLocalGlobalMessages((prev) => ({ ...prev, [ruleId]: message })); - }} - /> - )} + + setLocalGlobalExcluded((prev) => prev.filter((id) => id !== ruleId))} + onAdd={(ruleId, message) => { + setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])]); + setLocalGlobalMessages((prev) => ({ ...prev, [ruleId]: message })); + }} + /> + - {tab === 2 && ( - - - WAF Settings - - Configure the global Web Application Firewall. Per-host settings can merge with or override these defaults. - Powered by Coraza with optional OWASP Core Rule Set. - - - - {wafState?.message && ( - {wafState.message} - )} - } - label="Enable WAF globally (blocking)" - /> - } - label={ - Load OWASP Core Rule Set{" "} - - (covers SQLi, XSS, LFI, RCE — recommended) - - - } - /> - {/* WafRuleExclusions intentionally omitted — managed in Suppressed Rules tab */} - setWafCustomDirectives(e.target.value)} - placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`} - inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }} - helperText="ModSecurity SecLang syntax. Applied after OWASP CRS if enabled." - fullWidth - /> - - - - - {[ - { label: "Allow IP", snippet: `SecRule REMOTE_ADDR "@ipMatch 1.2.3.4" "id:9000,phase:1,allow,nolog,msg:'Allow IP'"` }, - { label: "Disable WAF for path", snippet: `SecRule REQUEST_URI "@beginsWith /api/" "id:9001,phase:1,ctl:ruleEngine=Off,nolog"` }, - { label: "Remove XSS rules", snippet: `SecRuleRemoveByTag "attack-xss"` }, - { label: "Block User-Agent", snippet: `SecRule REQUEST_HEADERS:User-Agent "@contains badbot" "id:9002,phase:1,deny,status:403,log"` }, - ].map((t) => ( - - ))} - - - - - Rule exclusions are managed on the Suppressed Rules tab. - - - - - - - )} - + +
+
+

WAF Settings

+

+ Configure the global Web Application Firewall. Per-host settings can merge with or override these defaults. + Powered by Coraza with optional OWASP Core Rule Set. +

+
+
+ {wafState?.message && ( + + {wafState.message} + + )} +
+ + +
+
+ + +
+ {/* WafRuleExclusions intentionally omitted — managed in Suppressed Rules tab */} +
+ +