overhaul automod escalation and register applications, restore orange theme
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m12s
SonarQube / sonar (push) Successful in -21s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 00:21:20 +02:00
parent fe41bfdd88
commit e708cac790
15 changed files with 3689 additions and 178 deletions

View File

@@ -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

View File

@@ -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<void>;
resetAutomodStrike: (userId: string) => Promise<void>;
setRegisterStatusFilter: (v: string) => void;
setRegisterFormFilter: (v: string) => void;
loadRegisterApps: (overrides?: { status?: string; formId?: string }) => Promise<void>;
openAppDetail: (id: string) => Promise<void>;
setNoteDraft: (v: string) => void;
addAppNote: () => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(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<TicketRecord | null>(null);
const [ticketMessages, setTicketMessages] = useState<any[]>([]);
const [automodStrikes, setAutomodStrikes] = useState<{ userId: string; count: number; lastAt: string; reasons: string[] }[]>([]);
const [registerStatusFilter, setRegisterStatusFilter] = useState('');
const [registerFormFilter, setRegisterFormFilter] = useState('');
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const [appNotes, setAppNotes] = useState<any[]>([]);
const [appHistory, setAppHistory] = useState<any[]>([]);
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<any>(`/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<any>(`/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<any>(`/register/apps/${id}/notes`),
apiFetch<any>(`/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<any>(`/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}
</AppContext.Provider>

View File

@@ -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: <Ban size={16} />,
title: 'Bad-Word-Filter',
description: 'Entfernt Nachrichten mit unerwünschten Begriffen automatisch.',
defaultAction: 'delete' as const,
},
{
key: 'linkFilter' as const,
icon: <Link2 size={16} />,
title: 'Link-Filter',
description: 'Blockiert Links, die nicht auf der Whitelist stehen.',
defaultAction: 'delete' as const,
},
{
key: 'inviteFilter' as const,
icon: <MailWarning size={16} />,
title: 'Einladungslink-Filter',
description: 'Blockiert Discord-Invites unabhängig vom Link-Filter.',
defaultAction: 'delete' as const,
},
{
key: 'mentionSpamFilter' as const,
icon: <AtSign size={16} />,
title: 'Mass-Mention-Schutz',
description: 'Verhindert Raid-artiges Massen-Pingen von Usern/Rollen.',
defaultAction: 'timeout' as const,
},
{
key: 'capsFilter' as const,
icon: <CaseUpper size={16} />,
title: 'Caps-Filter',
description: 'Entfernt Nachrichten mit überwiegend GROSSBUCHSTABEN.',
defaultAction: 'delete' as const,
},
{
key: 'spamFilter' as const,
icon: <AlertTriangle size={16} />,
title: 'Spam-Filter',
description: 'Erkennt und unterdrückt Mehrfachnachrichten in kurzer Zeit.',
defaultAction: 'timeout' as const,
},
];
const ACTION_LABELS: Record<string, string> = {
delete: 'Nur löschen',
warn: 'Warnen',
timeout: 'Timeout',
kick: 'Kick',
ban: 'Ban',
};
const ESCALATION_ACTION_LABELS: Record<string, string> = {
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 (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{values.length ? values.map((roleId) => {
const role = roles.find((r) => r.id === roleId);
return (
<div key={roleId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
{role?.color && <span className="size-2 rounded-full" style={{ backgroundColor: role.color }} />}
{role?.name || roleId}
<button
type="button"
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => onChange(values.filter((id) => id !== roleId))}
>
<X size={11} />
</button>
</div>
);
}) : <p className="text-xs text-muted">Keine Rollen-Ausnahmen</p>}
</div>
<div className="flex gap-2">
<RoleSelect options={roles.filter((r) => !values.includes(r.id))} value={draft} onChange={setDraft} placeholder="Rolle ausnehmen" />
<Button
size="sm" variant="tertiary" className="shrink-0" isDisabled={!draft}
onPress={() => { if (draft && !values.includes(draft)) { onChange([...values, draft]); setDraft(''); } }}
>
<Plus size={14} />
</Button>
</div>
</div>
);
}
function ChannelChipList({ channels, values, onChange }: { channels: ChannelOption[]; values: string[]; onChange: (v: string[]) => void }) {
const [draft, setDraft] = useState('');
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{values.length ? values.map((channelId) => {
const channel = channels.find((c) => c.id === channelId);
return (
<div key={channelId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
#{channel?.name || channelId}
<button
type="button"
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => onChange(values.filter((id) => id !== channelId))}
>
<X size={11} />
</button>
</div>
);
}) : <p className="text-xs text-muted">Keine Kanal-Ausnahmen</p>}
</div>
<div className="flex gap-2">
<ChannelSelect options={channels.filter((c) => !values.includes(c.id))} value={draft} onChange={setDraft} placeholder="Kanal ausnehmen" />
<Button
size="sm" variant="tertiary" className="shrink-0" isDisabled={!draft}
onPress={() => { if (draft && !values.includes(draft)) { onChange([...values, draft]); setDraft(''); } }}
>
<Plus size={14} />
</Button>
</div>
</div>
);
}
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<string, any>) =>
setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), ...patch } }));
const patchFilter = (key: string, patch: Record<string, any>) => {
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<string, any>) => {
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<string, any>) => patchConfig({ strikeConfig: { ...strikeCfg, ...patch } });
const thresholds: { count: number; action: string; timeoutMinutes?: number }[] = strikeCfg.thresholds || [];
const updateThreshold = (idx: number, patch: Record<string, any>) => {
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 (
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
<SectionCard title="Automod" subtitle="Filter, Aktionen, Eskalation und Sicherheit">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
@@ -92,83 +222,113 @@ export function Automod() {
</div>
<div className="flex flex-col gap-2">
{FILTERS.map((f) => (
<div key={f.key} className="bg-surface-secondary rounded-xl p-3">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
{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 (
<div key={f.key} className="bg-surface-secondary rounded-xl p-3">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
{f.icon}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{f.title}</div>
<div className="text-xs text-muted">{f.description}</div>
</div>
<AppSwitch
aria-label={f.title}
isSelected={cfg[f.key] ?? true}
onChange={(v) => patchConfig({ [f.key]: v })}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{f.title}</div>
<div className="text-xs text-muted">{f.description}</div>
</div>
<AppSwitch
aria-label={f.title}
isSelected={cfg[f.key] ?? true}
onChange={(v) => patchConfig({ [f.key]: v })}
/>
{(cfg[f.key] ?? true) && (
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3">
<div className="flex items-end gap-2">
<TextField className="w-48">
<Label className="text-xs">Aktion bei Verstoß</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={action}
onChange={(e) => patchFilter(f.key, { action: e.target.value })}
>
{Object.entries(ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</TextField>
{action === 'timeout' && (
<TextField className="w-36">
<Label className="text-xs">Timeout (Min.)</Label>
<Input
type="number" min="1"
value={String(filterCfg.timeoutMinutes ?? cfg.spamTimeoutMinutes ?? 10)}
onChange={(e) => patchFilter(f.key, { timeoutMinutes: Number(e.target.value || 10) })}
/>
</TextField>
)}
</div>
{f.key === 'linkFilter' && (
<TextField>
<Label className="text-xs">Whitelist Links (Komma-getrennt)</Label>
<TextArea
value={(cfg.linkWhitelist || []).join(', ')}
onChange={(e) => patchConfig({ linkWhitelist: toList(e.target.value) })}
placeholder="trusted-domain.com, another-safe.site"
/>
</TextField>
)}
{f.key === 'mentionSpamFilter' && (
<TextField className="w-56">
<Label className="text-xs">Max. Erwähnungen pro Nachricht</Label>
<Input
type="number" min="2"
value={String(cfg.maxMentions ?? 5)}
onChange={(e) => patchConfig({ maxMentions: Number(e.target.value || 5) })}
/>
</TextField>
)}
{f.key === 'spamFilter' && (
<div className="grid grid-cols-2 gap-2">
<TextField>
<Label className="text-xs">Nachrichten</Label>
<Input
type="number" min="2"
value={String(cfg.spamThreshold ?? 5)}
onChange={(e) => patchConfig({ spamThreshold: Number(e.target.value || 5) })}
/>
</TextField>
<TextField>
<Label className="text-xs">Zeitfenster (Sek.)</Label>
<Input
type="number" min="1"
value={String(Math.round((cfg.windowMs ?? 7000) / 1000))}
onChange={(e) => patchConfig({ windowMs: Number(e.target.value || 7) * 1000 })}
/>
</TextField>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Ausgenommene Rollen</Label>
<RoleChipList roles={roles} values={exemptRoleIds} onChange={(v) => patchFilter(f.key, { exemptRoleIds: v })} />
</div>
<div>
<Label className="text-xs">Ausgenommene Kanäle</Label>
<ChannelChipList channels={channels} values={exemptChannelIds} onChange={(v) => patchFilter(f.key, { exemptChannelIds: v })} />
</div>
</div>
</div>
)}
</div>
{f.key === 'linkFilter' && (cfg.linkFilter ?? true) && (
<div className="mt-3 border-t border-border pt-3">
<TextField>
<Label className="text-xs">Whitelist Links (Komma-getrennt)</Label>
<TextArea
value={(cfg.linkWhitelist || []).join(', ')}
onChange={(e) => patchConfig({ linkWhitelist: toList(e.target.value) })}
placeholder="trusted-domain.com, another-safe.site"
/>
</TextField>
</div>
)}
{f.key === 'mentionSpamFilter' && (cfg.mentionSpamFilter ?? true) && (
<div className="mt-3 flex items-end gap-2 border-t border-border pt-3">
<TextField className="w-56">
<Label className="text-xs">Max. Erwähnungen pro Nachricht</Label>
<Input
type="number"
min="2"
value={String(cfg.maxMentions ?? 5)}
onChange={(e) => patchConfig({ maxMentions: Number(e.target.value || 5) })}
/>
</TextField>
</div>
)}
{f.key === 'spamFilter' && (cfg.spamFilter ?? true) && (
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border pt-3">
<TextField>
<Label className="text-xs">Nachrichten</Label>
<Input
type="number"
min="2"
value={String(cfg.spamThreshold ?? 5)}
onChange={(e) => patchConfig({ spamThreshold: Number(e.target.value || 5) })}
/>
</TextField>
<TextField>
<Label className="text-xs">Zeitfenster (Sek.)</Label>
<Input
type="number"
min="1"
value={String(Math.round((cfg.windowMs ?? 7000) / 1000))}
onChange={(e) => patchConfig({ windowMs: Number(e.target.value || 7) * 1000 })}
/>
</TextField>
<TextField>
<Label className="text-xs">Timeout (Min.)</Label>
<Input
type="number"
min="1"
value={String(cfg.spamTimeoutMinutes ?? 10)}
onChange={(e) => patchConfig({ spamTimeoutMinutes: Number(e.target.value || 10) })}
/>
</TextField>
</div>
)}
</div>
))}
);
})}
</div>
<TextField>
@@ -192,24 +352,131 @@ export function Automod() {
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Eigene Bad-Words</h3>
<h3 className="text-base font-semibold">Bad-Word-Regeln</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
<TextArea
value={(cfg.customBadwords || []).join(', ')}
onChange={(e) => patchConfig({ customBadwords: toList(e.target.value) })}
placeholder="wort1, wort2, wort3"
/>
<p className="text-xs text-muted">Zusätzlich zur eingebauten Standard-Liste, Komma-getrennt.</p>
<CardContent className="flex flex-col gap-3 p-5">
<p className="text-xs text-muted">Wörter, Phrasen oder reguläre Ausdrücke mit eigenem Schweregrad. Ergänzt die eingebaute Standard-Liste.</p>
{badwordRules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-xl bg-surface-secondary p-2">
<Input
className="flex-1"
placeholder={rule.isRegex ? 'Regex, z.B. \\bwort\\d+\\b' : 'Wort oder Phrase'}
value={rule.pattern}
onChange={(e) => updateBadword(idx, { pattern: e.target.value })}
/>
<select
className="rounded-xl px-2 py-2 text-xs"
value={rule.severity || 'low'}
onChange={(e) => updateBadword(idx, { severity: e.target.value })}
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
<label className="flex items-center gap-1 text-xs text-muted shrink-0">
<AppSwitch aria-label="Regex" isSelected={!!rule.isRegex} onChange={(v) => updateBadword(idx, { isRegex: v })} />
Regex
</label>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => removeBadword(idx)}>
<Trash2 size={14} />
</Button>
</div>
))}
<Button size="sm" variant="tertiary" onPress={addBadword}>
<Plus size={14} /> Regel hinzufügen
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Rollen-Whitelist</h3>
<h3 className="flex items-center gap-2 text-base font-semibold"><Siren size={16} /> Eskalation / Strikes</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<p className="text-xs text-muted">Mitglieder mit diesen Rollen werden von Automod komplett ignoriert.</p>
<div className="flex items-center gap-3 rounded-xl bg-surface-secondary p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">Strike-System aktiv</div>
<div className="text-xs text-muted">Verstöße werden gezählt; ab einer Schwelle greift automatisch eine härtere Strafe.</div>
</div>
<AppSwitch aria-label="Strike-System aktiv" isSelected={!!strikeCfg.enabled} onChange={(v) => patchStrike({ enabled: v })} />
</div>
{strikeCfg.enabled && (
<>
<TextField className="w-48">
<Label className="text-xs">Verstöße verfallen nach (Std.)</Label>
<Input
type="number" min="0" placeholder="0 = nie"
value={String(strikeCfg.decayHours ?? 24)}
onChange={(e) => patchStrike({ decayHours: Number(e.target.value || 0) })}
/>
</TextField>
<div className="flex flex-col gap-2">
{thresholds.map((t, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-xl bg-surface-secondary p-2">
<TextField className="w-24">
<Label className="text-xs">Ab Anzahl</Label>
<Input type="number" min="1" value={String(t.count)} onChange={(e) => updateThreshold(idx, { count: Number(e.target.value || 1) })} />
</TextField>
<TextField className="w-32">
<Label className="text-xs">Aktion</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={t.action}
onChange={(e) => updateThreshold(idx, { action: e.target.value })}
>
{Object.entries(ESCALATION_ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</TextField>
{t.action === 'timeout' && (
<TextField className="w-28">
<Label className="text-xs">Minuten</Label>
<Input type="number" min="1" value={String(t.timeoutMinutes ?? 10)} onChange={(e) => updateThreshold(idx, { timeoutMinutes: Number(e.target.value || 10) })} />
</TextField>
)}
<Button isIconOnly size="sm" variant="danger-soft" className="mt-4" onPress={() => removeThreshold(idx)}>
<Trash2 size={14} />
</Button>
</div>
))}
<Button size="sm" variant="tertiary" onPress={addThreshold}>
<Plus size={14} /> Schwelle hinzufügen
</Button>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Aktuelle Verstöße</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{automodStrikes.length ? automodStrikes.map((s) => (
<div key={s.userId} className="flex items-center justify-between gap-2 rounded-xl bg-surface-secondary p-3">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{s.userId}</div>
<div className="text-xs text-muted truncate">{s.reasons.join(' · ') || 'Keine Details'}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="rounded-full bg-danger-soft px-2 py-1 text-xs font-semibold text-danger">{s.count}</span>
<Button size="sm" variant="tertiary" onPress={() => resetAutomodStrike(s.userId)}>Zurücksetzen</Button>
</div>
</div>
)) : (
<p className="text-xs text-muted">Keine aktiven Verstöße.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Globale Rollen-Whitelist</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<p className="text-xs text-muted">Mitglieder mit diesen Rollen werden von Automod komplett ignoriert (alle Filter).</p>
<div className="flex flex-wrap gap-2">
{whitelistRoles.length ? whitelistRoles.map((roleId) => {
const role = roles.find((r) => r.id === roleId);

View File

@@ -1,14 +1,30 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Tabs, Tab, Separator, TextField, Label } from '@heroui/react';
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText } from 'lucide-react';
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText, History, MessageSquare } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
accepted: 'Akzeptiert',
invited: 'Zum Gespräch eingeladen',
rejected: 'Abgelehnt',
};
const STATUS_COLORS: Record<string, 'success' | 'danger' | 'warning' | 'accent' | 'default'> = {
pending: 'warning',
accepted: 'success',
invited: 'accent',
rejected: 'danger',
};
export function Register() {
const {
registerForms, registerApps, registerTab, setRegisterTab,
formDraft, setFormDraft, editingFormId, setEditingFormId,
saveForm, deleteForm, sendFormPanel
saveForm, deleteForm, sendFormPanel,
registerStatusFilter, registerFormFilter, setRegisterStatusFilter, setRegisterFormFilter, loadRegisterApps,
selectedAppId, openAppDetail, appHistory, appNotes, noteDraft, setNoteDraft, addAppNote
} = useApp();
return (
@@ -118,17 +134,47 @@ export function Register() {
{registerTab === 'apps' && (
<div className="mt-5">
<div className="mb-4 flex flex-wrap items-end gap-2">
<TextField className="w-56">
<Label className="text-xs">Formular/Position</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={registerFormFilter}
onChange={(e) => { setRegisterFormFilter(e.target.value); loadRegisterApps({ formId: e.target.value }); }}
>
<option value="">Alle</option>
{registerForms.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
</select>
</TextField>
<TextField className="w-48">
<Label className="text-xs">Status</Label>
<select
className="w-full rounded-xl px-3 py-2 text-sm"
value={registerStatusFilter}
onChange={(e) => { setRegisterStatusFilter(e.target.value); loadRegisterApps({ status: e.target.value }); }}
>
<option value="">Alle</option>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</TextField>
</div>
<h3 className="mb-3 text-base font-semibold">Eingegangene Anträge ({registerApps.length})</h3>
<div className="space-y-3">
{registerApps.length ? registerApps.map((app) => (
<Card key={app.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<div className="font-semibold">{app.username || app.userId}</div>
<Chip size="sm" variant="soft" color={app.status === 'approved' ? 'success' : app.status === 'rejected' ? 'danger' : 'warning'}>
{app.status}
<button type="button" className="flex items-center justify-between gap-2 text-left" onClick={() => openAppDetail(app.id)}>
<div className="min-w-0">
<div className="font-semibold truncate">{app.username || app.userId}</div>
<div className="text-xs text-muted truncate">{app.form?.name || 'Formular'}</div>
</div>
<Chip size="sm" variant="soft" color={STATUS_COLORS[app.status] || 'default'}>
{STATUS_LABELS[app.status] || app.status}
</Chip>
</div>
</button>
<div className="text-xs text-muted">{formatDate(app.createdAt)}</div>
{app.answers?.length ? (
<div className="space-y-1">
@@ -140,6 +186,45 @@ export function Register() {
))}
</div>
) : null}
{selectedAppId === app.id && (
<div className="mt-2 grid gap-4 border-t border-border pt-3 sm:grid-cols-2">
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><History size={14} /> Bisherige Bewerbungen</h4>
{appHistory.length ? (
<div className="space-y-1.5">
{appHistory.map((h) => (
<div key={h.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">{h.form?.name || 'Formular'} · {formatDate(h.createdAt)}</span>
<Chip size="sm" variant="soft" color={STATUS_COLORS[h.status] || 'default'}>{STATUS_LABELS[h.status] || h.status}</Chip>
</div>
))}
</div>
) : <p className="text-xs text-muted">Keine weiteren Bewerbungen dieses Nutzers</p>}
</div>
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><MessageSquare size={14} /> Interne Notizen</h4>
<div className="space-y-2">
{appNotes.length ? appNotes.map((n) => (
<div key={n.id} className="rounded-lg bg-surface-secondary p-2 text-xs">
<div className="mb-1 flex items-center justify-between text-muted">
<span className="font-medium text-default">{n.authorTag}</span>
<span>{formatDate(n.createdAt)}</span>
</div>
{n.body}
</div>
)) : <p className="text-xs text-muted">Keine Notizen</p>}
</div>
<TextArea
className="mt-2" rows={2} placeholder="Interne Notiz hinzufügen..."
value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)}
/>
<Button size="sm" variant="tertiary" className="mt-2" isDisabled={!noteDraft.trim()} onPress={addAppNote}>
Notiz speichern
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)) : (

View File

@@ -135,8 +135,9 @@ export type RegisterApplication = {
formId: string;
userId: string;
username?: string;
status: 'pending' | 'approved' | 'rejected';
status: 'pending' | 'accepted' | 'invited' | 'rejected';
answers: { fieldId?: string; label?: string; value: string }[];
form?: RegisterForm;
createdAt?: string;
};