Add multi-guild Discord OAuth support

- Users can now login via Bacanaks OR Piccadilly Discord server
- Highest role from all servers is used (superadmin > moderator > user)
- Lazy initialization fixes env loading timing issue
- Updated documentation with implementation details and troubleshooting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-08 00:30:15 +01:00
parent 4fcc111def
commit 20ba93b26f
3 changed files with 260 additions and 31 deletions

23
auth.js
View File

@@ -2,7 +2,7 @@ import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js';
import { authenticateToken, requireRole } from '../middleware/auth.js';
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getUserRole } from '../services/discord.js';
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js';
const router = Router();
@@ -38,15 +38,18 @@ router.get('/discord/callback', async (req, res) => {
// Get Discord user info
const discordUser = await getDiscordUser(tokenData.access_token);
// Check if user is in the guild
const member = await getGuildMember(discordUser.id);
// Check if user is in any of the configured guilds
const memberships = await getGuildMemberships(discordUser.id);
if (!member) {
if (!memberships) {
return res.redirect(`${frontendUrl}/login?error=not_in_guild`);
}
// Determine role based on Discord roles
const role = getUserRole(member.roles);
// Determine role based on Discord roles (highest role from all servers)
const role = getUserRoleFromMemberships(memberships);
// Use first membership for display name
const member = memberships[0].member;
// Get display name (nickname or username)
const displayName = member.nick || discordUser.global_name || discordUser.username;
@@ -126,13 +129,13 @@ router.post('/refresh-role', authenticateToken, async (req, res) => {
}
try {
const member = await getGuildMember(req.user.discordId);
const memberships = await getGuildMemberships(req.user.discordId);
if (!member) {
return res.status(403).json({ error: 'No longer in guild' });
if (!memberships) {
return res.status(403).json({ error: 'No longer in any guild' });
}
const newRole = getUserRole(member.roles);
const newRole = getUserRoleFromMemberships(memberships);
db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?')
.run(newRole, req.user.discordId);

View File

@@ -1,6 +1,29 @@
// Discord OAuth2 Service
const DISCORD_API = 'https://discord.com/api/v10';
// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv)
let _guildConfigs = null;
function getGuildConfigs() {
if (_guildConfigs === null) {
_guildConfigs = [
{
name: 'Bacanaks',
guildId: process.env.DISCORD_GUILD_ID_1,
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1,
modRoleId: process.env.DISCORD_MOD_ROLE_ID_1
},
{
name: 'Piccadilly',
guildId: process.env.DISCORD_GUILD_ID_2,
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2,
modRoleId: process.env.DISCORD_MOD_ROLE_ID_2
}
].filter(config => config.guildId);
}
return _guildConfigs;
}
export function getDiscordAuthUrl() {
const params = new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
@@ -48,9 +71,10 @@ export async function getDiscordUser(accessToken) {
return response.json();
}
export async function getGuildMember(userId) {
// Prüft einen einzelnen Server
async function fetchGuildMember(guildId, userId) {
const response = await fetch(
`${DISCORD_API}/guilds/${process.env.DISCORD_GUILD_ID}/members/${userId}`,
`${DISCORD_API}/guilds/${guildId}/members/${userId}`,
{
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`
@@ -60,17 +84,78 @@ export async function getGuildMember(userId) {
if (!response.ok) {
if (response.status === 404) {
return null; // User not in guild
return null;
}
throw new Error('Failed to get guild member');
throw new Error(`Failed to get guild member from ${guildId}`);
}
return response.json();
}
// Prüft alle konfigurierten Server und gibt Memberships zurück
export async function getGuildMemberships(userId) {
const configs = getGuildConfigs();
const memberships = [];
for (const config of configs) {
try {
const member = await fetchGuildMember(config.guildId, userId);
if (member) {
memberships.push({
config,
member,
roles: member.roles || []
});
}
} catch (err) {
console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message);
}
}
return memberships.length > 0 ? memberships : null;
}
// Legacy-Funktion für Kompatibilität
export async function getGuildMember(userId) {
const memberships = await getGuildMemberships(userId);
if (!memberships || memberships.length === 0) {
return null;
}
return memberships[0].member;
}
// Rollen-Priorität: superadmin > moderator > user
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
// Bestimmt die höchste Rolle aus allen Server-Memberships
export function getUserRoleFromMemberships(memberships) {
if (!memberships || memberships.length === 0) {
return 'user';
}
let highestRole = 'user';
for (const { config, roles } of memberships) {
let role = 'user';
if (roles.includes(config.adminRoleId)) {
role = 'superadmin';
} else if (roles.includes(config.modRoleId)) {
role = 'moderator';
}
if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) {
highestRole = role;
}
}
return highestRole;
}
// Legacy-Funktion für Kompatibilität
export function getUserRole(memberRoles) {
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID;
const modRoleId = process.env.DISCORD_MOD_ROLE_ID;
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1;
const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1;
if (memberRoles.includes(adminRoleId)) {
return 'superadmin';

View File

@@ -49,13 +49,15 @@ Wenn der Bot einem Server beitritt, erstellt er automatisch folgende Struktur:
| Channel | @everyone | Bot |
|---------|-----------|-----|
| Kategorie | Lesen | Schreiben |
| info | Lesen | Schreiben |
| status | Lesen | Schreiben |
| alerts | Lesen | Schreiben |
| updates | Lesen | Schreiben |
| diskussion | Lesen + Schreiben | Schreiben |
| requests | Lesen + Threads erstellen | Schreiben |
| Kategorie | Lesen | ViewChannel + Schreiben |
| info | Lesen (kein Schreiben) | ViewChannel + Schreiben |
| status | Lesen (kein Schreiben) | ViewChannel + Schreiben |
| alerts | Lesen (kein Schreiben) | ViewChannel + Schreiben |
| updates | Lesen (kein Schreiben) | ViewChannel + Schreiben |
| diskussion | Lesen + Schreiben | ViewChannel + Schreiben |
| requests | Lesen + Threads erstellen | ViewChannel + Schreiben |
**Wichtig**: Der Bot braucht explizit `ViewChannel` Permission für jeden Channel, auch wenn @everyone den Channel sehen kann. Ohne `ViewChannel` kann der Bot nicht in den Channel schreiben ("Missing Access" Fehler).
## Datenbank
@@ -80,6 +82,8 @@ CREATE TABLE guild_settings (
);
```
Die Datenbank liegt in `backend/db/users.sqlite`.
### DB-Funktionen
In `backend/db/init.js`:
@@ -137,12 +141,43 @@ Jeder Server bekommt ein eigenes Embed mit:
DISCORD_CLIENT_ID=1458251194806833306
DISCORD_CLIENT_SECRET=xxx
DISCORD_BOT_TOKEN=xxx
DISCORD_GUILD_ID=729865854329815051 # Haupt-Server für Login
DISCORD_ADMIN_ROLE_ID=1024693717434650736
DISCORD_MOD_ROLE_ID=1024693170958766141
# Multi-Server OAuth Login
# User muss nur in EINEM der Server Mitglied sein
# Rollen werden pro Server geprüft, höchste Berechtigung zählt
# Server 1: Bacanaks
DISCORD_GUILD_ID_1=729865854329815051
DISCORD_ADMIN_ROLE_ID_1=1024693717434650736
DISCORD_MOD_ROLE_ID_1=1024693170958766141
# Server 2: Piccadilly
DISCORD_GUILD_ID_2=730907665802330224
DISCORD_ADMIN_ROLE_ID_2=1458595551514988584
DISCORD_MOD_ROLE_ID_2=1458591909210488914
```
**Hinweis**: `DISCORD_GUILD_ID` wird nur für den Discord OAuth Login verwendet, nicht für den Bot selbst.
**Hinweis**: Die Guild-IDs werden nur für den Discord OAuth Login verwendet, nicht für den Bot selbst.
### OAuth Login-Logik
```
1. User loggt sich via Discord OAuth ein
2. Für jeden konfigurierten Server:
- Ist User Mitglied? → Rollen prüfen
- Admin-Rolle → superadmin
- Mod-Rolle → moderator
- Nur Mitglied → user
3. Höchste Berechtigung aus allen Servern wird verwendet
4. Nicht in mindestens einem Server → Login verweigert
```
| User ist in... | Bacanaks Rolle | Piccadilly Rolle | GSM Rolle |
|----------------|----------------|------------------|-----------|
| Nur Bacanaks | Admin | - | superadmin |
| Nur Piccadilly | - | Mod | moderator |
| Beide | Mitglied | Admin | superadmin |
| Keinem | - | - | ❌ Kein Login |
### Developer Portal Einstellungen
@@ -158,12 +193,84 @@ DISCORD_MOD_ROLE_ID=1024693170958766141
| Datei | Beschreibung |
|-------|--------------|
| `backend/services/discord.js` | OAuth-Logik, Multi-Guild Membership-Prüfung |
| `backend/services/discordBot.js` | Bot-Logik und Event-Handler |
| `backend/routes/auth.js` | Auth-Endpoints (Login, Callback, Refresh) |
| `backend/db/init.js` | Guild-Settings DB-Funktionen |
| `frontend/src/pages/Dashboard.jsx` | Invite-Button im Dashboard |
## Implementierungsdetails
### Multi-Guild OAuth
Die OAuth-Implementierung in `discord.js` verwendet **lazy initialization** für die Guild-Konfigurationen:
```javascript
let _guildConfigs = null;
function getGuildConfigs() {
if (_guildConfigs === null) {
_guildConfigs = [
{ name: 'Bacanaks', guildId: process.env.DISCORD_GUILD_ID_1, ... },
{ name: 'Piccadilly', guildId: process.env.DISCORD_GUILD_ID_2, ... }
].filter(config => config.guildId);
}
return _guildConfigs;
}
```
**Wichtig**: Die Konfiguration darf NICHT beim Modul-Import initialisiert werden, da zu diesem Zeitpunkt dotenv die `.env` noch nicht geladen hat. Die lazy initialization stellt sicher, dass die Umgebungsvariablen verfügbar sind.
### Funktionen
| Funktion | Beschreibung |
|----------|--------------|
| `getGuildMemberships(userId)` | Prüft alle konfigurierten Server, gibt Array von Memberships zurück |
| `getUserRoleFromMemberships(memberships)` | Bestimmt höchste Rolle aus allen Memberships |
| `getGuildMember(userId)` | Legacy-Funktion, gibt ersten Match zurück |
| `getUserRole(memberRoles)` | Legacy-Funktion für einzelne Rollen-Liste |
### Rollen-Priorität
```javascript
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
```
Bei mehreren Memberships wird immer die höchste Rolle verwendet.
### Voraussetzungen
- Der Bot muss auf **allen** konfigurierten Discord-Servern sein
- Der Bot braucht Zugriff auf die Guild Members API
## Troubleshooting
### Login schlägt fehl mit "nicht Mitglied"
1. **Bot auf allen Servern?** Der Bot muss auf Bacanaks UND Piccadilly eingeladen sein
2. **Env-Variablen prüfen**:
```bash
grep GUILD /opt/gameserver-monitor/backend/.env
```
3. **Mit --update-env neustarten**:
```bash
pm2 restart gameserver-backend --update-env
```
4. **Logs prüfen**:
```bash
pm2 logs gameserver-backend --lines 30 | grep -i discord
```
### Rolle wird nicht erkannt
1. **Rollen-IDs prüfen** - Discord Developer Mode aktivieren, Rechtsklick auf Rolle → ID kopieren
2. **User-Rollen abfragen**:
```bash
curl -s -H "Authorization: Bot BOT_TOKEN" \
"https://discord.com/api/v10/guilds/GUILD_ID/members/USER_ID" | jq '.roles'
```
3. **Konfigurierte IDs vergleichen** mit den tatsächlichen Rollen des Users
### Bot erstellt keine Channels
- Prüfen ob Bot "Manage Channels" Permission hat
@@ -180,18 +287,52 @@ Suche nach `[DiscordBot]` Log-Einträgen.
### Bot aus Datenbank entfernen
```bash
sqlite3 /opt/gameserver-monitor/backend/users.sqlite
cd /opt/gameserver-monitor/backend
node -e "
import Database from 'better-sqlite3';
const db = new Database('./db/users.sqlite');
db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run('GUILD_ID_HIER');
console.log('Deleted');
"
```
Alternativ mit sqlite3 (falls installiert):
```bash
sqlite3 /opt/gameserver-monitor/backend/db/users.sqlite
DELETE FROM guild_settings WHERE guild_id = 'xxx';
```
### Missing Access Fehler
Wenn der Bot "Missing Access" meldet obwohl er eingeladen wurde:
1. **ViewChannel Permission prüfen**: Der Bot braucht explizit `ViewChannel` für jeden Channel
2. Im Discord: Rechtsklick auf Channel → Bearbeiten → Berechtigungen → Bot auswählen → "Kanal ansehen" aktivieren
3. Logs prüfen: `pm2 logs gameserver-backend --lines 50 | grep -i "missing\|access"`
### Status-Nachricht wurde gelöscht
Wenn die Status-Nachricht manuell gelöscht wurde, erscheint "Unknown Message" in den Logs. Fix:
```bash
cd /opt/gameserver-monitor/backend
node -e "
import Database from 'better-sqlite3';
const db = new Database('./db/users.sqlite');
// Status Message ID auf NULL setzen, Bot erstellt neue beim nächsten Update
db.prepare('UPDATE guild_settings SET status_message_id = NULL WHERE guild_id = ?').run('GUILD_ID_HIER');
console.log('Reset status_message_id');
"
```
## Login vs. Bot
| Feature | Login (OAuth) | Bot |
|---------|--------------|-----|
| Erfordert Mitgliedschaft | Haupt-Discord | Nein |
| Erfordert Mitgliedschaft | Bacanaks oder Piccadilly | Nein |
| Server-Steuerung | Ja (je nach Rolle) | Nein |
| Status sehen | Ja | Ja |
| Alerts erhalten | Nein | Ja |
| Verfügbar für | Haupt-Discord Mitglieder | Alle mit Bot |
| Verfügbar für | Mitglieder beider Discord-Server | Alle mit Bot |
Der Login zur Webapp erfordert Mitgliedschaft im Haupt-Discord-Server (DISCORD_GUILD_ID). Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.
Der Login zur Webapp erfordert Mitgliedschaft in mindestens einem der konfigurierten Discord-Server (Bacanaks oder Piccadilly). Die höchste Rolle aus beiden Servern bestimmt die GSM-Berechtigung. Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.