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:
parent
c8da9362a7
commit
fcbd34b0c6
4 changed files with 153 additions and 33 deletions
|
|
@ -2,18 +2,20 @@ import type { FastifyInstance } from 'fastify';
|
|||
import { z } from 'zod';
|
||||
import { one, run } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { encrypt, decrypt } from '../../core/crypto.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
|
||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.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';
|
||||
|
||||
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,
|
||||
});
|
||||
const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`;
|
||||
|
||||
export const discordModule: KcModule = {
|
||||
name: 'discord',
|
||||
|
|
@ -28,13 +30,16 @@ export const discordModule: KcModule = {
|
|||
const b = z.object({
|
||||
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),
|
||||
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
|
||||
}).parse(req.body);
|
||||
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'updated_by = ?'];
|
||||
const params: unknown[] = [b.guildId, b.adminChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, a.user.id];
|
||||
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];
|
||||
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 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 };
|
||||
});
|
||||
/** 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 } });
|
||||
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)); }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
migrations/027_discord_linking.sql
Normal file
23
migrations/027_discord_linking.sql
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue