add dashboard pages for permission scanner, info panels, and ticket categories
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m11s
SonarQube / sonar (push) Successful in 0s

Bring the command-only permission scanner, info panel builder, and ticket
role/template config to the dashboard. Also fixes /settings silently
dropping brandingConfig/partnerConfig/galleryConfig/imageModerationConfig/
ticketConfig/lockdownConfig on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 01:28:18 +02:00
parent 9943254aa4
commit 3ac8d9460d
9 changed files with 577 additions and 20 deletions

View File

@@ -22,6 +22,8 @@ import { Watchlist } from './pages/Watchlist';
import { Admin } from './pages/Admin';
import { Branding } from './pages/Branding';
import { Growth } from './pages/Growth';
import { Permissions } from './pages/Permissions';
import { Panels } from './pages/Panels';
const THEME_COLORS: Record<string, string> = {
orange: '#f97316',
@@ -68,6 +70,8 @@ function AppContent() {
case 'watchlist': return <Watchlist />;
case 'branding': return <Branding />;
case 'growth': return <Growth />;
case 'permissions': return <Permissions />;
case 'panels': return <Panels />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}

View File

@@ -6,7 +6,7 @@ import {
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
Shield, ShieldAlert, Sparkles, Tag, Ticket, TrendingUp, LayoutPanelTop, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar';
@@ -25,6 +25,7 @@ const navGroups = [
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'panels', label: 'Info-Panels', icon: <LayoutPanelTop size={18} /> },
]
},
{
@@ -39,6 +40,7 @@ const navGroups = [
label: 'Moderation',
items: [
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'permissions', label: 'Rechte-Scanner', icon: <ShieldAlert size={18} /> },
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
]

View File

@@ -5,7 +5,8 @@ import type {
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
InviteBreakdownEntry, RecentJoinEntry
InviteBreakdownEntry, RecentJoinEntry, PermissionScanResult, InfoPanel, InfoPanelType,
TicketTopicConfig
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -51,6 +52,12 @@ type AppState = {
growthStats: GrowthStats | null;
inviteBreakdown: InviteBreakdownEntry[];
recentJoins: RecentJoinEntry[];
permissionScan: PermissionScanResult | null;
permissionScanLoading: boolean;
infoPanels: InfoPanel[];
panelDraft: { type: InfoPanelType; channelId: string; title: string; description: string; items: string };
ticketTopics: Record<string, TicketTopicConfig>;
ticketTopicDraft: { topic: string; roleId: string; questions: string };
};
type AppContextType = AppState & {
@@ -135,6 +142,14 @@ type AppContextType = AppState & {
removeFromWatchlist: (userId: string) => Promise<void>;
loadGrowthStats: () => Promise<void>;
loadInviteBreakdown: () => Promise<void>;
loadPermissionScan: () => Promise<void>;
loadInfoPanels: () => Promise<void>;
setPanelDraft: (s: any | ((prev: any) => any)) => void;
createInfoPanel: () => Promise<void>;
deleteInfoPanel: (id: string) => Promise<void>;
loadTicketTopics: () => Promise<void>;
setTicketTopicDraft: (s: any | ((prev: any) => any)) => void;
saveTicketTopic: () => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(null);
@@ -196,6 +211,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
const [permissionScan, setPermissionScan] = useState<PermissionScanResult | null>(null);
const [permissionScanLoading, setPermissionScanLoading] = useState(false);
const [infoPanels, setInfoPanels] = useState<InfoPanel[]>([]);
const [panelDraft, setPanelDraft] = useState<{ type: InfoPanelType; channelId: string; title: string; description: string; items: string }>({ type: 'rules', channelId: '', title: '', description: '', items: '' });
const [ticketTopics, setTicketTopics] = useState<Record<string, TicketTopicConfig>>({});
const [ticketTopicDraft, setTicketTopicDraft] = useState({ topic: '', roleId: '', questions: '' });
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
@@ -204,7 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const hash = window.location.hash.replace('#', '') as NavKey;
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'admin'];
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'tasks', 'watchlist', 'branding', 'growth', 'permissions', 'panels', 'admin'];
if (validKeys.includes(hash)) setSectionState(hash);
}, []);
@@ -618,6 +639,64 @@ export function AppProvider({ children }: { children: ReactNode }) {
setRecentJoins(res.recentJoins || []);
}
async function loadPermissionScan() {
if (!currentGuildId) return;
setPermissionScanLoading(true);
try {
const res = await apiFetch<any>(`/permissions/scan?guildId=${encodeURIComponent(currentGuildId)}`);
setPermissionScan(res.result || null);
} finally {
setPermissionScanLoading(false);
}
}
async function loadInfoPanels() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/panels?guildId=${encodeURIComponent(currentGuildId)}`);
setInfoPanels(res.panels || []);
}
async function createInfoPanel() {
if (!currentGuildId || !panelDraft.channelId || !panelDraft.title) return;
const items = panelDraft.type === 'faq'
? panelDraft.items.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 25).map((line) => {
const [q, ...rest] = line.split(':');
return { question: (q || '').trim(), answer: rest.join(':').trim() || 'Keine Antwort hinterlegt.' };
}).filter((i) => i.question)
: undefined;
await apiFetch('/panels', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, channelId: panelDraft.channelId, type: panelDraft.type, title: panelDraft.title, description: panelDraft.description || undefined, items })
});
setPanelDraft({ type: 'rules', channelId: '', title: '', description: '', items: '' });
setStatusMessage('Panel wurde gepostet');
await loadInfoPanels();
}
async function deleteInfoPanel(id: string) {
await apiFetch(`/panels/${id}?guildId=${encodeURIComponent(currentGuildId)}`, { method: 'DELETE' });
setStatusMessage('Panel gelöscht');
await loadInfoPanels();
}
async function loadTicketTopics() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/ticketconfig?guildId=${encodeURIComponent(currentGuildId)}`);
setTicketTopics(res.topics || {});
}
async function saveTicketTopic() {
if (!currentGuildId || !ticketTopicDraft.topic.trim()) return;
const questions = ticketTopicDraft.questions.split('\n').map((q) => q.trim()).filter(Boolean).slice(0, 5);
await apiFetch('/ticketconfig', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, topic: ticketTopicDraft.topic.trim().toLowerCase(), roleId: ticketTopicDraft.roleId || undefined, questions })
});
setTicketTopicDraft({ topic: '', roleId: '', questions: '' });
setStatusMessage('Ticket-Kategorie gespeichert');
await loadTicketTopics();
}
const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
@@ -633,7 +712,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
inviteBreakdown, recentJoins,
inviteBreakdown, recentJoins, permissionScan, permissionScanLoading, infoPanels,
panelDraft, ticketTopics, ticketTopicDraft,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
@@ -650,6 +730,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
loadPermissionScan, loadInfoPanels, setPanelDraft, createInfoPanel, deleteInfoPanel,
loadTicketTopics, setTicketTopicDraft, saveTicketTopic,
}}>
{children}
</AppContext.Provider>

View File

@@ -0,0 +1,128 @@
import { Card, CardContent, CardHeader, Chip, Button, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
import { LayoutPanelTop, Trash2, Send, ScrollText, LifeBuoy, FileSignature, Handshake, Tags, CalendarDays, CircleHelp } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { useGuildResources } from '../hooks/useGuildResources';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { EmptyState } from '../components/shared/EmptyState';
import type { InfoPanelType } from '../types';
import { useEffect } from 'react';
const TYPE_META: Record<InfoPanelType, { label: string; icon: React.ReactNode }> = {
rules: { label: 'Regeln', icon: <ScrollText size={14} /> },
support: { label: 'Support', icon: <LifeBuoy size={14} /> },
bewerbung: { label: 'Bewerbungen', icon: <FileSignature size={14} /> },
partner: { label: 'Partner', icon: <Handshake size={14} /> },
rollen: { label: 'Rollen', icon: <Tags size={14} /> },
events: { label: 'Events', icon: <CalendarDays size={14} /> },
faq: { label: 'FAQ', icon: <CircleHelp size={14} /> },
};
export function Panels() {
const { currentGuildId, infoPanels, loadInfoPanels, panelDraft, setPanelDraft, createInfoPanel, deleteInfoPanel } = useApp();
const { channels } = useGuildResources(currentGuildId);
useEffect(() => {
if (currentGuildId) loadInfoPanels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
return (
<SectionCard title="Info-Panels" subtitle="Feste Panels für Regeln, Support, Bewerbungen, Partner, Rollen, Events und FAQ mit passenden Buttons.">
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Bestehende Panels ({infoPanels.length})</h3>
<div className="space-y-3">
{infoPanels.length ? infoPanels.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
{TYPE_META[p.type]?.icon || <LayoutPanelTop size={14} />}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{p.title}</div>
<div className="text-xs text-muted">#{channels.find((c) => c.id === p.channelId)?.name || p.channelId}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft">{TYPE_META[p.type]?.label || p.type}</Chip>
<Button size="sm" variant="danger-soft" onPress={() => deleteInfoPanel(p.id)}>
<Trash2 size={14} />
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Noch keine Panels erstellt" icon={<LayoutPanelTop size={24} />} />}
</div>
</div>
<div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neues Panel erstellen</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Typ</Label>
<select
aria-label="Panel-Typ"
className="w-full rounded-xl text-sm"
value={panelDraft.type}
onChange={(e) => setPanelDraft((s: any) => ({ ...s, type: e.target.value as InfoPanelType }))}
>
{Object.entries(TYPE_META).map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
</TextField>
<TextField>
<Label>Ziel-Kanal</Label>
<ChannelSelect options={channels} value={panelDraft.channelId} onChange={(id) => setPanelDraft((s: any) => ({ ...s, channelId: id }))} />
</TextField>
<TextField>
<Label>Titel</Label>
<Input value={panelDraft.title} onChange={(e) => setPanelDraft((s: any) => ({ ...s, title: e.target.value }))} />
</TextField>
{panelDraft.type !== 'faq' && (
<TextField>
<Label>Beschreibung</Label>
<TextArea rows={4} value={panelDraft.description} onChange={(e) => setPanelDraft((s: any) => ({ ...s, description: e.target.value }))} />
</TextField>
)}
{panelDraft.type === 'faq' && (
<TextField>
<Label>Fragen (eine Frage: Antwort pro Zeile)</Label>
<TextArea
rows={5}
placeholder="Wie erstelle ich ein Ticket?: Klicke auf den Support-Button."
value={panelDraft.items}
onChange={(e) => setPanelDraft((s: any) => ({ ...s, items: e.target.value }))}
/>
</TextField>
)}
<Separator />
<p className="text-xs text-muted">
{panelDraft.type === 'support' && 'Erhält automatisch einen Ticket-Button.'}
{panelDraft.type === 'bewerbung' && 'Erhält automatisch einen Bewerben-Button, falls ein aktives Formular existiert.'}
{panelDraft.type === 'partner' && 'Erhält automatisch einen Partner-werden-Button.'}
{panelDraft.type === 'faq' && 'Erhält ein Dropdown mit den hinterlegten Fragen.'}
{['rules', 'rollen', 'events'].includes(panelDraft.type) && 'Reines Info-Embed ohne Button.'}
</p>
<Button variant="primary" onPress={createInfoPanel} isDisabled={!panelDraft.channelId || !panelDraft.title}>
<Send size={16} /> Panel posten
</Button>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,125 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
import { ShieldAlert, ShieldX, Bot, Globe, MessageSquare, Users, Copy, Ban, RefreshCw, TriangleAlert } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
function EntryList({ entries, emptyMessage, icon }: { entries: { id: string; name: string }[]; emptyMessage: string; icon: React.ReactNode }) {
if (!entries.length) return <p className="py-3 text-center text-xs text-muted">{emptyMessage}</p>;
return (
<div className="flex flex-col gap-1.5">
{entries.slice(0, 10).map((e) => (
<div key={e.id} className="bg-surface-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
{icon}
<span className="truncate">{e.name}</span>
</div>
))}
{entries.length > 10 && <p className="pl-1 text-xs text-muted">+{entries.length - 10} weitere</p>}
</div>
);
}
export function Permissions() {
const { currentGuildId, permissionScan, permissionScanLoading, loadPermissionScan } = useApp();
useEffect(() => {
if (currentGuildId) loadPermissionScan();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const r = permissionScan;
return (
<SectionCard
title="Rechte-Scanner"
subtitle="Sicherheitsanalyse der Server-Rollen, Rechte und Kanäle."
action={
<Button variant="primary" onPress={() => loadPermissionScan()} isDisabled={permissionScanLoading}>
<RefreshCw size={16} className={permissionScanLoading ? 'animate-spin' : ''} /> Scan starten
</Button>
}
>
{!r && !permissionScanLoading && (
<EmptyState message="Noch kein Scan durchgeführt. Klicke auf „Scan starten“." icon={<ShieldAlert size={24} />} />
)}
{r && (
<div className="flex flex-col gap-5">
{r.tooManyAdmins && (
<Card className="border-danger bg-danger-soft">
<CardContent className="flex items-center gap-3 p-4 text-sm">
<TriangleAlert size={18} className="shrink-0 text-danger" />
<span>Auffällig viele Administrator-Rechte vergeben ({r.adminRoles.length} Rollen, {r.adminMemberCount} Mitglieder mit Adminrechten). Prüfe, ob das notwendig ist.</span>
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<ShieldAlert size={18} />} label="Admin-Rollen" value={r.adminRoles.length} color={r.tooManyAdmins ? 'danger' : 'default'} />
<StatCard icon={<Ban size={18} />} label="Ban-Rollen" value={r.banRoles.length} />
<StatCard icon={<Bot size={18} />} label="Bots mit gefährlichen Rechten" value={r.dangerousBots.length} color={r.dangerousBots.length ? 'warning' : 'default'} />
<StatCard icon={<Globe size={18} />} label="Öffentliche Kanäle" value={r.publicChannels.length} />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Admin-Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.adminRoles} emptyMessage="Keine Admin-Rollen" icon={<ShieldAlert size={14} className="text-danger shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Rollen, die bannen können</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.banRoles} emptyMessage="Keine Ban-Rollen" icon={<Ban size={14} className="text-warning shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Bots mit gefährlichen Rechten</h3></CardHeader>
<CardContent className="p-5">
{r.dangerousBots.length ? (
<div className="flex flex-col gap-1.5">
{r.dangerousBots.slice(0, 10).map((b) => (
<div key={b.id} className="bg-surface-tertiary flex flex-col gap-1 rounded-lg px-3 py-2 text-sm">
<div className="flex items-center gap-2"><Bot size={14} className="text-muted shrink-0" /><span className="truncate font-medium">{b.tag}</span></div>
<div className="flex flex-wrap gap-1 pl-5">
{b.perms.map((p) => <Chip key={p} size="sm" variant="soft" color="warning">{p}</Chip>)}
</div>
</div>
))}
</div>
) : <p className="py-3 text-center text-xs text-muted">Keine Bots mit gefährlichen Rechten</p>}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">@everyone kann schreiben</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.everyoneCanSendChannels} emptyMessage="Kein Kanal betroffen" icon={<MessageSquare size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Leere Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.emptyRoles} emptyMessage="Keine leeren Rollen" icon={<Users size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Nutzlose Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.uselessRoles} emptyMessage="Keine nutzlosen Rollen" icon={<ShieldX size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card className="xl:col-span-2">
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Doppelte Rollen (identische Rechte)</h3></CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{r.duplicateRoleGroups.length ? r.duplicateRoleGroups.map((group, i) => (
<div key={i} className="bg-surface-tertiary flex flex-wrap items-center gap-2 rounded-lg px-3 py-2 text-sm">
<Copy size={14} className="text-muted shrink-0" />
{group.map((role) => <Chip key={role.id} size="sm" variant="soft">{role.name}</Chip>)}
</div>
)) : <p className="py-3 text-center text-xs text-muted">Keine doppelten Rollen gefunden</p>}
</CardContent>
</Card>
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,22 +1,31 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight } from 'lucide-react';
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight, Tag, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { formatDate } from '../utils/formatters';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
import { TicketKanban } from '../components/shared/TicketKanban';
import { RoleSelect } from '../components/shared/RoleSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function Tickets() {
const {
tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
currentGuildId, tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
updateAutomation, deleteAutomation, overview
updateAutomation, deleteAutomation, overview,
ticketTopics, loadTicketTopics, ticketTopicDraft, setTicketTopicDraft, saveTicketTopic
} = useApp();
const { roles } = useGuildResources(currentGuildId);
useEffect(() => {
if (currentGuildId) loadTicketTopics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
@@ -30,6 +39,7 @@ export function Tickets() {
<Tab id="sla">SLA</Tab>
<Tab id="automations">Automationen</Tab>
<Tab id="kb">Knowledge Base</Tab>
<Tab id="kategorien">Kategorien</Tab>
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
@@ -350,6 +360,85 @@ export function Tickets() {
</div>
</div>
)}
{ticketTab === 'kategorien' && (
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Konfigurierte Kategorien ({Object.keys(ticketTopics).length})</h3>
<p className="mb-3 text-xs text-muted">
Der Kategorie-Slug entspricht dem Ticket-Grund (z.B. <code>ban</code>, <code>help</code>, <code>feedback</code>, <code>other</code>). Ohne Konfiguration bleibt das bisherige Verhalten unverändert.
</p>
<div className="space-y-3">
{Object.keys(ticketTopics).length ? Object.entries(ticketTopics).map(([topic, cfg]) => (
<Card key={topic}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
<Tag size={14} />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{topic}</div>
<div className="flex items-center gap-1 text-xs text-muted">
<Users size={12} />
{cfg.roleId ? roles.find((r) => r.id === cfg.roleId)?.name || cfg.roleId : 'Standard-Support-Rolle'}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft">{cfg.questions?.length ?? 0} Fragen</Chip>
<Button
size="sm"
variant="tertiary"
onPress={() => setTicketTopicDraft({ topic, roleId: cfg.roleId || '', questions: (cfg.questions || []).join('\n') })}
>
<Pencil size={14} /> Bearbeiten
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Noch keine Kategorien konfiguriert" icon={<Tag size={24} />} />}
</div>
</div>
<div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Kategorie {ticketTopicDraft.topic ? 'bearbeiten' : 'anlegen'}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Kategorie-Slug</Label>
<Input
placeholder="z.B. help, ban, feedback, other"
value={ticketTopicDraft.topic}
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, topic: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Rolle, die gepingt wird</Label>
<RoleSelect options={roles} value={ticketTopicDraft.roleId} onChange={(id) => setTicketTopicDraft((s: any) => ({ ...s, roleId: id }))} placeholder="Standard-Support-Rolle" />
</TextField>
<TextField>
<Label>Fragen-Vorlage (eine pro Zeile, max. 5)</Label>
<TextArea
rows={5}
placeholder={'Was ist passiert?\nWann ist es aufgetreten?\nHast du einen Screenshot?'}
value={ticketTopicDraft.questions}
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, questions: e.target.value }))}
/>
</TextField>
<p className="text-xs text-muted">Wenn Fragen hinterlegt sind, öffnet sich beim Ticket-Erstellen ein Formular statt sofort ein Kanal zu erstellen.</p>
<div className="flex gap-2">
<Button variant="primary" onPress={saveTicketTopic} isDisabled={!ticketTopicDraft.topic.trim()}>Speichern</Button>
{ticketTopicDraft.topic && (
<Button variant="tertiary" onPress={() => setTicketTopicDraft({ topic: '', roleId: '', questions: '' })}>Abbrechen</Button>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -38,6 +38,8 @@ export type NavKey =
| 'watchlist'
| 'branding'
| 'growth'
| 'permissions'
| 'panels'
| 'admin';
export type TicketRecord = {
@@ -196,6 +198,38 @@ export type RecentJoinEntry = {
createdAt: string;
};
export type PermissionScanResult = {
adminRoles: { id: string; name: string }[];
banRoles: { id: string; name: string }[];
dangerousBots: { id: string; tag: string; perms: string[] }[];
publicChannels: { id: string; name: string }[];
everyoneCanSendChannels: { id: string; name: string }[];
emptyRoles: { id: string; name: string }[];
duplicateRoleGroups: { id: string; name: string }[][];
uselessRoles: { id: string; name: string }[];
tooManyAdmins: boolean;
adminMemberCount: number;
};
export type InfoPanelType = 'rules' | 'support' | 'bewerbung' | 'partner' | 'rollen' | 'events' | 'faq';
export type InfoPanel = {
id: string;
guildId: string;
channelId: string;
messageId?: string | null;
type: InfoPanelType;
title: string;
description?: string | null;
items?: { question: string; answer: string }[] | null;
createdAt?: string;
};
export type TicketTopicConfig = {
roleId?: string;
questions?: string[];
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;

View File

@@ -4,6 +4,7 @@ import {
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
Client,
EmbedBuilder,
ModalBuilder,
ModalSubmitInteraction,
@@ -47,12 +48,6 @@ export class InfoPanelService {
if (!interaction.customId.startsWith('panel:create:') || !interaction.guildId || !interaction.guild) return;
const [, , type, channelId] = interaction.customId.split(':') as [string, string, PanelType, string];
const channel = await interaction.guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
return;
}
const title = interaction.fields.getTextInputValue('title');
const description = interaction.fields.getTextInputValue('description') || undefined;
let items: FaqItem[] | undefined;
@@ -70,15 +65,41 @@ export class InfoPanelService {
.filter((i) => i.question);
}
try {
const panel = await this.createPanel(interaction.guildId, channelId, type, title, description, items, interaction.client);
await interaction.reply({ content: `Panel wurde in <#${panel.channelId}> gepostet.`, ephemeral: true });
} catch {
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
}
}
public async createPanel(
guildId: string,
channelId: string,
type: PanelType,
title: string,
description: string | undefined,
items: FaqItem[] | undefined,
client: Client
) {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) throw new Error('channel not found');
const panel = await prisma.infoPanel.create({
data: { guildId: interaction.guildId, channelId, type, title, description, items: (items as any) ?? undefined }
data: { guildId, channelId, type, title, description, items: (items as any) ?? undefined }
});
const { embed, components } = await this.render(panel.id, type, title, description, items, interaction.guildId);
const { embed, components } = await this.render(panel.id, type, title, description, items, guildId);
const sent = await (channel as any).send({ embeds: [embed], components });
await prisma.infoPanel.update({ where: { id: panel.id }, data: { messageId: sent.id } });
return prisma.infoPanel.update({ where: { id: panel.id }, data: { messageId: sent.id } });
}
await interaction.reply({ content: `Panel wurde in ${channel} gepostet.`, ephemeral: true });
public async listPanels(guildId: string) {
return prisma.infoPanel.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' } });
}
public async deletePanel(guildId: string, id: string) {
await prisma.infoPanel.deleteMany({ where: { id, guildId } });
}
public async handleComponent(interaction: ButtonInteraction | StringSelectMenuInteraction) {

View File

@@ -111,6 +111,66 @@ router.get('/growth/invites', requireAuth, async (req, res) => {
res.json({ breakdown, recentJoins });
});
router.get('/permissions/scan', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const guild = context.client?.guilds.cache.get(guildId) || (await context.client?.guilds.fetch(guildId).catch(() => null));
if (!guild) return res.status(404).json({ error: 'guild not found' });
const result = await context.permissionScan.scan(guild);
res.json({ result });
});
router.get('/panels', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const panels = await context.infoPanels.listPanels(guildId);
res.json({ panels });
});
router.post('/panels', requireAuth, async (req, res) => {
const { guildId, channelId, type, title, description, items } = req.body || {};
if (!guildId || !channelId || !type || !title) return res.status(400).json({ error: 'guildId, channelId, type, title required' });
if (!context.client) return res.status(503).json({ error: 'bot not ready' });
try {
const panel = await context.infoPanels.createPanel(guildId, channelId, type, title, description || undefined, items || undefined, context.client);
res.json({ panel });
} catch {
res.status(404).json({ error: 'Zielkanal nicht gefunden' });
}
});
router.delete('/panels/:id', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.infoPanels.deletePanel(guildId, req.params.id);
res.json({ ok: true });
});
router.get('/ticketconfig', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const topics = settingsStore.get(guildId)?.ticketConfig?.topics || {};
res.json({ topics });
});
router.post('/ticketconfig', requireAuth, async (req, res) => {
const { guildId, topic, roleId, questions } = req.body || {};
if (!guildId || !topic) return res.status(400).json({ error: 'guildId, topic required' });
const key = String(topic).trim().toLowerCase();
const current = settingsStore.get(guildId)?.ticketConfig?.topics?.[key] || {};
await settingsStore.set(guildId, {
ticketConfig: {
topics: {
[key]: {
roleId: roleId !== undefined ? (roleId || undefined) : current.roleId,
questions: Array.isArray(questions) ? questions.filter(Boolean).slice(0, 5) : current.questions
}
}
}
});
res.json({ ok: true });
});
router.get('/guild/activity', requireAuth, (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
@@ -968,7 +1028,13 @@ router.post('/settings', requireAuth, async (req, res) => {
registerEnabled,
registerConfig,
serverStatsEnabled,
serverStatsConfig
serverStatsConfig,
lockdownConfig,
brandingConfig,
partnerConfig,
galleryConfig,
imageModerationConfig,
ticketConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
@@ -1106,7 +1172,13 @@ router.post('/settings', requireAuth, async (req, res) => {
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
serverStatsConfig: serverStatsConfig,
lockdownConfig: lockdownConfig ?? (current as any).lockdownConfig,
brandingConfig: brandingConfig ?? (current as any).brandingConfig,
partnerConfig: partnerConfig ?? (current as any).partnerConfig,
galleryConfig: galleryConfig ?? (current as any).galleryConfig,
imageModerationConfig: imageModerationConfig ?? (current as any).imageModerationConfig,
ticketConfig: ticketConfig ?? (current as any).ticketConfig
});
// Live update logging target
context.logging = new LoggingService(updated.logChannelId);