diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts index ef123c9..9406a8e 100644 --- a/apps/api/src/modules/discord/index.ts +++ b/apps/api/src/modules/discord/index.ts @@ -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); diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx index 9a178b1..9483f04 100644 --- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -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 }) {
  • Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands 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.
  • Kunden-Anmeldung (OAuth) einrichten: Im Reiter „OAuth2 → General“ die Client ID und (unter „Reset Secret“) das Client Secret kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: {redirectUri}
  • IDs ermitteln: 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.
  • -
  • Hier eintragen: 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 /kc-status und /kc-kunde nutzen dürfen.
  • -
  • Verfügbare Befehle: Kunden mit verknüpftem Konto können mit /ticket direkt ein neues Ticket eröffnen. Personal kann innerhalb eines Ticket-Threads mit /zuweisen und /schliessen das Ticket zuweisen bzw. schließen – dafür muss die jeweilige Person ihr eigenes Discord-Konto ebenfalls unter „Mein Konto“ verknüpft haben.
  • -
  • Saubere Trennung: Kunden sehen und nutzen ausschließlich /ticket 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.
  • +
  • Hier eintragen: Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.
  • +
  • Wer darf was: 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 /ticket ein neues Ticket eröffnen. Personal-Konten (Mitarbeiter, die sich ebenfalls unter „Mein Konto“ verknüpfen) dürfen zusätzlich /kc-status, /kc-kunde sowie innerhalb eines Ticket-Threads /zuweisen und /schliessen nutzen.
  • +
  • Saubere Trennung: Kunden sehen und nutzen ausschließlich /ticket 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.
  • ); } @@ -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() {

    Bot-Verbindung

    -

    Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal und beantwortet die Befehle /kc-status und /kc-kunde für die unten eingetragenen Personen.

    +

    Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal. Wer /kc-status, /kc-kunde, /zuweisen und /schliessen nutzen darf, richtet sich allein nach der Discord-Verknüpfung des jeweiligen Personal-Kontos (siehe unten).

    {!s.configured && Noch kein Token gespeichert.} {s.configured && !s.enabled && Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.} {s.lastError && Letzte Störung: {s.lastError}} @@ -70,15 +69,14 @@ export default function DiscordSettings() { - - +

    Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

    -

    Kunden-Anmeldung (OAuth)

    -

    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“.

    +

    Konto-Verknüpfung (OAuth)

    +

    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“.

    diff --git a/apps/worker/src/discord.ts b/apps/worker/src/discord.ts index 23a0677..346e54d 100644 --- a/apps/worker/src/discord.ts +++ b/apps/worker/src/discord.ts @@ -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 { 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, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key }); async function nextTicketNumber(): Promise { @@ -85,9 +85,8 @@ async function handle(i: ChatInputCommandInteraction): Promise { 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'; diff --git a/migrations/029_discord_drop_staff_ids.sql b/migrations/029_discord_drop_staff_ids.sql new file mode 100644 index 0000000..6ed77fa --- /dev/null +++ b/migrations/029_discord_drop_staff_ids.sql @@ -0,0 +1,4 @@ +-- Die manuelle Freigabeliste (Discord-IDs) für /kc-status und /kc-kunde entfällt: alle Personal-Befehle prüfen +-- jetzt einheitlich ein per Discord-OAuth verknüpftes Personal-Konto (users.discord_user_id, kind='staff'), +-- wie schon /zuweisen und /schliessen. Vermeidet zwei parallele, leicht zu verwechselnde Berechtigungswege. +ALTER TABLE discord_settings DROP COLUMN staff_user_ids;