diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts
index f48b166..ef123c9 100644
--- a/apps/api/src/modules/discord/index.ts
+++ b/apps/api/src/modules/discord/index.ts
@@ -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)); }
+ });
},
};
diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx
index d28cb5b..49692c8 100644
--- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx
+++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx
@@ -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 (Anleitung: Discord-Bot einrichten (aufklappen)
bot und applications.commands 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./kc-status (Systemzustand) und /kc-kunde (Kundensuche) nutzen dürfen.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.{redirectUri}/kc-status und /kc-kunde nutzen dürfen.
Wird geladen …
; + const redirectUri = typeof window !== 'undefined' ? `${window.location.origin}/api/discord/oauth/callback` : ''; return (<>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.
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“.
+Schützen Sie Ihr Konto zusätzlich mit einem Code aus einer Authenticator-App.
>} {!me.mfaEnrollRequired && (<> +Wird geladen …
: discord.linked ? (<> +Verknüpft als {discord.username}. Tickets sind bei Bedarf auch über Discord einsehbar.
+ + >) : discord.available ? (<> +Discord-Konto verknüpfen, um Tickets auch dort zu sehen und zu beantworten.
+ Mit Discord verbinden + >) :Diese Funktion ist derzeit nicht verfügbar.
} +