Linked Roles: Rollen, die an geprueften Werten haengen
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Neues Modul, standardmaessig aus. Ein Mitglied klickt in Discord auf "Rolle
holen", landet auf /linked-roles, bestaetigt den OAuth-Dialog — und der Bot
schiebt ihm drei Kennzahlen zu, die Discord dann gegen die Rollen-Einstellung
prueft:

  level        Zahl >= eingestelltem Wert
  dabei_seit   Datum <= "vor X Tagen", also Mitglied seit mindestens X Tagen
  playtester   Wahrheitswert

Der Bot vergibt dabei keine Rollen. Er liefert nur die Zahlen; was daraus
wird, entscheidet die Rollen-Einstellung im Server. Die Daten kommen aus dem,
was ohnehin da ist: Level-Tabelle, Playtester-Liste, Discords Beitrittsdatum.

Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind —
role_connections.write hat der normale Login nicht.

Die Tokens werden gespeichert, und das ist keine Bequemlichkeit: ohne
Refresh-Token bliebe jede Rolle auf dem Stand des Verknuepfungs-Tages stehen,
ein Level-Up kaeme nie an. Discords eigene Anleitung sagt dasselbe. Der
Umfang ist eng (nur identify + role_connections.write), beim Widerruf oder
Entkoppeln fliegt der Datensatz sofort raus. Steht so auch als Kommentar an
der Tabelle.

Abgeglichen wird alle 6 Stunden, einstellbar. Dafuer kann everyTuned jetzt
auch Stunden — und wirft bei einer unbekannten Einheit, statt den Abstand
still um Faktor 60 oder 3600 danebenzulegen.

Beim Bauen gefunden: moduleEnabled fehlte im Import von client.js. Der Bot
waere beim ClientReady mit ReferenceError gestorben.

Geprueft: alle drei Kennzahlen gegen Discords Formatgrenzen (Schluessel-Regex,
Laengen, gueltige Typ-Codes), Modul- und Stellwert-Register, die
Stunden-Umrechnung (6 h = 21600000 ms), Speichern und Loeschen einer
Verknuepfung, dazu 186 SQL-Abfragen und alle Routen auf Rechtepruefung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 05:57:57 +02:00
co-authored by Claude Fable 5
parent 55e9ef57e2
commit 137452da11
9 changed files with 358 additions and 2 deletions
+2
View File
@@ -291,6 +291,8 @@ export default {
'feat.alpha_keys.desc': 'A pool of keys handed out by direct message.', 'feat.alpha_keys.desc': 'A pool of keys handed out by direct message.',
'feat.role_menus': 'Role menus', 'feat.role_menus': 'Role menus',
'feat.role_menus.desc': 'Self-service roles at the press of a button — on Discord and in the profile.', 'feat.role_menus.desc': 'Self-service roles at the press of a button — on Discord and in the profile.',
'feat.linked_roles': 'Linked roles',
'feat.linked_roles.desc': 'Roles tied to verified numbers — level, playtester, how long someone has been around.',
'feat.applications': 'Applications', 'feat.applications': 'Applications',
'feat.applications.desc': 'Forms as input dialogs, review with accept and reject.', 'feat.applications.desc': 'Forms as input dialogs, review with accept and reject.',
+1
View File
@@ -20,6 +20,7 @@ export const MODULE_ICONS = {
events: IconEvents, playtester: IconGame, alpha_keys: IconKey, events: IconEvents, playtester: IconGame, alpha_keys: IconKey,
role_menus: IconRoles, applications: IconApplications, role_menus: IconRoles, applications: IconApplications,
moderation: IconShield, anti_raid: IconLock, modlog: IconArchive, modmail: IconMail, moderation: IconShield, anti_raid: IconLock, modlog: IconArchive, modmail: IconMail,
linked_roles: IconLink,
tickets: IconSupport, welcome: IconMagic, autorole: IconTeam, tickets: IconSupport, welcome: IconMagic, autorole: IconTeam,
sticky_roles: IconRefresh, bug_reports: IconWarning, sticky_roles: IconRefresh, bug_reports: IconWarning,
server_monitor: IconServer, watchdog: IconMonitor, temp_voice: IconVoice, server_monitor: IconServer, watchdog: IconMonitor, temp_voice: IconVoice,
+13
View File
@@ -4,10 +4,12 @@ import { config } from '../config.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js'; import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId, brandColor, brandFooter, discordGuildId } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js'; import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js'; import { tuning } from '../tuning.js';
import { moduleEnabled } from '../modules.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js'; import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js'; import { registerModTools } from './mod-tools.js';
import { registerAntiRaid } from './anti-raid.js'; import { registerAntiRaid } from './anti-raid.js';
import { registerMetadata, startLinkedRoles } from './linked-roles.js';
import { registerLevels } from './levels.js'; import { registerLevels } from './levels.js';
import { registerPresence } from './presence.js'; import { registerPresence } from './presence.js';
import { registerExtras } from './extras.js'; import { registerExtras } from './extras.js';
@@ -86,6 +88,17 @@ export async function startBot() {
} catch (error) { } catch (error) {
console.error('[bot] Slash-Commands konnten nicht registriert werden:', error.message); console.error('[bot] Slash-Commands konnten nicht registriert werden:', error.message);
} }
// Kennzahlen fuer verknuepfte Rollen anmelden — idempotent, aber ein
// Fehlschlag darf den Start nicht mitnehmen (siehe oben)
if (moduleEnabled('linked_roles')) {
try {
const n = await registerMetadata();
console.log(`[linkedroles] ${n} Kennzahlen bei Discord angemeldet`);
} catch (error) {
console.error('[linkedroles] Kennzahlen nicht angemeldet:', error.message);
}
}
startLinkedRoles(client);
}); });
// Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern // Live-Archivierung: neue Devlogs (Webhook-Posts im Devlog-Kanal) sofort sichern
+149
View File
@@ -0,0 +1,149 @@
// Linked Roles: Rollen, die an geprüfte Daten aus dem Bot hängen.
//
// Der Ablauf ist Discords: Ein Mitglied klickt in Discord auf „Rolle holen",
// landet auf unserer Verifizierungs-Adresse, bestätigt den OAuth-Dialog, und
// wir schieben ihm seine Kennzahlen zu. Discord vergleicht die dann mit dem,
// was du an der Rolle eingestellt hast, und setzt sie oder eben nicht.
//
// Wir vergeben also keine Rollen — wir liefern nur die Zahlen. Was daraus
// wird, entscheidet die Rollen-Einstellung im Server.
import { config } from '../config.js';
import { getLevelRow, isPlaytester, getRoleConnection, saveRoleConnection, allRoleConnections, deleteRoleConnection } from '../db.js';
import { moduleEnabled } from '../modules.js';
import { brandName, discordGuildId } from '../runtime-settings.js';
import { everyTuned } from '../tuning.js';
const API = 'https://discord.com/api/v10';
/**
* Die Kennzahlen, die Discord kennt. Höchstens fünf erlaubt, wir nutzen drei.
*
* Die Typen sind Discords Vergleichs-Codes:
* 2 = Zahl ≥ eingestelltem Wert
* 5 = Datum ≤ „vor X Tagen", also „dabei seit mindestens X Tagen"
* 7 = Wahrheitswert ist wahr
*/
export const METADATA = [
{ key: 'level', name: 'Level', description: 'Mindest-Level im Discord', type: 2 },
{ key: 'dabei_seit', name: 'Dabei seit', description: 'Mitglied seit mindestens X Tagen', type: 5 },
{ key: 'playtester', name: 'Playtester', description: 'Im Playtester-Programm', type: 7 },
];
/** Kennzahlen einmalig bei Discord anmelden — ohne das kennt die Rolle sie nicht */
export async function registerMetadata() {
const res = await fetch(`${API}/applications/${config.discordClientId}/role-connections/metadata`, {
method: 'PUT',
headers: {
Authorization: `Bot ${config.discordToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(METADATA),
});
if (!res.ok) {
throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
}
return METADATA.length;
}
/** Was wir über ein Mitglied wissen — in der Form, die Discord erwartet */
export async function metadataFor(client, userId) {
const level = getLevelRow(userId)?.level ?? 0;
// Beitrittsdatum kommt von Discord, nicht aus unserer Datenbank
let dabeiSeit = null;
const guildId = discordGuildId();
if (guildId) {
const guild = client.guilds.cache.get(guildId);
const member = guild ? await guild.members.fetch(userId).catch(() => null) : null;
dabeiSeit = member?.joinedAt?.toISOString() ?? null;
}
return {
level,
// Fehlt das Datum, lieber weglassen als etwas Falsches behaupten
...(dabeiSeit ? { dabei_seit: dabeiSeit } : {}),
playtester: isPlaytester(userId) ? 1 : 0,
};
}
/** Abgelaufenes Zugriffs-Token erneuern; gibt den gültigen Datensatz zurück */
async function gueltigeTokens(row) {
// Eine Minute Puffer: ein Token, das in zehn Sekunden abläuft, ist wertlos
if (row.expires_at > Date.now() + 60_000) return row;
const res = await fetch(`${API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.discordClientId,
client_secret: config.discordClientSecret,
grant_type: 'refresh_token',
refresh_token: row.refresh_token,
}),
});
if (!res.ok) {
// Widerrufen oder abgelaufen — Verknüpfung ist tot, Datensatz weg
if (res.status === 400 || res.status === 401) {
deleteRoleConnection(row.user_id);
console.warn(`[linkedroles] Verknüpfung von ${row.user_id} widerrufen, entfernt`);
}
return null;
}
const t = await res.json();
const neu = {
user_id: row.user_id,
username: row.username,
access_token: t.access_token,
refresh_token: t.refresh_token,
expires_at: Date.now() + t.expires_in * 1000,
};
saveRoleConnection(neu);
return neu;
}
/** Kennzahlen eines Mitglieds zu Discord schieben */
export async function pushConnection(client, userId) {
const row = getRoleConnection(userId);
if (!row) return false;
const tokens = await gueltigeTokens(row);
if (!tokens) return false;
const res = await fetch(`${API}/users/@me/applications/${config.discordClientId}/role-connection`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${tokens.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
platform_name: brandName(),
platform_username: tokens.username ?? undefined,
metadata: await metadataFor(client, userId),
}),
});
if (!res.ok) {
console.error(`[linkedroles] Push für ${userId} fehlgeschlagen: ${res.status}`);
return false;
}
return true;
}
/**
* Alle Verknüpfungen auffrischen. Nötig, weil Discord die Kennzahlen nicht
* selbst nachfragt — ohne das bliebe jede Rolle auf dem Stand des Tages, an
* dem verknüpft wurde.
*/
export async function syncAll(client) {
if (!moduleEnabled('linked_roles')) return;
const alle = allRoleConnections();
let ok = 0;
for (const row of alle) {
// Nacheinander statt alle auf einmal: bei vielen Mitgliedern liefe
// man sonst in Discords Rate-Limit
if (await pushConnection(client, row.user_id).catch(() => false)) ok++;
}
if (alle.length > 0) console.log(`[linkedroles] ${ok}/${alle.length} Verknüpfungen aufgefrischt`);
}
export function startLinkedRoles(client) {
everyTuned('linked_sync', 'hours', () => syncAll(client), 'linkedroles');
}
+36
View File
@@ -1074,6 +1074,42 @@ export function setSetting(key, value) {
setSettingStmt.run(key, value); setSettingStmt.run(key, value);
} }
// Linked Roles: OAuth-Tokens der Mitglieder, die ihr Konto verknüpft haben.
//
// Das Speichern ist nicht optional — ohne Refresh-Token bliebe die verknüpfte
// Rolle für immer auf dem Stand des Verknüpfungs-Tages stehen, ein Level-Up
// käme nie an. Discords eigene Anleitung sagt dasselbe.
//
// Die Tokens gelten NUR für role_connections.write und identify. Damit kann
// man weder Nachrichten lesen noch im Namen des Users handeln. Trotzdem:
// wer die Datenbank hat, hat diese Tokens — sie liegen im selben Volume wie
// alles andere und werden beim Entkoppeln sofort gelöscht.
db.exec(`
CREATE TABLE IF NOT EXISTS role_connections (
user_id TEXT PRIMARY KEY,
username TEXT,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expires_at INTEGER NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const upsertRoleConnStmt = db.prepare(`
INSERT INTO role_connections (user_id, username, access_token, refresh_token, expires_at, updated_at)
VALUES (@user_id, @username, @access_token, @refresh_token, @expires_at, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
username = excluded.username, access_token = excluded.access_token,
refresh_token = excluded.refresh_token, expires_at = excluded.expires_at,
updated_at = datetime('now')
`);
const getRoleConnStmt = db.prepare('SELECT * FROM role_connections WHERE user_id = ?');
const allRoleConnStmt = db.prepare('SELECT * FROM role_connections');
const deleteRoleConnStmt = db.prepare('DELETE FROM role_connections WHERE user_id = ?');
export const saveRoleConnection = (r) => upsertRoleConnStmt.run(r);
export const getRoleConnection = (userId) => getRoleConnStmt.get(userId) ?? null;
export const allRoleConnections = () => allRoleConnStmt.all();
export const deleteRoleConnection = (userId) => deleteRoleConnStmt.run(userId).changes > 0;
// Einmalige Übernahme der alten kommagetrennten Watchdog-Liste in die // Einmalige Übernahme der alten kommagetrennten Watchdog-Liste in die
// Dienst-Tabelle. Muss hier unten stehen, weil sie getSetting() braucht. // Dienst-Tabelle. Muss hier unten stehen, weil sie getSetting() braucht.
// //
+5
View File
@@ -198,6 +198,11 @@ export const MODULES = [
tab: 'community', default: true, tab: 'community', default: true,
requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }], requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }],
}, },
{
id: 'linked_roles', group: 'community', name: 'Verknüpfte Rollen',
desc: 'Rollen, die an geprüfte Werte hängen — Level, Playtester, Dauer der Mitgliedschaft.',
tab: 'rollen', setting: 'linked_roles_enabled', defaultOn: false,
},
{ {
id: 'sticky_roles', group: 'moderation', name: 'Rollen merken', id: 'sticky_roles', group: 'moderation', name: 'Rollen merken',
desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.', desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.',
+14 -2
View File
@@ -103,6 +103,11 @@ export const TUNING = [
id: 'raid_window', module: 'anti_raid', group: 'grenzen', label: 'Raid — Zeitfenster', id: 'raid_window', module: 'anti_raid', group: 'grenzen', label: 'Raid — Zeitfenster',
unit: 'Sekunden', default: 20, min: 5, max: 600, unit: 'Sekunden', default: 20, min: 5, max: 600,
}, },
{
id: 'linked_sync', module: 'linked_roles', group: 'pruefung', label: 'Verknüpfte Rollen abgleichen alle',
hint: 'Discord fragt die Werte nicht selbst nach — ohne Abgleich bliebe eine Rolle auf dem Stand des Verknüpfungs-Tages.',
unit: 'Stunden', default: 6, min: 1, max: 168,
},
{ {
id: 'raid_lockdown', module: 'anti_raid', group: 'zeiten', label: 'Raid-Sperre dauert', id: 'raid_lockdown', module: 'anti_raid', group: 'zeiten', label: 'Raid-Sperre dauert',
hint: 'Danach hebt der Bot sie von selbst wieder auf.', hint: 'Danach hebt der Bot sie von selbst wieder auf.',
@@ -152,6 +157,7 @@ export function tuning(id) {
export const tuningMs = { export const tuningMs = {
seconds: (id) => tuning(id) * 1000, seconds: (id) => tuning(id) * 1000,
minutes: (id) => tuning(id) * 60 * 1000, minutes: (id) => tuning(id) * 60 * 1000,
hours: (id) => tuning(id) * 60 * 60 * 1000,
}; };
/** /**
@@ -207,14 +213,20 @@ export function tuningStates() {
* festem Abstand könnte das nicht.) * festem Abstand könnte das nicht.)
* *
* @param {string} id — Stellwert * @param {string} id — Stellwert
* @param {'minutes'|'seconds'} unit * @param {'hours'|'minutes'|'seconds'} unit
* @param {() => unknown} task * @param {() => unknown} task
* @param {string} tag — Kennung fürs Fehler-Log * @param {string} tag — Kennung fürs Fehler-Log
* @returns {() => void} Abbrechen * @returns {() => void} Abbrechen
*/ */
export function everyTuned(id, unit, task, tag = id) { export function everyTuned(id, unit, task, tag = id) {
let timer; let timer;
const delay = () => (unit === 'minutes' ? tuningMs.minutes(id) : tuningMs.seconds(id)); // Unbekannte Einheit wäre schlimmer als ein Tippfehler: der Abstand läge
// um Faktor 60 oder 3600 daneben, ohne dass es jemand merkt.
const delay = () => {
const f = tuningMs[unit];
if (!f) throw new Error(`everyTuned(${id}): unbekannte Einheit "${unit}"`);
return f(id);
};
const run = async () => { const run = async () => {
try { try {
await task(); await task();
+136
View File
@@ -0,0 +1,136 @@
// Verifizierungs-Adresse für Linked Roles.
//
// Diese Adresse trägst du im Discord-Portal unter „Linked Roles Verification
// URL" ein. Discord schickt jeden hierher, der eine verknüpfte Rolle holen
// will; wir holen uns die Erlaubnis, schieben die Kennzahlen zurück und
// zeigen eine kurze Bestätigung.
//
// Eigener OAuth-Weg statt /auth/login, weil die Berechtigungen andere sind:
// hier braucht es role_connections.write, das der normale Login nicht hat.
import crypto from 'node:crypto';
import { config } from '../config.js';
import { saveRoleConnection, deleteRoleConnection } from '../db.js';
import { moduleEnabled } from '../modules.js';
import { brandName, publicUrl, botUrl, hubUrl } from '../runtime-settings.js';
import { pushConnection } from '../bot/linked-roles.js';
import { nurUeberHttps } from './auth.js';
const API = 'https://discord.com/api/v10';
const STATE_COOKIE = 'd4rkbot_lr_state';
function basis(request) {
const host = String(request?.headers?.host ?? '').toLowerCase().split(':')[0];
const treffer = [botUrl(), hubUrl(), publicUrl()].find((u) => {
try { return new URL(u).hostname === host; } catch { return false; }
});
return treffer ?? publicUrl();
}
const redirectUri = (request) => `${basis(request)}/linked-roles/callback`;
/** Kleine Antwortseite — hier gibt es keine React-App, das ist ein Zwischenstopp */
function seite(titel, text, ok = true) {
const farbe = ok ? '#3ba55d' : '#f23f43';
return `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${titel}</title>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0a0a0a;
color:#e8e0d0;font-family:system-ui,sans-serif;text-align:center;padding:1.5rem}
.k{max-width:26rem}h1{font-size:1.25rem;margin:0 0 .6rem;color:${farbe}}
p{color:#8a8378;line-height:1.6;margin:0}
small{display:block;margin-top:1.5rem;color:#5f5b54;font-size:.75rem;letter-spacing:.1em}
</style></head><body><div class="k"><h1>${titel}</h1><p>${text}</p>
<small>${brandName()}</small></div></body></html>`;
}
export function registerLinkedRoleRoutes(app, client) {
// Discords Einstiegspunkt
app.get('/linked-roles', async (request, reply) => {
if (!moduleEnabled('linked_roles')) {
return reply.code(503).type('text/html; charset=utf-8')
.send(seite('Nicht aktiv', 'Verknüpfte Rollen sind für diesen Server ausgeschaltet.', false));
}
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: config.discordClientId,
redirect_uri: redirectUri(request),
response_type: 'code',
// identify für den Namen, role_connections.write fürs Zurückschieben
scope: 'identify role_connections.write',
state,
prompt: 'consent',
});
return reply
.setCookie(STATE_COOKIE, state, {
path: '/linked-roles', httpOnly: true, sameSite: 'lax',
maxAge: 600, signed: true, secure: nurUeberHttps(request),
})
.redirect(`https://discord.com/oauth2/authorize?${params}`);
});
app.get('/linked-roles/callback', async (request, reply) => {
const html = (t, x, ok) => reply.type('text/html; charset=utf-8').send(seite(t, x, ok));
const { code, state } = request.query;
const keks = request.cookies[STATE_COOKIE]
? request.unsignCookie(request.cookies[STATE_COOKIE])
: null;
if (!code || !state || !keks?.valid || keks.value !== state) {
return html('Abgebrochen', 'Der Sicherheits-Code passte nicht. Versuch es in Discord noch einmal.', false);
}
reply.clearCookie(STATE_COOKIE, { path: '/linked-roles' });
const tokenRes = await fetch(`${API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.discordClientId,
client_secret: config.discordClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri(request),
}),
});
if (!tokenRes.ok) {
request.log.error(`Linked-Roles-Token fehlgeschlagen: ${tokenRes.status}`);
return html('Fehlgeschlagen', 'Discord hat die Anmeldung abgelehnt. Bitte noch einmal versuchen.', false);
}
const t = await tokenRes.json();
const meRes = await fetch(`${API}/users/@me`, {
headers: { Authorization: `Bearer ${t.access_token}` },
});
if (!meRes.ok) return html('Fehlgeschlagen', 'Deine Discord-Daten waren nicht abrufbar.', false);
const me = await meRes.json();
saveRoleConnection({
user_id: me.id,
username: me.global_name || me.username,
access_token: t.access_token,
refresh_token: t.refresh_token,
expires_at: Date.now() + t.expires_in * 1000,
});
// Direkt losschicken — sonst stünde die Rolle bis zum nächsten
// Auffrischen auf leeren Werten und der Nutzer sähe nichts passieren
const ok = await pushConnection(client, me.id).catch(() => false);
if (!ok) {
// Verknüpfung ist da, nur das Übertragen klemmte — Tokens behalten,
// der nächste Auffrisch-Lauf holt es nach
return html('Fast geschafft', 'Verknüpft, aber die Übertragung deiner Werte hat gehakt. '
+ 'Sie wird beim nächsten Abgleich automatisch nachgeholt.', false);
}
return html('Verknüpft', 'Deine Werte liegen jetzt bei Discord. '
+ 'Du kannst dieses Fenster schließen und in Discord die Rolle holen.');
});
// Entkoppeln: löscht die Tokens bei uns. In Discord selbst nimmt der Nutzer
// die Verknüpfung unter Einstellungen → Autorisierte Apps zurück.
app.post('/api/linked-roles/unlink', async (request, reply) => {
const { getSessionUser } = await import('./auth.js');
const user = getSessionUser(request);
if (!user) return reply.code(401).send({ error: 'nicht angemeldet' });
return { deleted: deleteRoleConnection(user.id) };
});
}
+2
View File
@@ -18,6 +18,7 @@ import { registerApiV1 } from './api-v1.js';
import { registerDevlogEndpoint } from './devlog-endpoint.js'; import { registerDevlogEndpoint } from './devlog-endpoint.js';
import { registerSsoRoutes } from './sso.js'; import { registerSsoRoutes } from './sso.js';
import { registerBrandRoutes } from './brand.js'; import { registerBrandRoutes } from './brand.js';
import { registerLinkedRoleRoutes } from './linked-roles.js';
import { imagesDir } from '../bot/devlog-archive.js'; import { imagesDir } from '../bot/devlog-archive.js';
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden, // Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
@@ -71,6 +72,7 @@ export async function startWebServer(client) {
registerDevlogEndpoint(app, client); registerDevlogEndpoint(app, client);
registerSsoRoutes(app, client); registerSsoRoutes(app, client);
registerBrandRoutes(app); registerBrandRoutes(app);
registerLinkedRoleRoutes(app, client);
// Healthcheck (für Portainer/NPM) // Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' })); app.get('/health', async () => ({ status: 'ok' }));