feat(discord): Kunden-Kontoverknüpfung per Discord-OAuth

Erster Teil des größeren Discord-Ausbaus (Konto verknüpfen, Tickets
über Discord sehen/beantworten). Dieser Schritt: Kunden können unter
"Mein Konto" ihr Discord-Konto per OAuth verknüpfen/trennen.

- Migration 027: discord_settings um client_id/client_secret_enc/
  ticket_channel_id erweitert; users um discord_user_id/
  discord_username (unique); discord_oauth_states (kurzlebiger
  CSRF-Schutz für den Redirect-Flow, 10 Min. gültig); neue Tabelle
  ticket_discord_threads für den nächsten Schritt (Ticket-Threads).
- Neue Routen: GET/POST /discord/link, /discord/unlink,
  GET /discord/oauth/start (leitet zu Discord weiter),
  GET /discord/oauth/callback (tauscht Code gegen Discord-Nutzer-ID,
  verknüpft das Konto, verhindert doppelte Verknüpfung).
- Einstellungen > Discord: neue Felder für Client ID/Secret, Anzeige
  der einzutragenden Redirect-URI, aktualisierte Anleitung inkl.
  OAuth-Einrichtung und Hinweis auf die Message Content Intent (für
  den nächsten Schritt: Ticket-Threads).

Noch offen (nächster Schritt): Ticket-Threads je Ticket, bidirektionale
Synchronisierung der Nachrichten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 23:41:04 +02:00
parent c8da9362a7
commit fcbd34b0c6
4 changed files with 153 additions and 33 deletions

View file

@ -4,18 +4,19 @@ 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; staffUserIds: string[]; 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; staffUserIds: string[]; 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() {
function Guide({ redirectUri }: { redirectUri: string }) {
return (<details className="card">
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Anleitung: Discord-Bot einrichten (aufklappen)</summary>
<ol className="small" style={{ margin: '16px 0 0', paddingLeft: 20, display: 'grid', gap: 12 }}>
<li><strong>Anwendung anlegen:</strong> Auf <a href="https://discord.com/developers/applications" target="_blank" rel="noreferrer">discord.com/developers/applications</a> auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).</li>
<li><strong>Bot-Token erzeugen:</strong> Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Privilegierte Intents (Message Content etc.) werden nicht benötigt.</li>
<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“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>IDs ermitteln:</strong> In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, den gewünschten Kanal und die eigene Person jeweils „ID kopieren“ wählen.</li>
<li><strong>Hier eintragen:</strong> Token, Server-ID und Kanal-ID (für Systemmeldungen wie Backup-Warnungen oder neue Tickets) unten speichern. Unter „Erlaubte Nutzer-IDs“ die Discord-IDs aller Personen eintragen, die im Server die Befehle <code className="mono">/kc-status</code> (Systemzustand) und <code className="mono">/kc-kunde</code> (Kundensuche) nutzen dürfen.</li>
<li><strong>Bot-Token erzeugen:</strong> Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Dort außerdem die <strong>Message Content Intent</strong> aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).</li>
<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>
</ol>
</details>);
}
@ -32,11 +33,11 @@ export default function DiscordSettings() {
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
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 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'), staffUserIds, enabled: f.get('enabled') === 'on', ...(token ? { token } : {}) });
setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; void load();
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 } : {}) });
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); }
}
async function testToken() {
@ -50,31 +51,42 @@ export default function DiscordSettings() {
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setMsgBusy(false); }
}
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const redirectUri = typeof window !== 'undefined' ? `${window.location.origin}/api/discord/oauth/callback` : '';
return (<>
<h2>Discord</h2>
{err && <Alert kind="err">{err}</Alert>}
<Guide />
<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>
{!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>}
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<form onSubmit={save}>
<Guide redirectUri={redirectUri} />
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<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>
{!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>}
<div className="cols">
<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>
</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>
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button>
{s.hasToken && <button className="btn" type="button" disabled={tokenBusy} onClick={testToken}>{tokenBusy ? 'Bitte warten …' : 'Token prüfen'}</button>}
{s.hasToken && s.adminChannelId && <button className="btn" type="button" disabled={msgBusy} onClick={testMessage}>{msgBusy ? 'Bitte warten …' : 'Testnachricht senden'}</button>}
</div>}
</form>
<p className="small muted" style={{ marginTop: 8 }}>Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>
</div>
<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>
<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>
<Field id="redirectUri" label="Redirect-URI (bei Discord eintragen)"><input id="redirectUri" value={redirectUri} readOnly disabled /></Field>
</div>
</div>
{w && <div className="row" style={{ marginTop: 16 }}><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button>
{s.hasToken && <button className="btn" type="button" disabled={tokenBusy} onClick={testToken}>{tokenBusy ? 'Bitte warten …' : 'Token prüfen'}</button>}
{s.hasToken && s.adminChannelId && <button className="btn" type="button" disabled={msgBusy} onClick={testMessage}>{msgBusy ? 'Bitte warten …' : 'Testnachricht senden'}</button>}
</div>}
</form>
</>);
}

View file

@ -1,18 +1,32 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import QRCode from 'qrcode';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Sess { id: string; ip: string | null; userAgent: string | null; lastSeenAt: string; current: boolean }
interface DiscordLink { linked: boolean; username: string | null; available: boolean }
export default function Konto() {
const { me, reload } = useSession();
const { me, reload } = useSession(); const router = useRouter(); const params = useSearchParams();
const [sessions, setSessions] = useState<Sess[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [setup, setSetup] = useState<{ secret: string; url: string; qr: string } | null>(null); const [codes, setCodes] = useState<string[] | null>(null);
const [discord, setDiscord] = useState<DiscordLink | null>(null); const [discordBusy, setDiscordBusy] = useState(false);
const load = useCallback(() => api<Sess[]>('GET', '/auth/sessions').then(setSessions).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
useEffect(() => { if (me && !me.mfaEnrollRequired) void load(); }, [me, load]);
const loadDiscord = useCallback(() => api<DiscordLink>('GET', '/discord/link').then(setDiscord).catch(() => undefined), []);
useEffect(() => { if (me && !me.mfaEnrollRequired) { void load(); void loadDiscord(); } }, [me, load, loadDiscord]);
useEffect(() => {
const d = params.get('discord');
if (d === 'linked') { setMsg({ k: 'ok', t: 'Discord-Konto verknüpft.' }); void loadDiscord(); router.replace('/konto'); }
else if (d === 'error') { setMsg({ k: 'err', t: `Discord-Verknüpfung fehlgeschlagen: ${params.get('msg') ?? 'Unbekannter Fehler'}` }); router.replace('/konto'); }
}, [params, router, loadDiscord]);
async function unlinkDiscord() {
setDiscordBusy(true);
try { await api('POST', '/discord/unlink'); setMsg({ k: 'ok', t: 'Discord-Konto getrennt.' }); void loadDiscord(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setDiscordBusy(false); }
}
if (!me) return null;
async function startMfa() {
@ -63,6 +77,15 @@ export default function Konto() {
: <><p>Schützen Sie Ihr Konto zusätzlich mit einem Code aus einer Authenticator-App.</p><button className="btn primary" onClick={startMfa}>2FA einrichten</button></>}
</div>
{!me.mfaEnrollRequired && (<>
<div className="card"><h2>Discord</h2>
{discord === null ? <p className="muted small" role="status">Wird geladen …</p> : discord.linked ? (<>
<p><span className="badge ok"><span aria-hidden="true">●</span>Verknüpft</span> als <strong>{discord.username}</strong>. Tickets sind bei Bedarf auch über Discord einsehbar.</p>
<button className="btn small danger" disabled={discordBusy} onClick={unlinkDiscord}>Verknüpfung trennen</button>
</>) : discord.available ? (<>
<p className="muted small">Discord-Konto verknüpfen, um Tickets auch dort zu sehen und zu beantworten.</p>
<a className="btn primary" href="/api/discord/oauth/start">Mit Discord verbinden</a>
</>) : <p className="muted small">Diese Funktion ist derzeit nicht verfügbar.</p>}
</div>
<div className="card"><h2>Passwort ändern</h2><form onSubmit={changePw}>
<div className="cols"><Field id="current" label="Aktuelles Passwort"><input id="current" name="current" type="password" autoComplete="current-password" required /></Field>
<Field id="next" label="Neues Passwort" hint="Mindestens 12 Zeichen."><input id="next" name="next" type="password" autoComplete="new-password" minLength={12} required /></Field>