From e708cac7904cbf1a5672eb1e937ef17848f1cafe Mon Sep 17 00:00:00 2001 From: Pepe44DEV Date: Fri, 3 Jul 2026 00:21:20 +0200 Subject: [PATCH] overhaul automod escalation and register applications, restore orange theme Automod: per-filter actions (warn/timeout/kick/ban), channel/role exemptions, regex/severity badword rules, and a strike/escalation system backed by a new AutomodStrike table. Register: fixes missing answer labels, adds internal reviewer notes and cross-form application history for a user, and exposes form/status filters in the dashboard. Dashboard: overrides HeroUI's default blue accent token with the bot's established orange (#f97316), which was lost during the HeroUI v3 migration. Co-Authored-By: Claude Sonnet 5 --- frontend/src/app.css | 10 + frontend/src/context/AppContext.tsx | 72 + frontend/src/pages/Automod.tsx | 449 ++- frontend/src/pages/Register.tsx | 99 +- frontend/src/types/index.ts | 3 +- node_modules/.prisma/client/edge.js | 38 +- node_modules/.prisma/client/index-browser.js | 23 +- node_modules/.prisma/client/index.d.ts | 2675 ++++++++++++++++- node_modules/.prisma/client/index.js | 42 +- node_modules/.prisma/client/wasm.js | 23 +- .../migration.sql | 33 + src/database/schema.prisma | 26 + src/services/automodService.ts | 281 +- src/services/registerService.ts | 44 +- src/web/routes/api.ts | 49 +- 15 files changed, 3689 insertions(+), 178 deletions(-) create mode 100644 src/database/migrations/20260702120000_add_automod_strikes_and_register_notes/migration.sql diff --git a/frontend/src/app.css b/frontend/src/app.css index d548bb2..70ba715 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3,6 +3,16 @@ @custom-variant dark (&:is(.dark *)); +:root, +.light, +.dark { + /* Brand accent: matches the orange used across bot embeds and the login page + (see 0xf97316 in src/services/*.ts and src/web/server.ts), overriding + HeroUI's blue default theme token. */ + --accent: #f97316; + --accent-foreground: var(--snow); +} + :root { /* HeroUI ships fields with a transparent border and 0 border-width by default, so an Input on a plain Card is invisible (identical bg, no outline). Give diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 04dafe7..410f370 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -37,6 +37,13 @@ type AppState = { registerForms: RegisterForm[]; registerApps: RegisterApplication[]; musicStatus: { activeGuilds: number; sessions: MusicSession[] }; + automodStrikes: { userId: string; count: number; lastAt: string; reasons: string[] }[]; + registerStatusFilter: string; + registerFormFilter: string; + selectedAppId: string | null; + appNotes: any[]; + appHistory: any[]; + noteDraft: string; }; type AppContextType = AppState & { @@ -105,6 +112,14 @@ type AppContextType = AppState & { setKbEditDraft: (s: any | ((prev: any) => any)) => void; automationEditDraft: any; setAutomationEditDraft: (s: any | ((prev: any) => any)) => void; + loadAutomodStrikes: () => Promise; + resetAutomodStrike: (userId: string) => Promise; + setRegisterStatusFilter: (v: string) => void; + setRegisterFormFilter: (v: string) => void; + loadRegisterApps: (overrides?: { status?: string; formId?: string }) => Promise; + openAppDetail: (id: string) => Promise; + setNoteDraft: (v: string) => void; + addAppNote: () => Promise; }; const AppContext = createContext(null); @@ -153,6 +168,13 @@ export function AppProvider({ children }: { children: ReactNode }) { const [statsItemDraft, setStatsItemDraft] = useState<{ id?: string; label: string; type: string }>({ label: '', type: 'members' }); const [ticketDetail, setTicketDetail] = useState(null); const [ticketMessages, setTicketMessages] = useState([]); + const [automodStrikes, setAutomodStrikes] = useState<{ userId: string; count: number; lastAt: string; reasons: string[] }[]>([]); + const [registerStatusFilter, setRegisterStatusFilter] = useState(''); + const [registerFormFilter, setRegisterFormFilter] = useState(''); + const [selectedAppId, setSelectedAppId] = useState(null); + const [appNotes, setAppNotes] = useState([]); + const [appHistory, setAppHistory] = useState([]); + const [noteDraft, setNoteDraft] = useState(''); const setSection = useCallback((key: NavKey) => { setSectionState(key); @@ -473,6 +495,52 @@ export function AppProvider({ children }: { children: ReactNode }) { await loadGuildData(currentGuildId); } + async function loadAutomodStrikes() { + if (!currentGuildId) return; + const res = await apiFetch(`/automod/strikes?guildId=${encodeURIComponent(currentGuildId)}`); + setAutomodStrikes(res.strikes || []); + } + + async function resetAutomodStrike(userId: string) { + await apiFetch(`/automod/strikes?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' }); + await loadAutomodStrikes(); + } + + async function loadRegisterApps(overrides?: { status?: string; formId?: string }) { + if (!currentGuildId) return; + const params = new URLSearchParams({ guildId: currentGuildId }); + const status = overrides?.status ?? registerStatusFilter; + const formId = overrides?.formId ?? registerFormFilter; + if (status) params.set('status', status); + if (formId) params.set('formId', formId); + const res = await apiFetch(`/register/apps?${params.toString()}`); + setRegisterApps(res.applications || []); + } + + async function openAppDetail(id: string) { + if (selectedAppId === id) { + setSelectedAppId(null); + setAppNotes([]); + setAppHistory([]); + return; + } + setSelectedAppId(id); + const [notesRes, historyRes] = await Promise.all([ + apiFetch(`/register/apps/${id}/notes`), + apiFetch(`/register/apps/${id}/history`) + ]); + setAppNotes(notesRes.notes || []); + setAppHistory(historyRes.applications || []); + } + + async function addAppNote() { + if (!selectedAppId || !noteDraft.trim()) return; + await apiFetch(`/register/apps/${selectedAppId}/notes`, { method: 'POST', body: JSON.stringify({ body: noteDraft.trim() }) }); + setNoteDraft(''); + const notesRes = await apiFetch(`/register/apps/${selectedAppId}/notes`); + setAppNotes(notesRes.notes || []); + } + const handleLogout = useCallback(() => { window.location.href = `${appConfig.baseAuth || '/auth'}/logout`; }, []); @@ -486,6 +554,8 @@ export function AppProvider({ children }: { children: ReactNode }) { automationDraft, kbDraft, eventDraft, statusDraft, statsDraft, reactionDraft, formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft, ticketDetail, ticketMessages, kbEditDraft, automationEditDraft, + automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId, + appNotes, appHistory, noteDraft, setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin, setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData, saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats, @@ -498,6 +568,8 @@ export function AppProvider({ children }: { children: ReactNode }) { setReactionDraft, setFormDraft, setEditingFormId, setRegisterTab, setStatusServiceDraft, setStatsItemDraft, setTicketDetail, setKbEditDraft, setAutomationEditDraft, + loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter, + loadRegisterApps, openAppDetail, setNoteDraft, addAppNote, }}> {children} diff --git a/frontend/src/pages/Automod.tsx b/frontend/src/pages/Automod.tsx index 76b947a..dcab63a 100644 --- a/frontend/src/pages/Automod.tsx +++ b/frontend/src/pages/Automod.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react'; -import { Shield, Link2, Ban, AlertTriangle, Save, Info, MailWarning, AtSign, CaseUpper, X, UserPlus } from 'lucide-react'; +import { Shield, Link2, Ban, AlertTriangle, Save, Info, MailWarning, AtSign, CaseUpper, X, UserPlus, Plus, Trash2, Siren } from 'lucide-react'; import { useApp } from '../context/AppContext'; import { SectionCard } from '../components/shared/SectionCard'; -import { ChannelSelect } from '../components/shared/ChannelSelect'; -import { RoleSelect } from '../components/shared/RoleSelect'; +import { ChannelSelect, type ChannelOption } from '../components/shared/ChannelSelect'; +import { RoleSelect, type RoleOption } from '../components/shared/RoleSelect'; import { AppSwitch } from '../components/shared/AppSwitch'; import { useGuildResources } from '../hooks/useGuildResources'; @@ -14,62 +14,192 @@ const FILTERS = [ icon: , title: 'Bad-Word-Filter', description: 'Entfernt Nachrichten mit unerwünschten Begriffen automatisch.', + defaultAction: 'delete' as const, }, { key: 'linkFilter' as const, icon: , title: 'Link-Filter', description: 'Blockiert Links, die nicht auf der Whitelist stehen.', + defaultAction: 'delete' as const, }, { key: 'inviteFilter' as const, icon: , title: 'Einladungslink-Filter', description: 'Blockiert Discord-Invites unabhängig vom Link-Filter.', + defaultAction: 'delete' as const, }, { key: 'mentionSpamFilter' as const, icon: , title: 'Mass-Mention-Schutz', description: 'Verhindert Raid-artiges Massen-Pingen von Usern/Rollen.', + defaultAction: 'timeout' as const, }, { key: 'capsFilter' as const, icon: , title: 'Caps-Filter', description: 'Entfernt Nachrichten mit überwiegend GROSSBUCHSTABEN.', + defaultAction: 'delete' as const, }, { key: 'spamFilter' as const, icon: , title: 'Spam-Filter', description: 'Erkennt und unterdrückt Mehrfachnachrichten in kurzer Zeit.', + defaultAction: 'timeout' as const, }, ]; +const ACTION_LABELS: Record = { + delete: 'Nur löschen', + warn: 'Warnen', + timeout: 'Timeout', + kick: 'Kick', + ban: 'Ban', +}; + +const ESCALATION_ACTION_LABELS: Record = { + timeout: 'Timeout', + kick: 'Kick', + ban: 'Ban', +}; + function toList(value?: string) { return (value || '').split(',').map((x) => x.trim()).filter(Boolean); } +function RoleChipList({ roles, values, onChange }: { roles: RoleOption[]; values: string[]; onChange: (v: string[]) => void }) { + const [draft, setDraft] = useState(''); + return ( +
+
+ {values.length ? values.map((roleId) => { + const role = roles.find((r) => r.id === roleId); + return ( +
+ {role?.color && } + {role?.name || roleId} + +
+ ); + }) :

Keine Rollen-Ausnahmen

} +
+
+ !values.includes(r.id))} value={draft} onChange={setDraft} placeholder="Rolle ausnehmen" /> + +
+
+ ); +} + +function ChannelChipList({ channels, values, onChange }: { channels: ChannelOption[]; values: string[]; onChange: (v: string[]) => void }) { + const [draft, setDraft] = useState(''); + return ( +
+
+ {values.length ? values.map((channelId) => { + const channel = channels.find((c) => c.id === channelId); + return ( +
+ #{channel?.name || channelId} + +
+ ); + }) :

Keine Kanal-Ausnahmen

} +
+
+ !values.includes(c.id))} value={draft} onChange={setDraft} placeholder="Kanal ausnehmen" /> + +
+
+ ); +} + export function Automod() { - const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp(); + const { + settings, setSettings, saveSettingsPayload, currentGuildId, + automodStrikes, loadAutomodStrikes, resetAutomodStrike, + } = useApp(); const { channels, roles } = useGuildResources(currentGuildId); const [roleDraft, setRoleDraft] = useState(''); const cfg = settings.automodConfig || {}; const whitelistRoles: string[] = cfg.whitelistRoles || []; + const strikeCfg = cfg.strikeConfig || { enabled: false, decayHours: 24, thresholds: [] }; + const badwordRules: { pattern: string; isRegex?: boolean; severity?: string }[] = + cfg.badwordRules && cfg.badwordRules.length + ? cfg.badwordRules + : (cfg.customBadwords || []).map((w: string) => ({ pattern: w, isRegex: false, severity: 'low' })); + + useEffect(() => { + if (currentGuildId) loadAutomodStrikes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentGuildId]); const patchConfig = (patch: Record) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), ...patch } })); + const patchFilter = (key: string, patch: Record) => { + const filters = { ...(cfg.filters || {}) }; + filters[key] = { ...(filters[key] || {}), ...patch }; + patchConfig({ filters }); + }; + const addWhitelistRole = () => { if (!roleDraft || whitelistRoles.includes(roleDraft)) return; patchConfig({ whitelistRoles: [...whitelistRoles, roleDraft] }); setRoleDraft(''); }; + const updateBadword = (idx: number, patch: Record) => { + patchConfig({ badwordRules: badwordRules.map((r, i) => (i === idx ? { ...r, ...patch } : r)) }); + }; + const removeBadword = (idx: number) => { + patchConfig({ badwordRules: badwordRules.filter((_, i) => i !== idx) }); + }; + const addBadword = () => { + patchConfig({ badwordRules: [...badwordRules, { pattern: '', isRegex: false, severity: 'low' }] }); + }; + + const patchStrike = (patch: Record) => patchConfig({ strikeConfig: { ...strikeCfg, ...patch } }); + const thresholds: { count: number; action: string; timeoutMinutes?: number }[] = strikeCfg.thresholds || []; + const updateThreshold = (idx: number, patch: Record) => { + patchStrike({ thresholds: thresholds.map((t, i) => (i === idx ? { ...t, ...patch } : t)) }); + }; + const removeThreshold = (idx: number) => { + patchStrike({ thresholds: thresholds.filter((_, i) => i !== idx) }); + }; + const addThreshold = () => { + patchStrike({ thresholds: [...thresholds, { count: 3, action: 'timeout', timeoutMinutes: 10 }] }); + }; + return ( - +
@@ -92,83 +222,113 @@ export function Automod() {
- {FILTERS.map((f) => ( -
-
-
- {f.icon} + {FILTERS.map((f) => { + const filterCfg = cfg.filters?.[f.key] || {}; + const action = filterCfg.action || f.defaultAction; + const exemptRoleIds: string[] = filterCfg.exemptRoleIds || []; + const exemptChannelIds: string[] = filterCfg.exemptChannelIds || []; + return ( +
+
+
+ {f.icon} +
+
+
{f.title}
+
{f.description}
+
+ patchConfig({ [f.key]: v })} + />
-
-
{f.title}
-
{f.description}
-
- patchConfig({ [f.key]: v })} - /> + + {(cfg[f.key] ?? true) && ( +
+
+ + + + + {action === 'timeout' && ( + + + patchFilter(f.key, { timeoutMinutes: Number(e.target.value || 10) })} + /> + + )} +
+ + {f.key === 'linkFilter' && ( + + +