diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 37f3c17c..28c023e6 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -7,6 +7,7 @@ import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterTo import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances"; import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings, getWafSettings } from "@/src/lib/settings"; import { listProxyHosts, updateProxyHost } from "@/src/lib/models/proxy-hosts"; +import { getWafRuleMessages } from "@/src/lib/models/waf-events"; import type { CloudflareSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings"; type ActionResult = { @@ -629,6 +630,12 @@ export async function syncSlaveInstancesAction(_prevState: ActionResult | null, } } +export async function lookupWafRuleMessageAction(ruleId: number): Promise<{ message: string | null }> { + await requireAdmin(); + const map = await getWafRuleMessages([ruleId]); + return { message: map[ruleId] ?? null }; +} + export async function removeWafRuleGloballyAction(ruleId: number): Promise { try { await requireAdmin(); diff --git a/app/(dashboard)/waf-events/WafEventsClient.tsx b/app/(dashboard)/waf-events/WafEventsClient.tsx index 4714a8bb..9b41f5e7 100644 --- a/app/(dashboard)/waf-events/WafEventsClient.tsx +++ b/app/(dashboard)/waf-events/WafEventsClient.tsx @@ -29,6 +29,7 @@ import { suppressWafRuleGloballyAction, suppressWafRuleForHostAction, removeWafRuleGloballyAction, + lookupWafRuleMessageAction, } from "../settings/actions"; type Props = { @@ -231,17 +232,28 @@ function WafEventDrawer({ function GlobalSuppressedRules({ excluded, - messages, + messages: initialMessages, wafEnabled, onRemove, + onAdd, }: { excluded: number[]; messages: Record; wafEnabled: boolean; onRemove: (ruleId: number) => void; + 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 + const [addInput, setAddInput] = useState(""); + const [lookupPending, setLookupPending] = useState(false); + const [pendingRule, setPendingRule] = useState<{ id: number; message: string | null } | null>(null); + + // Search + const [search, setSearch] = useState(""); function handleRemove(ruleId: number) { startTransition(async () => { @@ -251,6 +263,45 @@ function GlobalSuppressedRules({ }); } + async function handleLookup() { + 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 }); + return; + } + setLookupPending(true); + try { + const result = await lookupWafRuleMessageAction(n); + setPendingRule({ id: n, message: result.message }); + } finally { + setLookupPending(false); + } + } + + function handleConfirmAdd() { + 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) { + onAdd(pendingRule.id, pendingRule.message); + setMessages((prev) => ({ ...prev, [pendingRule.id]: pendingRule.message })); + setAddInput(""); + setPendingRule(null); + } + }); + } + + const filtered = excluded.filter((id) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + String(id).includes(q) || + (messages[id] ?? "").toLowerCase().includes(q) + ); + }); + return ( <> @@ -264,6 +315,72 @@ function GlobalSuppressedRules({ )} + {/* 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} + /> + + + + {pendingRule && ( + + + Rule {pendingRule.id} + + + {pendingRule.message ?? "No description available — rule has not triggered yet"} + + + + + + + )} + + + {/* Search */} + {excluded.length > 0 && ( + setSearch(e.target.value)} + size="small" + sx={{ maxWidth: 400 }} + slotProps={{ + input: { startAdornment: }, + }} + /> + )} + {excluded.length === 0 ? ( No globally suppressed rules. - Open a WAF event and click "Suppress Globally" to add one. + Add a rule above or open a WAF event and click "Suppress Globally". + ) : filtered.length === 0 ? ( + No rules match your search. ) : ( - {excluded.map((id) => ( + {filtered.map((id) => ( (null); const [localGlobalExcluded, setLocalGlobalExcluded] = useState(globalExcluded); + const [localGlobalMessages, setLocalGlobalMessages] = useState(globalExcludedMessages); const [localHostWafMap, setLocalHostWafMap] = useState(hostWafMap); useEffect(() => { setSearchTerm(initialSearch); }, [initialSearch]); const debounceRef = useRef | null>(null); @@ -487,9 +607,13 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo {tab === 1 && ( setLocalGlobalExcluded((prev) => prev.filter((id) => id !== ruleId))} + onAdd={(ruleId, message) => { + setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])]); + setLocalGlobalMessages((prev) => ({ ...prev, [ruleId]: message })); + }} /> )}