import { useEffect, useMemo, useState } from 'react' import { Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row, Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message, } from 'antd' import { CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined, WarningOutlined, } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import apiClient, { isEnvelope } from '../../api/client' import PageHeader from '../../components/PageHeader' import DataTable from '../../components/DataTable' import { useAuthStore } from '../../stores/auth' import { CRS_RULES, getRuleDescription } from './crsRules' const { Text } = Typography // ---------- Types ----------------------------------------------------------- interface Domain { id: number name: string active: boolean } interface WafConfig { id?: number domain_id: number enabled: boolean mode: 'detection' | 'blocking' paranoia_level: number rule_exclusions: string[] crs_plugins: string[] app_profiles: string[] exclusion_notes: Record trusted_proxies: string[] custom_rules: string } interface WafProfile { id: number name: string description: string rule_exclusions: string[] builtin: boolean created_at?: string updated_at?: string } // CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen. const CRS_PLUGIN_OPTIONS = [ { value: 'nextcloud', label: 'Nextcloud' }, { value: 'wordpress', label: 'WordPress' }, { value: 'drupal', label: 'Drupal' }, ] // ---------- API helpers ----------------------------------------------------- async function fetchDomains(): Promise { const r = await apiClient.get('/domains') if (!isEnvelope(r.data)) return [] return (r.data.data as { domains?: Domain[] }).domains ?? [] } async function fetchWafConfigs(): Promise { const r = await apiClient.get('/waf/configs') if (!isEnvelope(r.data)) return [] return (r.data.data as { configs?: WafConfig[] }).configs ?? [] } async function fetchWafConfig(domainId: number): Promise { const r = await apiClient.get(`/waf/configs/${domainId}`) if (isEnvelope(r.data)) return (r.data.data as { config: WafConfig }).config return defaultConfig(domainId) } function defaultConfig(domainId: number): WafConfig { return { domain_id: domainId, enabled: false, mode: 'detection', paranoia_level: 1, rule_exclusions: [], crs_plugins: [], app_profiles: [], exclusion_notes: {}, trusted_proxies: [], custom_rules: '', } } async function fetchProfiles(): Promise { const r = await apiClient.get('/waf/profiles') if (!isEnvelope(r.data)) return [] return (r.data.data as { profiles?: WafProfile[] }).profiles ?? [] } interface WafFormValues { enabled: boolean mode: 'detection' | 'blocking' paranoia_level: number crs_plugins: string[] app_profiles: string[] trusted_proxies_str: string custom_rules: string } // ---------- Config Drawer --------------------------------------------------- interface ConfigDrawerProps { domainName: string domainId: number | null onClose: () => void } function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const [form] = Form.useForm() const [addRuleId, setAddRuleId] = useState('') const [addNote, setAddNote] = useState('') // Durchsuchbare Optionen aus der CRS-Regel-Liste (ID — Beschreibung). // filterOption unten matcht sowohl ID als auch Beschreibung. const ruleOptions = useMemo( () => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })), [], ) const { data: cfg, isLoading } = useQuery({ queryKey: ['waf', 'config', domainId], queryFn: () => fetchWafConfig(domainId!), enabled: domainId !== null, }) // Eigene App-Profile (nur benutzerdefinierte, nicht built-in) als Optionen. const { data: profiles } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles }) const profileOptions = useMemo( () => (profiles ?? []).filter(p => !p.builtin).map(p => ({ value: p.name, label: p.name })), [profiles], ) const save = useMutation({ mutationFn: (values: WafConfig) => apiClient.put(`/waf/configs/${domainId}`, values), onSuccess: () => { message.success(t('common.save')) void qc.invalidateQueries({ queryKey: ['waf'] }) onClose() }, onError: () => message.error(t('waf.config.saveFailed')), }) return ( {domainName}} open={domainId !== null} onClose={onClose} width={520} footer={ } > {!isLoading && cfg && (
{ const proxies = (vals.trusted_proxies_str ?? '') .split(',').map((s: string) => s.trim()).filter(Boolean) save.mutate({ domain_id: domainId!, enabled: vals.enabled, mode: vals.mode, paranoia_level: vals.paranoia_level, rule_exclusions: cfg?.rule_exclusions ?? [], crs_plugins: vals.crs_plugins ?? [], app_profiles: vals.app_profiles ?? [], exclusion_notes: cfg?.exclusion_notes ?? {}, trusted_proxies: proxies, custom_rules: vals.custom_rules ?? '', }) }} > {/* Exclusions list — shows existing exclusions with notes + remove button */} {(cfg?.rule_exclusions ?? []).length === 0 ? ( {t('waf.config.noExclusions')} ) : ( {(cfg?.rule_exclusions ?? []).map(ruleId => (
{ruleId} {cfg?.exclusion_notes?.[ruleId] || {t('waf.config.noNote')}} {!isViewer && (
))}
)} {!isViewer && (
setAddNote(e.target.value)} style={{ width: 150 }} />
{t('waf.config.exclusionsAddHint')}
)}
)}
) } // ---------- Alerts tab ------------------------------------------------------ interface WafAlert { id: number domain_id?: number hostname: string client_ip: string method: string uri: string rule_id: number rule_msg: string severity: string action: 'detected' | 'blocked' created_at: string } async function fetchAlerts(domainId?: number): Promise { const params = domainId ? `?domain_id=${domainId}&limit=500` : '?limit=500' const r = await apiClient.get(`/waf/alerts${params}`) if (!isEnvelope(r.data)) return [] return (r.data.data as { alerts?: WafAlert[] }).alerts ?? [] } function AlertsTab({ domainId, configMap }: { domainId?: number; configMap: Map }) { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const [exceptionModal, setExceptionModal] = useState<{ domainId: number; ruleId: number } | null>(null) const [exceptionNote, setExceptionNote] = useState('') const { data: alerts, isLoading } = useQuery({ queryKey: ['waf', 'alerts', domainId ?? 'all'], queryFn: () => fetchAlerts(domainId), refetchInterval: 15_000, }) const purge = useMutation({ mutationFn: () => apiClient.delete('/waf/alerts?days=30'), onSuccess: () => { message.success(t('waf.alerts.purged')) void qc.invalidateQueries({ queryKey: ['waf', 'alerts'] }) }, }) const addException = useMutation({ mutationFn: async ({ domainId, ruleId, note }: { domainId: number; ruleId: number; note: string }) => { const r = await apiClient.get(`/waf/configs/${domainId}`) const cfg: WafConfig = isEnvelope(r.data) ? (r.data.data as { config: WafConfig }).config : defaultConfig(domainId) const exclusions = [...(cfg.rule_exclusions ?? [])] const notes = { ...(cfg.exclusion_notes ?? {}) } const ruleStr = String(ruleId) if (!exclusions.includes(ruleStr)) exclusions.push(ruleStr) if (note.trim()) notes[ruleStr] = note.trim() return apiClient.put(`/waf/configs/${domainId}`, { ...cfg, rule_exclusions: exclusions, exclusion_notes: notes }) }, onSuccess: () => { message.success(t('waf.alerts.exceptionAdded')) setExceptionModal(null) setExceptionNote('') void qc.invalidateQueries({ queryKey: ['waf'] }) }, onError: () => message.error(t('waf.alerts.exceptionFailed')), }) const severityColor = (s: string) => { switch (s?.toLowerCase()) { case 'critical': return 'red' case 'error': return 'red' case 'warning': return 'orange' case 'notice': return 'blue' default: return 'default' } } const columns = [ { title: t('waf.alerts.col.time'), dataIndex: 'created_at', key: 'created_at', width: 155, render: (v: string) => ( {new Date(v).toLocaleString()} ), }, { title: t('waf.alerts.col.action'), dataIndex: 'action', key: 'action', width: 100, render: (v: string) => v === 'blocked' ? {t('waf.alerts.blocked')} : {t('waf.alerts.detected')}, }, { title: t('waf.alerts.col.hostname'), dataIndex: 'hostname', key: 'hostname', width: 180, render: (v: string) => {v} }, { title: t('waf.alerts.col.clientIp'), dataIndex: 'client_ip', key: 'client_ip', width: 120, render: (v: string) => {v} }, { title: t('waf.alerts.col.method'), dataIndex: 'method', key: 'method', width: 70 }, { title: t('waf.alerts.col.uri'), dataIndex: 'uri', key: 'uri', ellipsis: true, render: (v: string) => {v} }, { title: t('waf.alerts.col.ruleId'), dataIndex: 'rule_id', key: 'rule_id', width: 90, render: (v: number) => ( {v} ) }, { title: t('waf.alerts.col.severity'), dataIndex: 'severity', key: 'severity', width: 90, render: (v: string) => {v || '—'} }, { title: t('waf.alerts.col.msg'), dataIndex: 'rule_msg', key: 'rule_msg', ellipsis: true, render: (v: string) => {v || '—'} }, { title: '', key: 'exception', width: 130, render: (_: unknown, row: WafAlert) => { const isExcluded = !!row.domain_id && (configMap.get(row.domain_id)?.rule_exclusions ?? []).includes(String(row.rule_id)) if (isExcluded) { return ( }> {t('waf.alerts.alreadyExcluded')} ) } const canExclude = !!row.domain_id && row.rule_id > 0 && !isViewer return ( ) }, }, ] return (
{(alerts ?? []).length} {t('waf.alerts.total')} purge.mutate()} disabled={isViewer} >
{(alerts ?? []).length === 0 && !isLoading ? ( ) : ( { const isExcluded = !!row.domain_id && (configMap.get(row.domain_id)?.rule_exclusions ?? []).includes(String(row.rule_id)) if (isExcluded) return 'waf-alert-row--excluded' if (row.action === 'blocked') return 'fw-rule-row--zero-hit' return '' }} /> )} {/* Exception note modal */} { setExceptionModal(null); setExceptionNote('') }} onOk={() => exceptionModal && addException.mutate({ domainId: exceptionModal.domainId, ruleId: exceptionModal.ruleId, note: exceptionNote, })} confirmLoading={addException.isPending} okText={t('waf.alerts.addException')} >

{t('waf.alerts.exceptionModalHint')}

setExceptionNote(e.target.value)} autoFocus />
) } // ---------- Profiles tab ---------------------------------------------------- interface ProfileFormValues { name: string description: string rule_exclusions: string[] } function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) { const { t } = useTranslation() const qc = useQueryClient() const [form] = Form.useForm() const isCreate = profile !== null && profile.id === 0 const ruleOptions = useMemo( () => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })), [], ) // Formular bei jedem Öffnen/Wechsel neu befüllen. useEffect(() => { if (profile) { form.setFieldsValue({ name: profile.name, description: profile.description, rule_exclusions: profile.rule_exclusions ?? [], }) } }, [profile, form]) const save = useMutation({ mutationFn: (vals: ProfileFormValues) => isCreate ? apiClient.post('/waf/profiles', vals) : apiClient.put(`/waf/profiles/${profile!.id}`, vals), onSuccess: () => { message.success(t('waf.profiles.saved')) void qc.invalidateQueries({ queryKey: ['waf'] }) onClose() }, onError: () => message.error(t('waf.profiles.saveFailed')), }) return ( } >
save.mutate({ name: vals.name, description: vals.description ?? '', rule_exclusions: vals.rule_exclusions ?? [], })} >