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

@ -2,18 +2,20 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { one, run } from '../../core/db.js'; import { one, run } from '../../core/db.js';
import { audit } from '../../core/audit.js'; import { audit } from '../../core/audit.js';
import { encrypt, decrypt } from '../../core/crypto.js'; import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
import { clientIp, requirePermission } from '../../core/auth.js'; import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js'; import { badRequest } from '../../core/errors.js';
import { rl } from '../../core/config.js'; import { rl, config } from '../../core/config.js';
import type { KcModule } from '../../core/module.js'; import type { KcModule } from '../../core/module.js';
const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
const settingsView = (s: any) => ({ const settingsView = (s: any) => ({
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id, 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), 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, configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
}); });
const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`;
export const discordModule: KcModule = { export const discordModule: KcModule = {
name: 'discord', name: 'discord',
@ -28,13 +30,16 @@ export const discordModule: KcModule = {
const b = z.object({ const b = z.object({
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen 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(), 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), staffUserIds: z.array(z.string().trim().regex(ID)).max(50), enabled: z.boolean().default(false),
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
}).parse(req.body); }).parse(req.body);
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'updated_by = ?']; 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.staffUserIds.join(','), b.enabled ? 1 : 0, a.user.id]; const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.staffUserIds.join(','), 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.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); await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined } }); await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined, clientSecret: b.clientSecret !== undefined ? (b.clientSecret ? '***' : null) : undefined } });
return { ok: true }; return { ok: true };
}); });
/** Prüft nur den Token gegen die Discord-API (GET /users/@me), ohne den laufenden Bot zu berühren. */ /** Prüft nur den Token gegen die Discord-API (GET /users/@me), ohne den laufenden Bot zu berühren. */
@ -73,5 +78,62 @@ export const discordModule: KcModule = {
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.test_message', resourceType: 'discord_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok } }); await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.test_message', resourceType: 'discord_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok } });
return { ok, error }; return { ok, error };
}); });
// ---- Konto-Verknüpfung (Mein Konto): jeder angemeldete Benutzer darf sein eigenes Discord-Konto verknüpfen ----
app.get('/discord/link', async (req) => {
const a = requireAuth(req);
const [u, s] = await Promise.all([
one('SELECT discord_user_id, discord_username FROM users WHERE id = ?', [a.user.id]),
one('SELECT client_id, client_secret_enc FROM discord_settings WHERE id = 1'),
]);
return { linked: !!u?.discord_user_id, username: u?.discord_username ?? null, available: !!(s?.client_id && s?.client_secret_enc) };
});
app.post('/discord/unlink', async (req) => {
const a = requireAuth(req);
await run('UPDATE users SET discord_user_id = NULL, discord_username = NULL WHERE id = ?', [a.user.id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.unlink', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { ok: true };
});
/** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */
app.get('/discord/oauth/start', async (req, reply) => {
const a = requireAuth(req);
const s = await one('SELECT client_id FROM discord_settings WHERE id = 1');
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
const state = randomToken(32);
await run('DELETE FROM discord_oauth_states WHERE user_id = ?', [a.user.id]); // nur ein offener Versuch je Benutzer
await run('INSERT INTO discord_oauth_states (state, user_id) VALUES (?,?)', [state, a.user.id]);
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', s.client_id); url.searchParams.set('redirect_uri', redirectUri());
url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'identify'); url.searchParams.set('state', state); url.searchParams.set('prompt', 'consent');
return reply.redirect(url.toString());
});
/** Discord leitet den Browser hierher zurück; tauscht den Code gegen die Discord-Nutzer-ID und verknüpft das Konto. */
app.get('/discord/oauth/callback', async (req, reply) => {
const q = z.object({ code: z.string().optional(), state: z.string().optional(), error: z.string().optional() }).parse(req.query);
const fail = (msg: string) => reply.redirect(`${config.baseUrl}/konto?discord=error&msg=${encodeURIComponent(msg)}`);
if (q.error || !q.code || !q.state) return fail(q.error === 'access_denied' ? 'Verknüpfung abgebrochen.' : 'Ungültige Antwort von Discord.');
const st = await one('SELECT user_id FROM discord_oauth_states WHERE state = ? AND created_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE)', [q.state]);
if (!st) return fail('Der Verknüpfungsversuch ist abgelaufen. Bitte erneut starten.');
await run('DELETE FROM discord_oauth_states WHERE state = ?', [q.state]);
const s = await one('SELECT client_id, client_secret_enc FROM discord_settings WHERE id = 1');
if (!s?.client_id || !s.client_secret_enc) return fail('Discord-Anmeldung ist nicht eingerichtet.');
const clientSecret = JSON.parse(decrypt(s.client_secret_enc)).secret as string;
try {
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: s.client_id, client_secret: clientSecret, grant_type: 'authorization_code', code: q.code, redirect_uri: redirectUri() }),
});
if (!tokenRes.ok) throw new Error(`Discord antwortete mit Status ${tokenRes.status}`);
const { access_token } = await tokenRes.json() as { access_token: string };
const meRes = await fetch('https://discord.com/api/v10/users/@me', { headers: { authorization: `Bearer ${access_token}` } });
if (!meRes.ok) throw new Error(`Discord antwortete mit Status ${meRes.status}`);
const me = await meRes.json() as { id: string; username: string };
const other = await one('SELECT id FROM users WHERE discord_user_id = ? AND id <> ?', [me.id, st.user_id]);
if (other) return fail('Dieses Discord-Konto ist bereits mit einem anderen Zugang verknüpft.');
await run('UPDATE users SET discord_user_id = ?, discord_username = ? WHERE id = ?', [me.id, me.username, st.user_id]);
await audit({ actorType: 'user', actorId: st.user_id, action: 'discord.link', resourceType: 'user', resourceId: st.user_id, correlationId: req.correlationId, ip: clientIp(req), after: { discordUsername: me.username } });
return reply.redirect(`${config.baseUrl}/konto?discord=linked`);
} catch (e) { return fail(String((e as Error).message).slice(0, 200)); }
});
}, },
}; };

View file

@ -4,18 +4,19 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui'; 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. */ /** 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"> return (<details className="card">
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Anleitung: Discord-Bot einrichten (aufklappen)</summary> <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 }}> <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>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-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“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</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>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>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>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>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> </ol>
</details>); </details>);
} }
@ -32,11 +33,11 @@ export default function DiscordSettings() {
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null); e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
const form = e.currentTarget; const form = e.currentTarget;
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null); 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); const staffUserIds = String(f.get('staffUserIds') ?? '').split(',').map((x) => x.trim()).filter(Boolean);
try { try {
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), staffUserIds, enabled: f.get('enabled') === 'on', ...(token ? { token } : {}) }); 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 = ''; void load(); 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); } } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
} }
async function testToken() { async function testToken() {
@ -50,31 +51,42 @@ export default function DiscordSettings() {
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setMsgBusy(false); } 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>; 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 (<> return (<>
<h2>Discord</h2> <h2>Discord</h2>
{err && <Alert kind="err">{err}</Alert>} {err && <Alert kind="err">{err}</Alert>}
<Guide /> <Guide redirectUri={redirectUri} />
<div className="card" style={{ marginTop: 16 }}> {msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<h2>Bot-Verbindung</h2> <form onSubmit={save}>
<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> <div className="card" style={{ marginTop: 16 }}>
{!s.configured && <Alert kind="warn">Noch kein Token gespeichert.</Alert>} <h2>Bot-Verbindung</h2>
{s.configured && !s.enabled && <Alert kind="info">Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.</Alert>} <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.lastError && <Alert kind="err">Letzte Störung: {s.lastError}</Alert>} {!s.configured && <Alert kind="warn">Noch kein Token gespeichert.</Alert>}
{msg && <Alert kind={msg.k}>{msg.t}</Alert>} {s.configured && !s.enabled && <Alert kind="info">Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.</Alert>}
<form onSubmit={save}> {s.lastError && <Alert kind="err">Letzte Störung: {s.lastError}</Alert>}
<div className="cols"> <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="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="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="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="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> </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> <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> <p className="small muted">Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>
{s.hasToken && <button className="btn" type="button" disabled={tokenBusy} onClick={testToken}>{tokenBusy ? 'Bitte warten …' : 'Token prüfen'}</button>} </div>
{s.hasToken && s.adminChannelId && <button className="btn" type="button" disabled={msgBusy} onClick={testMessage}>{msgBusy ? 'Bitte warten …' : 'Testnachricht senden'}</button>} <div className="card" style={{ marginTop: 16 }}>
</div>} <h2>Kunden-Anmeldung (OAuth)</h2>
</form> <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>
<p className="small muted" style={{ marginTop: 8 }}>Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p> <div className="cols">
</div> <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'; 'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { api, errMsg } from '@/lib/api'; import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui'; import { Alert, Field, fmt } from '@/components/ui';
interface Sess { id: string; ip: string | null; userAgent: string | null; lastSeenAt: string; current: boolean } 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() { 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 [sessions, setSessions] = useState<Sess[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
const [resetOpen, setResetOpen] = useState(false); 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 [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) })), []); 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; if (!me) return null;
async function startMfa() { 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></>} : <><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> </div>
{!me.mfaEnrollRequired && (<> {!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="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> <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> <Field id="next" label="Neues Passwort" hint="Mindestens 12 Zeichen."><input id="next" name="next" type="password" autoComplete="new-password" minLength={12} required /></Field>

View file

@ -0,0 +1,23 @@
-- Discord-OAuth: Kunden verknüpfen ihr Discord-Konto mit ihrem Kundencenter-Konto (für Ticket-Kanäle je Ticket).
ALTER TABLE discord_settings
ADD COLUMN client_id VARCHAR(32) NULL,
ADD COLUMN client_secret_enc TEXT NULL,
ADD COLUMN ticket_channel_id VARCHAR(32) NULL;
ALTER TABLE users
ADD COLUMN discord_user_id VARCHAR(32) NULL UNIQUE,
ADD COLUMN discord_username VARCHAR(100) NULL;
-- Kurzlebiger Zustand für den OAuth-Redirect (CSRF-Schutz), wird beim erfolgreichen Callback gelöscht.
CREATE TABLE discord_oauth_states (
state CHAR(43) PRIMARY KEY,
user_id CHAR(36) NOT NULL REFERENCES users(id),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Ticket <-> Discord-Thread-Zuordnung.
CREATE TABLE ticket_discord_threads (
ticket_id CHAR(36) PRIMARY KEY REFERENCES tickets(id),
thread_id VARCHAR(32) NOT NULL UNIQUE,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;