refactor(discord): einheitliche Berechtigung über verknüpfte Konten

Nutzerfrage: warum gibt es neben der Kontoverknüpfung noch eine
separate "Erlaubte Nutzer-IDs"-Liste, und woher weiß der Bot, ob
jemand Kunde oder Personal ist? Antwort: das waren zwei parallele,
verwirrende Mechanismen. /kc-status und /kc-kunde prüften bisher eine
manuell gepflegte Discord-ID-Liste (aus der Zeit vor der OAuth-
Verknüpfung), während /zuweisen und /schliessen bereits das verknüpfte
Personal-Konto (users.discord_user_id, kind='staff') prüften.

Jetzt einheitlich: alle vier Personal-Befehle verlangen ein verknüpftes
Personal-Konto, genau wie /zuweisen und /schliessen. Die Liste entfällt
vollständig (Migration 029, Spalte staff_user_ids entfernt) – wer ein
Personal-Konto im Kundencenter hat und es unter "Mein Konto" mit
Discord verknüpft, darf automatisch alle vier Befehle nutzen, ohne
zusätzliche manuelle Pflege einer ID-Liste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 00:12:10 +02:00
parent 8243cdb111
commit 078b1357fb
4 changed files with 20 additions and 20 deletions

View file

@ -11,7 +11,6 @@ import type { KcModule } from '../../core/module.js';
const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
const settingsView = (s: any) => ({
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
staffUserIds: String(s.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean),
clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id,
configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
});
@ -31,11 +30,11 @@ export const discordModule: KcModule = {
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
ticketChannelId: z.string().trim().regex(ID).nullable(),
staffUserIds: z.array(z.string().trim().regex(ID)).max(50), enabled: z.boolean().default(false),
enabled: z.boolean().default(false),
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
}).parse(req.body);
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, b.clientId, a.user.id];
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id];
if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); }
if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); }
await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);

View file

@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; staffUserIds: string[]; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
/** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */
function Guide({ redirectUri }: { redirectUri: string }) {
@ -16,9 +16,9 @@ function Guide({ redirectUri }: { redirectUri: string }) {
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>Kunden-Anmeldung (OAuth) einrichten:</strong> Im Reiter „OAuth2 → General“ die <strong>Client ID</strong> und (unter „Reset Secret“) das <strong>Client Secret</strong> kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: <code className="mono">{redirectUri}</code></li>
<li><strong>IDs ermitteln:</strong> In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, die gewünschten Kanäle und die eigene Person jeweils „ID kopieren“ wählen.</li>
<li><strong>Hier eintragen:</strong> Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern. Unter „Erlaubte Nutzer-IDs“ die Discord-IDs aller Personen eintragen, die <code className="mono">/kc-status</code> und <code className="mono">/kc-kunde</code> nutzen dürfen.</li>
<li><strong>Verfügbare Befehle:</strong> Kunden mit verknüpftem Konto können mit <code className="mono">/ticket</code> direkt ein neues Ticket eröffnen. Personal kann <em>innerhalb eines Ticket-Threads</em> mit <code className="mono">/zuweisen</code> und <code className="mono">/schliessen</code> das Ticket zuweisen bzw. schließen – dafür muss die jeweilige Person ihr eigenes Discord-Konto ebenfalls unter „Mein Konto“ verknüpft haben.</li>
<li><strong>Saubere Trennung:</strong> Kunden sehen und nutzen ausschließlich <code className="mono">/ticket</code> sowie ihren eigenen Ticket-Thread. Die Personal-Befehle sind zusätzlich so eingestellt, dass sie nur Discord-Mitgliedern mit der Server-Berechtigung „Threads verwalten“ überhaupt in der Befehlsliste angezeigt werden – vergeben Sie diese Berechtigung über eine eigene Rolle nur an Ihr Support-Team, nicht an die Kundenrolle.</li>
<li><strong>Hier eintragen:</strong> Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.</li>
<li><strong>Wer darf was:</strong> Es gibt keine separate Liste mehr – maßgeblich ist allein, ob und wie ein Discord-Konto unter „Mein Konto“ mit einem Kundencenter-Zugang verknüpft ist. Kunden mit verknüpftem Konto können mit <code className="mono">/ticket</code> ein neues Ticket eröffnen. Personal-Konten (Mitarbeiter, die sich ebenfalls unter „Mein Konto“ verknüpfen) dürfen zusätzlich <code className="mono">/kc-status</code>, <code className="mono">/kc-kunde</code> sowie <em>innerhalb eines Ticket-Threads</em> <code className="mono">/zuweisen</code> und <code className="mono">/schliessen</code> nutzen.</li>
<li><strong>Saubere Trennung:</strong> Kunden sehen und nutzen ausschließlich <code className="mono">/ticket</code> sowie ihren eigenen Ticket-Thread. Die Personal-Befehle sind zusätzlich so eingestellt, dass sie nur Discord-Mitgliedern mit der Server-Berechtigung „Threads verwalten“ überhaupt in der Befehlsliste angezeigt werden (reine Sichtbarkeit – die eigentliche Prüfung ist immer das verknüpfte Personal-Konto). Vergeben Sie diese Berechtigung über eine eigene Rolle nur an Ihr Support-Team, nicht an die Kundenrolle.</li>
</ol>
</details>);
}
@ -36,9 +36,8 @@ export default function DiscordSettings() {
const form = e.currentTarget;
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? '');
const staffUserIds = String(f.get('staffUserIds') ?? '').split(',').map((x) => x.trim()).filter(Boolean);
try {
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), staffUserIds, enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
@ -62,7 +61,7 @@ export default function DiscordSettings() {
<form onSubmit={save}>
<div className="card" style={{ marginTop: 16 }}>
<h2>Bot-Verbindung</h2>
<p className="muted small">Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal und beantwortet die Befehle <code className="mono">/kc-status</code> und <code className="mono">/kc-kunde</code> für die unten eingetragenen Personen.</p>
<p className="muted small">Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal. Wer <code className="mono">/kc-status</code>, <code className="mono">/kc-kunde</code>, <code className="mono">/zuweisen</code> und <code className="mono">/schliessen</code> nutzen darf, richtet sich allein nach der Discord-Verknüpfung des jeweiligen Personal-Kontos (siehe unten).</p>
{!s.configured && <Alert kind="warn">Noch kein Token gespeichert.</Alert>}
{s.configured && !s.enabled && <Alert kind="info">Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.</Alert>}
{s.lastError && <Alert kind="err">Letzte Störung: {s.lastError}</Alert>}
@ -70,15 +69,14 @@ export default function DiscordSettings() {
<Field id="token" label="Bot-Token" hint={s.hasToken ? 'Ein Token ist gespeichert. Leer lassen, um ihn unverändert zu lassen.' : 'Noch kein Token gespeichert.'}><input id="token" name="token" type="password" autoComplete="new-password" disabled={!w} /></Field>
<Field id="guildId" label="Server-ID (Guild-ID)"><input id="guildId" name="guildId" defaultValue={s.guildId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="adminChannelId" label="Kanal-ID für Systemmeldungen"><input id="adminChannelId" name="adminChannelId" defaultValue={s.adminChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads" hint="Wird für die kommende Ticket-Thread-Funktion benötigt."><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="staffUserIds" label="Erlaubte Nutzer-IDs (kommagetrennt)" hint="Dürfen /kc-status und /kc-kunde nutzen."><input id="staffUserIds" name="staffUserIds" defaultValue={s.staffUserIds.join(', ')} disabled={!w} /></Field>
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads"><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
</div>
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={s.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Bot aktiv</label>
<p className="small muted">Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>
</div>
<div className="card" style={{ marginTop: 16 }}>
<h2>Kunden-Anmeldung (OAuth)</h2>
<p className="muted small">Ermöglicht Kunden, ihr Discord-Konto unter „Mein Konto“ mit ihrem Kundencenter-Zugang zu verknüpfen. Client ID und Secret stehen in derselben Discord-Anwendung wie der Bot, im Reiter „OAuth2 → General“.</p>
<h2>Konto-Verknüpfung (OAuth)</h2>
<p className="muted small">Ermöglicht sowohl Kunden als auch Personal, das eigene Discord-Konto unter „Mein Konto“ mit dem Kundencenter-Zugang zu verknüpfen – davon hängt ab, welche Discord-Befehle jemand nutzen darf. Client ID und Secret stehen in derselben Discord-Anwendung wie der Bot, im Reiter „OAuth2 → General“.</p>
<div className="cols">
<Field id="clientId" label="Client ID"><input id="clientId" name="clientId" defaultValue={s.clientId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="clientSecret" label="Client Secret" hint={s.hasClientSecret ? 'Gespeichert. Leer lassen, um es unverändert zu lassen.' : 'Noch nicht gespeichert.'}><input id="clientSecret" name="clientSecret" type="password" autoComplete="new-password" disabled={!w} /></Field>

View file

@ -4,12 +4,12 @@ import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; staffUserIds: string[] }
interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null }
async function loadSettings(): Promise<DiscordSettings> {
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
const s = rows[0];
const token = s?.token_enc ? (JSON.parse(decrypt(s.token_enc)).token as string) : null;
return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null, staffUserIds: String(s?.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean) };
return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null };
}
const notify = (event: string, extra: Record<string, unknown>, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key });
async function nextTicketNumber(): Promise<string> {
@ -85,9 +85,8 @@ async function handle(i: ChatInputCommandInteraction): Promise<void> {
if (i.commandName === 'ticket') return handleTicketCreate(i);
if (i.commandName === 'zuweisen') return handleAssign(i);
if (i.commandName === 'schliessen') return handleClose(i);
// Alle Antworten ephemeral: nie vertrauliche Daten in Kanälen sichtbar machen.
const s = await loadSettings();
if (!s.staffUserIds.includes(i.user.id)) { await i.reply({ content: 'Keine Berechtigung.', ephemeral: true }); return; }
// Alle Antworten ephemeral: nie vertrauliche Daten in Kanälen sichtbar machen. Berechtigung = verknüpftes Personal-Konto (wie /zuweisen, /schliessen).
if (!(await linkedUser(i.user.id, 'staff'))) { await i.reply({ content: 'Nur für verknüpfte Personal-Konten.', ephemeral: true }); return; }
if (i.commandName === 'kc-status') {
const [rows] = await pool.query('SELECT status, COUNT(*) n FROM jobs GROUP BY status') as any;
const jobs = rows.length ? rows.map((r: any) => `${r.status}: ${r.n}`).join(', ') : 'keine';